fix(phase-26): jtbl_carve trims trailing .align pad words (§8a-pad)

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 <n>` 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).
This commit is contained in:
Drew T
2026-07-13 15:23:59 -06:00
parent f89fd50afb
commit b0691f4bd9
2 changed files with 65 additions and 2 deletions
+19
View File
@@ -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/<ov>/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 <n>` 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:
+46 -2
View File
@@ -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_<hex>`, 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_<hex>: end = next data dlabel, else the region end."""
"""(start_vram, end_vram) of a RAW jtbl_<hex>: 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 <n>` 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):