From b0691f4bd98c810aea40fd8deb59503f141fe9aa Mon Sep 17 00:00:00 2001 From: Drew T <50529377+Druthulu@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:23:59 -0600 Subject: [PATCH] =?UTF-8?q?fix(phase-26):=20jtbl=5Fcarve=20trims=20trailin?= =?UTF-8?q?g=20.align=20pad=20words=20(=C2=A78a-pad)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trailing `.word 0x00000000` under a jtbl dlabel is the ORIGINAL TU's intra-rdata .align 3 padding, NOT a table entry (0x00000000 is not a jump target). The true entry count is the fn's `sltiu ` bound: func_8015AE2C has sltiu 0x7 = 7 entries yet its raw dlabel spans 8 words. maspsx drops all .align, so a C-emitted jump table can never reproduce the pad. Carving to the next dlabel would reserve 8 words while the compiled object supplies 7 -> .rodata under-fills by 4 B -> every later symbol shifts +4 (the same image-corruption class as §41d). jtbl_range now trims trailing zero words, leaving the pad in the raw post-carve data piece. Retroactively explains the §8a func_80159C84 '5 words vs the real 6' false-MATCH. Existing carves are parsed from CONFIG, not re-derived, so committed banks are unaffected (verified: the 3 carved jtbls are absent from the raw data asm). The build never invokes jtbl_carve, so the fleet is inert to this change until the next bank. Found by the Fable5 crack of func_8015AE2C (562 ins x134, MATCH, pin-free). --- docs/matching-cookbook.md | 19 ++++++++++++++++ tools/jtbl_carve.py | 48 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/docs/matching-cookbook.md b/docs/matching-cookbook.md index 88199484c..f829c0642 100644 --- a/docs/matching-cookbook.md +++ b/docs/matching-cookbook.md @@ -362,6 +362,25 @@ a 25-ins single-jtbl jr-function in ov_SC01_077):** the sibling's jtbl address (a tool over `family_sweep`), then reconcile+template the body per sibling. The PoC proves the per-binary mechanism; the fleet rollout is the mechanical generator. +### §8a-pad — a trailing `.word 0x00000000` under a jtbl dlabel is `.align` PAD, not an entry (Phase 26 session 6, byte-proven) + +**This retroactively explains the §8a `func_80159C84` "5 words vs the real 6" false-MATCH.** + +The raw `dlabel jtbl_XXXXXXXX` in `asm//data/*.data.s` can span one word MORE than the switch has +cases. That last `.word 0x00000000` is the ORIGINAL TU's intra-rdata **`.align 3` padding** — emitted when a +jump table's entries end ≡4 mod 8 and another jtbl of the same TU follows. It cannot be a table entry: +`0x00000000` is not a jump target. + +- **The true entry count is the function's `sltiu ` range check**, not the dlabel span. Byte-confirmed: + `func_8015AE2C` → `sltiu $v0, $v1, 0x7` = **7** entries, yet its raw dlabel spans **8** words. +- **maspsx drops all `.align`** (maspsx.py:435), so a C-emitted jump table can NEVER reproduce the pad. +- **Therefore `jtbl_carve` must TRIM trailing zero words** from the carve range, leaving the pad in the raw + post-carve `data` piece. Carving to the next dlabel reserves 8 words while the compiled object supplies + only 7 → the `.rodata` piece under-fills by 4 bytes → **every later symbol shifts +4** (the same image + corruption class as §41d: ~271k differing bytes from one missing word). Trimming is always safe. +- Existing carves are parsed from the CONFIG (their `end` = the next piece's offset), not re-derived from + the data asm, so the trim only affects NEW carves — committed banks are unaffected. + ## §8b MULTI-jtbl per overlay — the `ld_interleave --order` sandwich + the same-subseg cases (Phase 26 session 4) Once ONE jr-function is banked in an overlay, banking a SECOND makes it multi-jtbl (§8a's single-carve breaks: `jtbl_family_bank.revert()` restores the committed config = already has carve #1). The generalization: diff --git a/tools/jtbl_carve.py b/tools/jtbl_carve.py index d9d8ecd11..baebfe86f 100644 --- a/tools/jtbl_carve.py +++ b/tools/jtbl_carve.py @@ -100,14 +100,58 @@ def all_data_labels(ov): return sorted(labels) +def jtbl_words(ov, jtbl_hex): + """The raw `.word` values under `dlabel jtbl_`, in order.""" + pat = re.compile(rf"dlabel\s+jtbl_{jtbl_hex}\b", re.I) + for p in glob.glob(os.path.join(REPO, "asm", ov, "data", "*.data.s")): + lines = open(p).read().split("\n") + for i, ln in enumerate(lines): + if pat.search(ln): + out = [] + for ln2 in lines[i + 1:]: + m = re.search(r"\.word\s+(0x[0-9A-Fa-f]+)", ln2) + if m: + out.append(int(m.group(1), 16)) + continue + if re.search(r"\b(?:dlabel|glabel|enddlabel)\b", ln2): + break + return out + return [] + + def jtbl_range(ov, jtbl_hex, labels, region_end_vram): - """(start_vram, end_vram) of a RAW jtbl_: end = next data dlabel, else the region end.""" + """(start_vram, end_vram) of a RAW jtbl_: end = the next data dlabel, MINUS any trailing + zero words. + + A trailing `.word 0x00000000` under a jtbl dlabel is NOT a table entry — it is the original TU's + intra-rdata **`.align 3` padding** (a jtbl whose entries end ≡4 mod 8, with another jtbl of the + same TU following, gets one zero word of alignment fill). It cannot be an entry: 0x00000000 is + not a jump target, and the function's `sltiu ` range check names the true entry count + (byte-confirmed: `func_8015AE2C` → `sltiu 0x7` = 7 entries, yet the raw dlabel spans 8 words). + + This matters because **maspsx drops `.align`**, so a C-emitted jump table can never reproduce the + pad. Carving to the next dlabel would reserve 8 words while the compiled object supplies only 7 — + under-filling the `.rodata` piece by 4 bytes and shifting every later symbol (the same +4 image + corruption class as §41d). Trimming leaves the pad where it belongs: in the raw post-carve data + piece. This also retroactively explains the §8a `func_80159C84` "5 words vs the real 6" + false-MATCH.""" start = int(jtbl_hex, 16) if start not in labels: sys.exit(f"jtbl_carve: jtbl_{jtbl_hex} not found in the raw data asm " f"(asm/{ov}/data/*.data.s) — already carved / stale asm? re-extract or --revert first") nxt = next((a for a in labels if a > start), None) - return start, (nxt if nxt is not None else region_end_vram) + end = nxt if nxt is not None else region_end_vram + words = jtbl_words(ov, jtbl_hex) + if words: + n = len(words) + while n > 0 and words[n - 1] == 0: + n -= 1 + trimmed = start + n * 4 + if trimmed < end: + print(f"jtbl_carve: jtbl_{jtbl_hex}: trimmed {(end - trimmed) // 4} trailing .align pad " + f"word(s) — {n} real entries") + end = trimmed + return start, end def parse_config(ov):