From 5e78dccf07be33a5ff97ede386d9c63d857ec753 Mon Sep 17 00:00:00 2001 From: Drew T <50529377+Druthulu@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:03:48 -0600 Subject: [PATCH] =?UTF-8?q?feat(phase-29=20=C2=A78e):=20jtbl=208-align=20p?= =?UTF-8?q?ad-spec=20filter=20=E2=80=94=20the=204-giant=20unblock=20toolin?= =?UTF-8?q?g=20(fleet-neutral)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ROOT CAUSE PINNED (the session-2 half-pin was INVERTED; both probes were vacuous, R35): cc1 emits .align 3 before EVERY jump table; maspsx passes it VERBATIM (the :435 'drop' is an inventory-only pass); as bakes the pad SECTION-RELATIVE; link placement was never guilty (SUBALIGN(2) + ALIGN(.,4) place 4-mod-8 carve starts tight). Merging originally-separate TUs fires an intra-TU align where the original packed tight -> +4 at rodata 0xCC -> image-wide %lo shift. Honest probes persisted: .run/probe_jtbl/ (verdict.md + objdumps). - NEW tools/jtbl_rodata_pads.py: post-maspsx filter replaces each rodata .align 3 with the ORIGINAL's exact pad bytes per a JTBL_PADS spec; fail-loud on table-count drift / non-align-3 / non-jtbl rodata content. Byte-proven: verbatim 0xE4 pad-at-0xCC -> filtered 0xE0 tight (= the merged carve span). - jtbl_carve.py: spec-aware same-subseg merge (gap 0 or 4-with-zero-payload-word; else NON-CONTIGUOUS -> isolate), interval-arithmetic pad specs (committed values CARRIED, never re-derived), JTBL_PADS target-var emission into overlays.mk + revert() restore + stale-.o invalidation; the false 'maspsx drops .align' docstring corrected (H5). - Makefile: $(if $(JTBL_PADS),| jtbl_rodata_pads.py ...) stage in build/src/%.o + file-scope empty default (env-shield). jtbl_family_bank.stub_file: duplicate-stub fail-loud (the earlier 'ladder failure' was a wrong-TU splice into a stale _a.c stub, byte-witnessed). - R22 clean-fleet WITH the fix wired: 140/140 byte-identical, tools-health green (dedup 1846/0, C1 234205/234205), ZERO new banks -- fleet-neutral by construction. - cookbook §8e (the jtbl alignment law) + §8a/§8a-pad corrections; decision-log R31 entry; SETUP.md tool row; .gitignore allowlist for the probe verdict artifacts. --- .gitignore | 5 + .run/probe_jtbl/rodata_filtered.objdump | 20 +++ .run/probe_jtbl/rodata_verbatim.objdump | 22 +++ .run/probe_jtbl/verdict.md | 33 +++++ Makefile | 13 +- docs/SETUP.md | 1 + docs/decision-log.md | 39 +++++ docs/matching-cookbook.md | 53 ++++++- phase-ends/CURRENT_PHASE.md | 34 +++++ tools/jtbl_carve.py | 184 ++++++++++++++++++++---- tools/jtbl_family_bank.py | 15 +- tools/jtbl_rodata_pads.py | 104 ++++++++++++++ 12 files changed, 490 insertions(+), 33 deletions(-) create mode 100644 .run/probe_jtbl/rodata_filtered.objdump create mode 100644 .run/probe_jtbl/rodata_verbatim.objdump create mode 100644 .run/probe_jtbl/verdict.md create mode 100644 tools/jtbl_rodata_pads.py diff --git a/.gitignore b/.gitignore index 0107abc93..76f12bb1d 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,11 @@ !/.run/giants/*.c !/.run/giants/*.md !/.run/giants/*.sh +!/.run/probe_jtbl/ +/.run/probe_jtbl/* +!/.run/probe_jtbl/verdict.md +!/.run/probe_jtbl/rodata_verbatim.objdump +!/.run/probe_jtbl/rodata_filtered.objdump !/.run/giants/fable_cd4/ /.run/giants/fable_cd4/* !/.run/giants/fable_cd4/*.c diff --git a/.run/probe_jtbl/rodata_filtered.objdump b/.run/probe_jtbl/rodata_filtered.objdump new file mode 100644 index 000000000..ac805cb9f --- /dev/null +++ b/.run/probe_jtbl/rodata_filtered.objdump @@ -0,0 +1,20 @@ + +.run/probe_jtbl/tu_filtered.o: file format elf32-tradlittlemips + +Contents of section .rodata: + 0000 50000000 50000000 50000000 50000000 P...P...P...P... + 0010 50000000 50000000 50000000 50000000 P...P...P...P... + 0020 50000000 50000000 50000000 50000000 P...P...P...P... + 0030 50000000 50000000 50000000 50000000 P...P...P...P... + 0040 50000000 50000000 50000000 50000000 P...P...P...P... + 0050 50000000 50000000 50000000 50000000 P...P...P...P... + 0060 50000000 50000000 50000000 50000000 P...P...P...P... + 0070 50000000 50000000 50000000 50000000 P...P...P...P... + 0080 50000000 50000000 50000000 50000000 P...P...P...P... + 0090 50000000 50000000 50000000 50000000 P...P...P...P... + 00a0 50000000 50000000 50000000 50000000 P...P...P...P... + 00b0 50000000 50000000 50000000 50000000 P...P...P...P... + 00c0 48000000 48000000 48000000 60670000 H...H...H...`g.. + 00d0 60670000 54690000 6c6a0000 146c0000 `g..Ti..lj...l.. + [ 9] .rodata PROGBITS 00000000 00f6f0 0000e0 00 A 0 0 4 + [10] .rel.rodata REL 00000000 017abc 0001c0 08 I 12 9 4 diff --git a/.run/probe_jtbl/rodata_verbatim.objdump b/.run/probe_jtbl/rodata_verbatim.objdump new file mode 100644 index 000000000..549158b52 --- /dev/null +++ b/.run/probe_jtbl/rodata_verbatim.objdump @@ -0,0 +1,22 @@ + +.run/probe_jtbl/tu_verbatim.o: file format elf32-tradlittlemips + +Contents of section .rodata: + 0000 50000000 50000000 50000000 50000000 P...P...P...P... + 0010 50000000 50000000 50000000 50000000 P...P...P...P... + 0020 50000000 50000000 50000000 50000000 P...P...P...P... + 0030 50000000 50000000 50000000 50000000 P...P...P...P... + 0040 50000000 50000000 50000000 50000000 P...P...P...P... + 0050 50000000 50000000 50000000 50000000 P...P...P...P... + 0060 50000000 50000000 50000000 50000000 P...P...P...P... + 0070 50000000 50000000 50000000 50000000 P...P...P...P... + 0080 50000000 50000000 50000000 50000000 P...P...P...P... + 0090 50000000 50000000 50000000 50000000 P...P...P...P... + 00a0 50000000 50000000 50000000 50000000 P...P...P...P... + 00b0 50000000 50000000 50000000 50000000 P...P...P...P... + 00c0 48000000 48000000 48000000 00000000 H...H...H....... + 00d0 60670000 60670000 54690000 6c6a0000 `g..`g..Ti..lj.. + 00e0 146c0000 .l.. + [Nr] Name Type Addr Off Size ES Flg Lk Inf Al + [ 9] .rodata PROGBITS 00000000 00f6f0 0000e4 00 A 0 0 8 + [10] .rel.rodata REL 00000000 017ac0 0001c0 08 I 12 9 4 diff --git a/.run/probe_jtbl/verdict.md b/.run/probe_jtbl/verdict.md new file mode 100644 index 000000000..a8f2487e6 --- /dev/null +++ b/.run/probe_jtbl/verdict.md @@ -0,0 +1,33 @@ +# jtbl 8-align probe — the HONEST stage-walk (2026-07-18, replaces the vacuous probe_jtbl_{cc1,maspsx}.s) + +**Target:** func_80131340 (424 ins) draft spliced over its INCLUDE_ASM stub in the committed +src/ov_SC01_077/ov_SC01_077_jr_8012ACE0.c (probe copy: tu_spliced.c). Real pipeline, real flags. + +## Verdict — the session-2 checkpoint's half-pin ("cc1 AND maspsx emit .align 2") is INVERTED + +| stage | observable | result | +|---|---|---| +| cc1 (Sony gcc 2.7.2) | directives before EACH jump table | `.rdata` + **`.align 3`** + label (cc1.s L46-48 → $L15/51 words = jtbl_801D8078; L10578-80 → $L810/5 words = jtbl_801D8144). Exactly one `.align 3` per table, exactly 2 tables in the TU. | +| maspsx (pinned 874855c) | `.align` handling | **passed through VERBATIM** (maspsx.s L40-42, L9312-14; `.rdata`→`.section .rodata` only). The `continue` at maspsx/__init__.py:435-437 is inventory-only (`preprocess_lines`); the output path catch-all (L872-873) re-emits `.align`. | +| GNU as 2.42 | .rodata content | **size 0xE4, sh_addralign=8, ZERO PAD WORD AT OFFSET 0xCC** (rodata_verbatim.objdump: `00c0 48000000 48000000 48000000 00000000` then table 2 at 0xD0). The +4 interior pad, baked into the object — the linker can never remove it. | +| as control | `.section .rodata`+`.word` alone → Al=**4** (ctrl.o); +`.align 3` → Al=**8** (ctrl2.o) | the Al=8 is cc1's directive, NOT an as default. | + +## Why this breaks the image +Original layout (asm/ov_SC01_077/data/tail2.data.s): jtbl_801D8078's 51 entries end 0x801D8144 exactly +where jtbl_801D8144 begins — originally-SEPARATE TUs, packed TIGHT by the original linker. Merging both +into one decomp TU makes cc1's intra-TU `.align 3` fire at 0xCC (%8==4) where the original had a tight TU +boundary → every downstream data symbol +4 → %lo relocs break image-wide (.run/build_g2.log SHA1 FAIL). +Intra-TU pads ARE real elsewhere (jtbl_801D8158/8170/8188/81A0: 5 entries + one 0x00000000 each, every +non-first table landing %8==0) — so the align must be REPLACED by the original's exact pad bytes +(pad ∈ {0,4} from carve-interval arithmetic), not blanket-dropped. Isolation is not general: as computes +`.align` SECTION-RELATIVE, so a 4-mod-8-start multi-table object would mis-pad internally. + +**Fix:** tools/jtbl_rodata_pads.py post-maspsx filter, per-object JTBL_PADS spec from jtbl_carve. +For this TU the spec is `0,0` → expected filtered .rodata = 0xE0, table 2 tight at 0xCC (= the merged +carve span 0xaff20..0xB0000). + +## CONFIRMED (same session): the filter's byte proof +`maspsx.s | jtbl_rodata_pads.py --pads 0,0 | as` → `.rodata` size **0xE0**, Al=4, table 2 TIGHT at +0xCC (rodata_filtered.objdump) = the merged carve span 0xaff20..0xB0000 exactly. Guards verified +fail-loud (1-spec / 3-spec / pads[0]=4 all rc=1 with attributable messages). The pad=4 direction +verified synthetically (`.word 0` emitted in place of the align). diff --git a/Makefile b/Makefile index c1d196fe5..55777a307 100644 --- a/Makefile +++ b/Makefile @@ -436,6 +436,12 @@ ASPSX_VERSION := 2.56 # maspsx leaves a bare divu and div/rem functions never match. Only affects div/rem, # so the all-INCLUDE_ASM build and div-free functions are unchanged. MASPSX_FLAGS := --expand-div +# Phase-29 §8e: per-object jump-table pad spec (tools/jtbl_rodata_pads.py). Set ONLY as a +# target-specific var by tools/jtbl_carve.py in config/overlays.mk for multi-table .rodata +# carve spans; the file-scope empty default shields the recipe from an inherited environment +# variable accidentally arming the filter fleet-wide (a plain `JTBL_PADS=... make` would +# otherwise become a global make var). Unset => the compile pipeline is byte-unchanged. +JTBL_PADS := # Object set must match the splat linker script's references. After the Phase-6 asm->c # flip the text subseg is src/800.c -> build/src/800.o; the per-function @@ -539,10 +545,15 @@ build/assets/%.o: assets/%.bin # fingerprint ladder PINS the triple (then this block + ASPSX_VERSION are updated, G8). CPPFLAGS := -lang-c -Iinclude -undef -Wall -fno-builtin -Dmips -D__GNUC__=2 -D__OPTIMIZE__ -Dpsx -D_PSYQ -D_MIPSEL -D_LANGUAGE_C CC1FLAGS := -quiet -O2 -G0 -mips1 -mcpu=3000 -mgas -msoft-float -fgnu-linker +# The optional jtbl_rodata_pads stage (Phase-29 §8e) is inserted only when the object has a +# JTBL_PADS target-specific var (written by tools/jtbl_carve.py for multi-table .rodata carve +# spans): it replaces cc1's per-table `.align 3` with the ORIGINAL's exact pad bytes, so a merged +# span reproduces the original packing regardless of section-start parity. Unset => stage absent, +# pipeline byte-identical to pre-§8e. build/src/%.o: src/%.c @mkdir -p $(dir $@) @echo " CC $@" - @set -o pipefail; $(CPP) $(CPPFLAGS) -MMD -MP -MT $@ -MF $(@:.o=.d) $< | $(CC1_PSX) $(CC1FLAGS) | $(VENV_PY) $(MASPSX) --aspsx-version=$(ASPSX_VERSION) $(MASPSX_FLAGS) | $(AS) $(ASFLAGS) -o $@ + @set -o pipefail; $(CPP) $(CPPFLAGS) -MMD -MP -MT $@ -MF $(@:.o=.d) $< | $(CC1_PSX) $(CC1FLAGS) | $(VENV_PY) $(MASPSX) --aspsx-version=$(ASPSX_VERSION) $(MASPSX_FLAGS) $(if $(JTBL_PADS),| $(VENV_PY) tools/jtbl_rodata_pads.py --pads $(JTBL_PADS)) | $(AS) $(ASFLAGS) -o $@ # Per-module optimization override (SETUP §5.5 — per-module compiler mixing). The boot/ # main/game-mode-dispatch module (src/boot.c, vram 0x80010000-0x800123F0) was compiled at diff --git a/docs/SETUP.md b/docs/SETUP.md index 384812c81..f8bbc2a1d 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -676,6 +676,7 @@ Every script under `tools/` (plus the two report make-targets), grouped by purpo | | `tools/make_snd_used.py` | **(Phase 8)** Build the combined libspu+libsnd curated dir (alias dedup by byte-match, scattered-`.bss` exclusions; §9.6). | | | `tools/make_apicard_used.py` | **(Phase 8)** Build the combined libapi+libcard curated dir (§9.6). | | | `tools/ld_interleave.py` | Interleave linker inputs to match original section ordering. | +| | `tools/jtbl_rodata_pads.py` | **(Phase 29, cookbook §8e)** Post-maspsx filter for multi-table `.rodata` carve spans: REPLACES each cc1 `.align 3` (which is section-relative and would mis-pad a merged/4-mod-8 span) with the ORIGINAL's exact pad bytes per the object's `JTBL_PADS` spec (written by `jtbl_carve` into `config/overlays.mk`; armed via `$(if $(JTBL_PADS),…)` in the Makefile `build/src/%.o` recipe). Fail-loud on table-count drift / non-`.align 3` / non-jtbl rodata content. Unset var ⇒ pipeline byte-identical. | | | `tools/split_src_region.py` | Split a `src/` region file at object boundaries. | | | `tools/rollout_whale_o0.py` | **(Phase 24 W9)** Roll out the -O0 whale `func_80144B9C` ×134: per single-file overlay, line-split `.c` at the whale, carve the yaml code subseg into before/`_o0b`(-O0)/`_after`, write a thin `_o0b.c` that `#include`s the shared `src/shared/func_80144B9C.h`. Idempotent; the `WHALE_O0B_OBJS` Makefile wildcard -O0-compiles all `_o0b.o` (cookbook §38). | | **Reports** | `tools/progress.py` | Per-binary decomp progress (`make report`); counts dedup-shared fns as REAL via the registry (Phase 11). **`--fleet`** (Phase 15) aggregates all 136 binaries → `docs/progress.fleet.md` (deterministic, source-derived). **`--weighted`** (Phase 25) prints the two BYTE/instruction-weighted metrics from `.run/sig.*.jsonl` (executable code only): **instr-weighted** (fleet per-overlay, the decomp.dev-display number) + **distinct-code** (dedup, each unique fn once, the distinct-RE number); both also fold into `--fleet` alongside the ×134-inflated function-count %. Needs `make sig-overlays` first; degrades gracefully without sigs. | diff --git a/docs/decision-log.md b/docs/decision-log.md index c761adb94..535762484 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -1353,3 +1353,42 @@ are self-contained") is false whenever `engine_core.h` already declares the memb signature. `--fix-def-sig` should likely be default-on for the h_seq path. And the meta-lesson, hammered three times in one task: **a masked/intermediate MATCH is a candidate, never a diagnosis — reproduce the real build and read the real error before naming the cause (R35).** + +### 2026-07-18 — P29 jtbl 8-align wall: the half-pin was INVERTED (vacuous probes), the fix is a pad-spec filter (§8e) + +**Context.** The 4 jtbl giants (`func_80131340`/`80159C84`/`8013C414`/`8013F350`, all match_one MATCH, +~2.6M agent-tokens of preserved drafts) were blocked on ONE tooling gap: banking `func_80131340` into the +shared `_jr_8012ACE0` TU produced a +4 pad at rodata 0xCC → image-wide %lo shift → SHA1 fail. The session-2 +checkpoint recorded a half-pin — "cc1 AND maspsx both emit the jtbl `.align 2`; the +4 is a downstream +`as`/`ld_interleave` artifact" — and told the next session to start from there. + +**What the evidence actually said (R35, again).** Both preserved probes were VACUOUS: an empty `j $31` +function with NO jump table — the `.align 2` they "showed" was the function-entry `.text` align. The honest +stage-walk (real draft spliced into the real TU, `.run/probe_jtbl/`) inverted every clause: cc1 emits +**`.align 3` before every table**; **maspsx passes it through verbatim** (the famous `maspsx.py:435` "drops +`.align`" is an inventory-only pass — the §8a-pad cookbook claim was false too); `as` bakes the pad +section-relative; and the LINK side was never guilty (`SUBALIGN(2)` + `ALIGN(.,4)` place even 4-mod-8 carve +starts tight — the banked `0xb07dc` carve proves it). Two Explore subagents produced OPPOSITE readings of +maspsx (one read the inventory pass as the output path); the tie was broken by reading the code path myself +plus one byte observable — the clean object's `.rodata` sh_addralign=8, which only a surviving `.align 3` +explains (R34: make oracles argue; R14: settle on bytes). + +**The design fork and why the filter won.** The obvious fixes all fail a generality test: blanket align-demote +breaks the main EXE's island (its intra-TU pads are load-bearing); pure isolation fails multi-table functions +whose first table sits at vram ≡4 mod 8 (`.align` is section-relative, so the section-start parity flips every +internal pad); sed/as/ld have no per-occurrence mechanism. The winning shape: **replace each rodata `.align 3` +with the ORIGINAL's exact pad bytes** — derived per span by interval arithmetic from the carve config +(`pad[K] = start[K] − end[K−1]` ∈ {0,4}), emitted as a per-object `JTBL_PADS` make var, applied by a ~50-line +post-maspsx filter with fail-loud drift guards. Parity-independent, per-sibling self-adapting (each overlay's +own addresses), and structurally fleet-neutral (every pre-existing carve is single-table → no var → pipeline +byte-identical). A red-team subagent pre-verified the transform empirically (verbatim 0xE4/pad-at-0xCC vs +filtered 0xE0/tight) and surfaced 7 hardening items, including the LATENT bug that produced the original +failure (tight abutment silently merged into a bytes-impossible span) and a byte-witnessed wrong-TU splice +(`stub_file` first-match returned a stale duplicate stub — the "conflicting types" cascade was never the +draft's fault). + +**Hindsight better-path.** The checkpoint's half-pin cost nothing this time because R35 forced re-derivation — +but only because the vacuous probes were LOOKED AT. The transferable rule: **a probe whose output contains no +instance of the probed thing pins nothing** — check that first, before trusting any recorded verdict. And when +a wall involves a multi-stage pipeline, walk it stage-by-stage with one byte observable per stage before +designing anything; the whole design fell out of five observables in under an hour. diff --git a/docs/matching-cookbook.md b/docs/matching-cookbook.md index cfb18eaff..94fac6811 100644 --- a/docs/matching-cookbook.md +++ b/docs/matching-cookbook.md @@ -353,7 +353,8 @@ a 25-ins single-jtbl jr-function in ov_SC01_077):** orthogonal — reconcile first, then the carved jtbl lands byte-exact. - **Alignment:** gcc emits the jtbl `.rdata .align 3` (8-byte). If the original jtbl address is 8-aligned (`jtbl_801D8078`, 0x…078) there is no pad and it lands exact. A **4-aligned** original address (`jtbl_801D8AFC`) - would force a 4-byte align pad → handle then (not hit by the PoC target). + would force a 4-byte align pad → **HANDLED (Phase 29): the §8e `JTBL_PADS` pad-spec filter** (hit for real by + `jtbl_801D8144` in the func_80131340 bank). - **rtu_match is NOT a whole-binary gate for jr-functions** — it masks relocs AND excludes the §8 jtbl rodata, so it MATCHes a body whose switch is subtly wrong (e.g. func_80159C84's 2nd jtbl was 5 words vs the real 6 — a false-MATCH). Always confirm jr-function cracks with the whole-binary gate (which now works, via this carve). @@ -364,6 +365,11 @@ a 25-ins single-jtbl jr-function in ov_SC01_077):** ### §8a-pad — a trailing `.word 0x00000000` under a jtbl dlabel is `.align` PAD, not an entry (Phase 26 session 6, byte-proven) +> **⚠ CORRECTED by §8e (Phase 29):** the "maspsx drops all `.align`" rationale below is **FALSE** (that +> `continue` is in an inventory-only pass; the output path passes `.align` verbatim). The TRIM itself remains +> correct — the pad belongs to the NEXT table's `.align 3`, absent when that owner isn't compiled in the same +> object. Multi-table spans now reproduce interior pads via the §8e `JTBL_PADS` spec filter. + **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 @@ -507,6 +513,51 @@ the base for the later recovery stages. **First sibling byte-identical; 562-ins finds only make's `Error 33`. Grep for the diagnostic text (`conflicting types`, `undeclared`, `parse error`, `redefinition`) instead, and remember `warning: conflicting types for built-in function 'memcpy'` is benign. +## §8e The jtbl ALIGNMENT LAW + the pad-spec filter — multi-table .rodata spans (Phase 29, byte-proven; `.run/probe_jtbl/verdict.md`) + +**Two prior cookbook claims are CORRECTED here** (both were instrument errors, R35): +- §8a-pad's "**maspsx drops all `.align`**" is **FALSE**. The `continue` at `maspsx/__init__.py:435` is in + `preprocess_lines` — an *inventory-only* pass (sbss/bss/sdata dicts) whose skips produce no output; the real + output path (`process_line`, L872-873 catch-all) re-emits `.align` **verbatim**. The §8a-pad *trim* is still + right, for a different reason: the trailing pad word belongs to the NEXT table's `.align 3`, which is only + emitted when that next owner is compiled in the same object. +- The Phase-29 session-2 "half-pin" ("cc1 AND maspsx emit the jtbl `.align 2`") was **inverted**, measured off + VACUOUS probes (an empty `j $31` fn — no jtbl in them at all; the only `.align 2` was the function-entry + `.text` align). Lesson: **a probe whose output contains no instance of the thing being probed pins nothing.** + +**The law (each link byte-verified in `.run/probe_jtbl/`):** +1. cc1 (Sony gcc 2.7.2) emits `.rdata` + **`.align 3`** + label before **every** switch jump table + (probe: 2 tables in one TU → 2× `.align 3`; the clean single-table object's `.rodata` sh_addralign=8). +2. maspsx passes `.align` through; `as` bakes the pad into the section **SECTION-RELATIVE** — the linker can + never remove an intra-object pad. (`as` control: bare `.word` section → Al=4; with `.align 3` → Al=8.) +3. Link placement is always TIGHT: `SUBALIGN(2)` (splat `subalign: 2`, fleet-wide) + `ld_interleave`'s + `. = ALIGN(., 4)` override input-section alignment — a 4-mod-8 carve start places exactly (the banked + `0xb07dc` carve is the byte proof). So **only intra-object pads can diverge from the original.** +4. ORIGINAL layout semantics: originally-separate TUs pack **TIGHT** (PSX linker, 4-aligned placement: + jtbl_801D8078's 51 entries end 0x801D8144 exactly where the next TU's table begins, %8==4); **intra-TU** + consecutive tables carry a REAL zero-word pad wherever the previous table ends ≡4 mod 8 + (tail2.data.s: jtbl_801D8158/8170/8188/81A0 = 5 entries + one `.word 0` each). +5. ⇒ Merging originally-separate TUs into one decomp TU makes cc1's intra-TU `.align 3` fire where the + original had a tight TU boundary: a non-first table at original vram ≡4 mod 8 gains a **+4 interior pad** + → every downstream data symbol shifts → `%lo` relocs break image-wide (func_80131340: pad at rodata 0xCC, + SHA1 fail from a clean build). Conversely a genuine intra-TU pad must be REPRODUCED. +6. **Isolation does NOT fix this in general**: `.align` is section-relative, so an isolated object whose + first table starts at vram ≡4 mod 8 flips the parity of every INTERNAL align — a multi-table function + with a 4-mod-8 first table would mis-pad inside its own object. Only explicit pad control is general. + +**The mechanism (`tools/jtbl_rodata_pads.py` + `jtbl_carve` + Makefile `JTBL_PADS`):** for a multi-table +span, `jtbl_carve` derives each boundary's pad by **interval arithmetic** (`pad[K] = start[K] − end[K−1]` +∈ {0,4}; a 4-gap must be a verifiably-zero payload word, else NON-CONTIGUOUS→isolate) and writes a +per-object `build/src//.o: JTBL_PADS := 0,4,…` target var into `config/overlays.mk`; the Makefile +pipes that object through the filter, which **REPLACES each rodata `.align` with the spec'd pad bytes** +(`.word 0` or nothing). Per-sibling self-adapting (each overlay's own addresses), fail-loud on drift +(spec count ≠ align count; non-`.align 3`; non-jtbl rodata content). Committed spec values are CARRIED, +never re-derived (a committed span's interior boundaries are unrecoverable from its interval). +Single-table carves get no var — their pipeline is byte-identical to pre-§8e (their lone `.align 3` at +section offset 0 pads nothing and SUBALIGN neutralizes the sh_addralign). +- **Object-layer proof before image-layer claims:** verbatim vs filtered objdump — 0xE4/pad-at-0xCC vs + 0xE0/tight — settled the mechanism before any carve landed. Cheap, decisive, reusable probe shape. + ## §9 Link real PsyQ library objects byte-exact (Phase 7 — GO proven) ~350 of BFM's functions are unmodified PsyQ 4.0 SDK code. They are **byte-identical to the real PsyQ library objects**, so link them directly instead of hand-decompiling — and each library `.o` brings its own correct diff --git a/phase-ends/CURRENT_PHASE.md b/phase-ends/CURRENT_PHASE.md index f86540524..224f53447 100644 --- a/phase-ends/CURRENT_PHASE.md +++ b/phase-ends/CURRENT_PHASE.md @@ -144,6 +144,40 @@ conditional) · main-EXE/B9 + GLM/B6 + resident's 14 walls (P30) · behemoths B7 ## Per-task log *(appended after each task; the crash-recovery trail — becomes `phase-ends/logs/Phase29.md` at close, R19)* +- **2026-07-18 — jtbl 8-align fix OPENED (Fable5 Max, plan approved).** Plan mirror: + `~/.claude/plans/fable5-max-set-jtbl-ancient-marble.md`. **⚠️ CORRECTION of the session-2 checkpoint's + half-pin (R35/R14 — do NOT re-trust it): the claim "cc1 AND maspsx both emit the jtbl `.align 2`" is + INVERTED, and both preserved probes (`.run/probe_jtbl_{cc1,maspsx}.s`) are VACUOUS (empty `j $31` fn, no + jtbl).** True chain (byte-grounded this session): **(1)** Sony cc1 emits `.align 3` before EACH jump table + in `.rdata` (clean `jr_8012ACE0.o` has `.rodata` sh_addralign=8 w/ a single 51-entry table); **(2)** maspsx + passes `.align` through VERBATIM (`maspsx/__init__.py:872-873` output catch-all; the L435-437 `continue` is + an inventory-only pass — `jtbl_carve.py:132`'s "maspsx drops .align" docstring is FALSE); **(3)** `as` bakes + the pad SECTION-RELATIVE (intra-object, linker can't remove); **(4)** placement is always tight + (`SUBALIGN(2)` fleet-wide + ld_interleave `ALIGN(.,4)` — the 0xb07dc 4-mod-8 carve proves it); **(5)** + original semantics: separate TUs pack TIGHT (078→144 abutment), intra-TU tables 8-align w/ real zero-word + pads (tail2.data.s 8158/8170/8188/81A0). **THE BUG:** merging originally-separate TUs into one decomp TU + fires an intra-TU `.align 3` where the original had a tight TU boundary → +4 at rodata 0xCC → image-wide + %lo shift (build_g2.log SHA1 FAIL). **THE FIX (red-team-hardened, empirically pre-verified):** pad-spec + filter `tools/jtbl_rodata_pads.py` (post-maspsx, per-object `JTBL_PADS` make var, fail-loud guards) + + `jtbl_carve` interval-arithmetic specs + merge relaxation (gap 4 must be verifiably zero) + revert/stale-.o + coverage + `jtbl_family_bank` stub_file uniqueness assert (yesterday's ladder failure spliced into a stale + `_a.c` duplicate stub — byte-witnessed in bank_func_80131340.log). Isolation is NOT general (as aligns + section-relative; a 4-mod-8-start multi-table object mis-pads internally) — kept as NON-CONTIGUOUS fallback. +- **✅ 2026-07-18 — jtbl §8e fix BUILT + fleet-neutral (Steps 1-3 of the approved plan).** Honest probes + persisted (`.run/probe_jtbl/verdict.md` + objdumps; the vacuous pair deleted): cc1 `.align 3` per table + (2 tables → 2 aligns, byte-listed), maspsx verbatim passthrough in situ, **the +4 pad materialized at object + rodata 0xCC** (0xE4 verbatim), as-controls (bare rodata → Al=4; +`.align 3` → Al=8). Built: NEW + `tools/jtbl_rodata_pads.py` (spec-driven align→pad-bytes replacement; guards byte-tested rc=1 ×3; pad=4 + direction synthetically proven), `jtbl_carve.py` (spec-aware merge: gap∈{0,4-zero-checked-in-payload}, + interval-arithmetic pads CARRIED never re-derived, `JTBL_PADS` target-var emission into overlays.mk + + revert() restore + stale-.o invalidation, honest docstring), Makefile `$(if $(JTBL_PADS),…)` stage + + env-shield default, `jtbl_family_bank.stub_file` duplicate-stub fail-loud. **Object-layer byte proof:** + filtered `.rodata` = 0xE0, table 2 TIGHT at 0xCC = the merged carve span exactly. Dry-run build_carve → + span `0xaff20..0xb0000` spec `[0,0]` as designed. **R22 clean-fleet WITH the fix wired: 140/140 + byte-identical, tools-health green (dedup 1846/0, C1 234205/234205), zero new banks — fleet-neutral.** + Flywheel captured in-session (R30): cookbook **§8e** + §8a/§8a-pad corrections, decision-log entry, + SETUP.md row. NEXT: Step 4 — bank func_80131340 ×1. + - **2026-07-16 — Phase opened.** Session Start Protocol complete (all 28 PhaseEnds + roadmap + calibration + decision-log read). 3 Explore surveys grounded the plan (tooling state, frontier data, idiom bank). Plan approved at Max/plan-mode. Beginning Task 1. diff --git a/tools/jtbl_carve.py b/tools/jtbl_carve.py index baebfe86f..8bb4f40bc 100644 --- a/tools/jtbl_carve.py +++ b/tools/jtbl_carve.py @@ -21,6 +21,18 @@ code object contributes at most ONE contiguous .rodata run, so two matched jr-fu code subseg (non-adjacent jtbls in the island) are UNSATISFIABLE -> this tool fails loud, and the caller must first isolate one into its own code subseg (the whale `_o0b` precedent). +MULTI-TABLE spans & the 8-align pad spec (Phase-29 §8e; .run/probe_jtbl/verdict.md): cc1 emits +`.align 3` before EVERY jump table and maspsx passes it through, so a merged same-subseg span whose +non-first table sits at an original vram ≡4 mod 8 would gain a +4 interior pad the original does +not have (originally-separate TUs pack TIGHT) — and conversely a real intra-TU pad word must be +reproduced where the original HAS one. When a span holds >1 table, this tool derives the per-table +pad spec by interval arithmetic (pad[K] = start[K] - end[K-1] ∈ {0,4}; a 4-gap must be a zero word +in the payload) and writes a per-object `JTBL_PADS` target var into config/overlays.mk; the +Makefile then pipes that object through tools/jtbl_rodata_pads.py, which REPLACES each rodata +`.align` with the spec'd pad bytes. Committed spec values are carried, never re-derived (a +committed span's interior boundaries are not recoverable from its interval). Single-table carves +get NO var and keep today's byte-identical pipeline. + Usage: jtbl_carve.py --func func_XXXX [--func ...] # add these matched jr-fns to the carve set jtbl_carve.py --revert # restore the config from git (drop carves) Idempotent: re-running with the same (accumulated) funcs reproduces the same config. @@ -51,6 +63,46 @@ def overlay_vram_base(ov): return int(m.group(1), 16) +def payload_path(ov): + """The overlay's decompressed payload, derived from the config's target_path (R33).""" + m = re.search(r"target_path:\s*(\S+)", open(cfg_path(ov)).read()) + if not m: + sys.exit(f"jtbl_carve: no target_path in {cfg_path(ov)}") + p = os.path.join(REPO, m.group(1)) + if not os.path.exists(p): + sys.exit(f"jtbl_carve: payload {p} missing — run `make extract` first (R32: refusing to " + f"skip the gap-word check)") + return p + + +def payload_word(ov, off): + """The little-endian u32 at file offset `off` in the overlay's payload.""" + with open(payload_path(ov), "rb") as f: + f.seek(off) + b = f.read(4) + if len(b) != 4: + sys.exit(f"jtbl_carve: short read at payload offset 0x{off:x}") + return int.from_bytes(b, "little") + + +# Per-object pad-spec lines in config/overlays.mk (Phase-29 §8e — consumed by the Makefile's +# jtbl_rodata_pads.py stage). One line per multi-table span: +# build/src//.o: JTBL_PADS := 0,4 # Phase-29 §8e jtbl pad spec (jtbl_carve.py) +PADS_COMMENT = " # Phase-29 §8e jtbl pad spec (jtbl_carve.py)" + + +def pads_line_re(ov): + return rf"^build/src/{re.escape(ov)}/(\w+)\.o: JTBL_PADS := ([\d,]+).*$" + + +def current_pads_specs(ov, txt=None): + """{subseg: [pad,...]} from the CURRENT overlays.mk (carries committed + in-flight values).""" + if txt is None: + txt = open(os.path.join(REPO, "config/overlays.mk")).read() + return {m.group(1): [int(x) for x in m.group(2).split(",")] + for m in re.finditer(pads_line_re(ov), txt, re.M)} + + def code_pieces(ov): """[(vram, subseg)] for every `- [off, c, name]` code piece in the config, ascending.""" base = overlay_vram_base(ov) @@ -129,12 +181,16 @@ def jtbl_range(ov, jtbl_hex, labels, region_end_vram): 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.""" + Why trim (CORRECTED Phase-29 §8e — the old rationale "maspsx drops `.align`" was FALSE; maspsx + passes it through, .run/probe_jtbl/verdict.md): the pad belongs to the NEXT table's `.align 3`, + which is emitted only if that next table's owner is compiled in the SAME object. When the next + owner is unmatched (or another TU), the carved object ends at the last real entry, so carving + to the next dlabel would reserve the pad word the object does not supply — 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 — and when + the next owner IS matched into the same span later, the merge re-attributes the pad to that + table's JTBL_PADS spec (pad[K]=4) and jtbl_rodata_pads.py emits it. 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 " @@ -208,14 +264,19 @@ def parse_config(ov): def build_carve(ov, funcs): - """Return (region_lines, order_arg): the regenerated data-region `- [...]` piece lines and the - `ld_interleave --order` object list, for the accumulated carve set (existing + the new funcs).""" + """Return (region_lines, order_arg, pads_map): the regenerated data-region `- [...]` piece + lines, the `ld_interleave --order` object list, and {subseg: [pad,...]} for every carve span, + for the accumulated carve set (existing + the new funcs).""" base = overlay_vram_base(ov) (_, indent, _, _, tail_start, region_end, trailing_present, existing) = parse_config(ov) region_end_vram = base + region_end - # carves: (start_off, end_off, subseg). Existing ones come from the config (already migrated). - carves = list(existing) + # carves: (start_off, end_off, subseg, spec). Existing ones come from the config (already + # migrated); their pad spec is CARRIED from overlays.mk (a committed span's interior boundaries + # are not recoverable from its interval — never re-derive), default [0] = single-table (the + # fleet-wide state before Phase-29 §8e; a multi-table span always writes its spec line). + specs = current_pads_specs(ov) + carves = [(s, e, sub, specs.get(sub, [0])) for (s, e, sub) in existing] have = {c[0] for c in carves} # A new jtbl's end is bounded by the next RAW data dlabel OR the next EXISTING carve start # (an already-carved adjacent jtbl is gone from the data asm, so the raw dlabels alone would @@ -230,28 +291,42 @@ def build_carve(ov, funcs): s_off, e_off = s_vram - base, e_vram - base if s_off in have: continue # idempotent: already carved - carves.append((s_off, e_off, sub)) + carves.append((s_off, e_off, sub, [0])) have.add(s_off) - carves.sort() + carves.sort(key=lambda c: (c[0], c[1])) # A code object emits its jtbls CONTIGUOUS in .rodata (gcc source order). So two carves in the - # SAME subseg are byte-correct only if ADJACENT in the island (no unmatched jtbl between) -> merge - # them into one spanning .rodata piece. NON-adjacent same-subseg is unsatisfiable (a single object - # can't leave a gap for the raw jtbl between) -> isolate one fn into its own subseg (jr_isolate.py). + # SAME subseg are byte-correct only if ADJACENT in the island — where "adjacent" is abutting + # (gap 0) OR separated by exactly one original `.align 3` pad word (gap 4, verifiably zero in + # the payload; Phase-29 §8e) -> merge them into one spanning .rodata piece, recording the + # boundary pad in the span's spec (jtbl_rodata_pads.py reproduces it at compile time). + # Any other same-subseg gap is unsatisfiable (a single object can't leave a hole for the raw + # jtbl between) -> isolate one fn into its own subseg (jr_isolate_all.py). merged = [] - for s_off, e_off, sub in carves: - if merged and merged[-1][2] == sub and merged[-1][1] == s_off: - merged[-1] = (merged[-1][0], e_off, sub) # extend the contiguous same-subseg run - else: - merged.append((s_off, e_off, sub)) + for s_off, e_off, sub, spec in carves: + if merged and merged[-1][2] == sub: + gap = s_off - merged[-1][1] + if gap in (0, 4): + if gap == 4: + w = payload_word(ov, merged[-1][1]) + if w != 0: + sys.exit( + f"jtbl_carve: the 4-byte gap at 0x{merged[-1][1]:x} between same-subseg " + f"carves is 0x{w:08x}, not a zero .align pad word — treating as " + f"NON-CONTIGUOUS. Isolate one matched jr-function into its own code " + f"subseg first (tools/jr_isolate_all.py), then re-carve.") + ps, _, _, pspec = merged[-1] + merged[-1] = (ps, e_off, sub, pspec + [gap] + spec[1:]) + continue + merged.append((s_off, e_off, sub, spec)) seen_subsegs = {} - for s_off, _, sub in merged: + for s_off, _, sub, _ in merged: if sub in seen_subsegs: sys.exit( f"jtbl_carve: subseg '{sub}' would host NON-CONTIGUOUS .rodata carves " f"(0x{seen_subsegs[sub]:x} and 0x{s_off:x}) — a single object can't leave a gap for the " f"unmatched jtbl between them. Isolate one matched jr-function into its own code subseg " - f"first (tools/jr_isolate.py, the whale `_o0b` precedent), then re-carve.") + f"first (tools/jr_isolate_all.py, the whale `_o0b` precedent), then re-carve.") seen_subsegs[sub] = s_off carves = merged @@ -264,7 +339,7 @@ def build_carve(ov, funcs): nonlocal n_data n_data += 1 return "tail" if n_data == 1 else f"tail{n_data}" - for s_off, e_off, sub in carves: + for s_off, e_off, sub, _spec in carves: if cursor < s_off: nm = data_name() pieces.append((cursor, "data", nm)) @@ -283,16 +358,20 @@ def build_carve(ov, funcs): for off, kind, name in pieces: comment = " # Phase-26 §8 jtbl-rodata carve (jtbl_carve.py)" if kind == ".rodata" else "" region_lines.append(f"{indent}- [{hex(off)}, {kind}, {name}]{comment}") - return region_lines, "--order " + ",".join(order) + pads_map = {sub: spec for (_s, _e, sub, spec) in carves} + return region_lines, "--order " + ",".join(order), pads_map def apply(ov, funcs): - region_lines, order_arg = build_carve(ov, funcs) + region_lines, order_arg, pads_map = build_carve(ov, funcs) lines, indent, lo, hi, *_ = parse_config(ov) new_lines = lines[:lo] + region_lines + lines[hi:] open(cfg_path(ov), "w").write("\n".join(new_lines) + "\n") set_overlays_var(ov, order_arg) - print(f"jtbl_carve {ov}: carve set = {len(region_lines)} pieces; JTBL_INTERLEAVE = {order_arg}") + set_pads_vars(ov, pads_map) + multi = {s: p for s, p in pads_map.items() if len(p) > 1} + print(f"jtbl_carve {ov}: carve set = {len(region_lines)} pieces; JTBL_INTERLEAVE = {order_arg}" + + (f"; JTBL_PADS = {multi}" if multi else "")) def set_overlays_var(ov, args): @@ -310,6 +389,38 @@ def set_overlays_var(ov, args): open(mk, "w").write(txt) +def set_pads_vars(ov, pads_map): + """Write this overlay's per-object JTBL_PADS lines (Phase-29 §8e), preserving carried values. + + Only multi-table spans (len(spec) > 1) get a line; single-table spans get none (their pipeline + stays byte-identical to pre-§8e). All of this overlay's current pads lines are replaced by the + regenerated block as one unit (values were CARRIED into pads_map by current_pads_specs, so this + is a rewrite of the same state plus the new boundary — not a re-derivation). Any object whose + spec appears, changes, or disappears gets its stale build/src//.o deleted: the spec is + no make-prerequisite, and a padless stale object would fail the SHA gate mystifyingly.""" + mk = os.path.join(REPO, "config/overlays.mk") + txt = open(mk).read() + before = current_pads_specs(ov, txt) + after = {sub: spec for sub, spec in pads_map.items() if len(spec) > 1} + # drop all current lines for this overlay, then insert the regenerated block + txt = re.sub(pads_line_re(ov) + r"\n", "", txt, flags=re.M) + if after: + block = "\n".join( + f"build/src/{ov}/{sub}.o: JTBL_PADS := {','.join(map(str, spec))}{PADS_COMMENT}" + for sub, spec in sorted(after.items())) + m = re.search(rf"^{re.escape(ov)}_JTBL_INTERLEAVE.*$", txt, re.M) + if not m: + sys.exit(f"jtbl_carve: no {ov}_JTBL_INTERLEAVE line to anchor JTBL_PADS on") + txt = txt[:m.end()] + "\n" + block + txt[m.end():] + open(mk, "w").write(txt) + for sub in set(before) | set(after): + if before.get(sub) != after.get(sub): + obj = os.path.join(REPO, f"build/src/{ov}/{sub}.o") + if os.path.exists(obj): + os.remove(obj) + print(f"jtbl_carve: JTBL_PADS changed for {sub} — removed stale {obj}") + + def revert(ov): """Restore this overlay's carve state to the COMMITTED one. @@ -335,9 +446,28 @@ def revert(ov): txt = txt.replace(anchor, anchor + "\n" + m.group(0), 1) else: # no committed carve -> drop ours txt = re.sub(rf"^{re.escape(ov)}_JTBL_INTERLEAVE.*\n", "", txt, flags=re.M) + # JTBL_PADS lines (Phase-29 §8e): restore this overlay's per-object pad specs to the committed + # set with the same surgical splice (a failed sibling bank must not leave its spec behind, and + # a blunt checkout would wipe OTHER siblings' in-flight lines — overlays.mk is shared). + now_pads = current_pads_specs(ov, txt) + committed_pads = current_pads_specs(ov, committed) + txt = re.sub(pads_line_re(ov) + r"\n", "", txt, flags=re.M) + committed_lines = [l for l in committed.splitlines() + if re.match(pads_line_re(ov), l)] + if committed_lines: + m2 = re.search(rf"^{re.escape(ov)}_JTBL_INTERLEAVE.*$", txt, re.M) + if not m2: + sys.exit(f"jtbl_carve: no {ov}_JTBL_INTERLEAVE line to anchor committed JTBL_PADS on") + txt = txt[:m2.end()] + "\n" + "\n".join(committed_lines) + txt[m2.end():] open(mk, "w").write(txt) + for sub in set(now_pads) | set(committed_pads): + if now_pads.get(sub) != committed_pads.get(sub): + obj = os.path.join(REPO, f"build/src/{ov}/{sub}.o") + if os.path.exists(obj): + os.remove(obj) print(f"jtbl_carve {ov}: reverted config + JTBL_INTERLEAVE restored to committed" - f"{'' if m else ' (none)'}") + f"{'' if m else ' (none)'}" + + (f" + {len(committed_lines)} JTBL_PADS line(s) restored" if committed_lines else "")) def main(): diff --git a/tools/jtbl_family_bank.py b/tools/jtbl_family_bank.py index aa0e6e4d8..752eeabf4 100644 --- a/tools/jtbl_family_bank.py +++ b/tools/jtbl_family_bank.py @@ -38,10 +38,17 @@ def sh(cmd): def stub_file(ov, func): - for cf in sorted(glob.glob(f"src/{ov}/{ov}*.c")): - if re.search(rf'INCLUDE_ASM\("[^"]*",\s*{func}\);', open(cf).read()): - return cf - return None + """The ONE .c holding this func's INCLUDE_ASM stub. Fail-loud on duplicates (Phase-29 §8e): + the first-sorted-glob behavior once returned a STALE duplicate stub in a different TU + (func_80131340 spliced into ov_SC01_077_a.c instead of ..._jr_8012ACE0.c), producing a + misattributed `conflicting types` cascade — a wrong-TU splice, not a draft defect.""" + hits = [cf for cf in sorted(glob.glob(f"src/{ov}/{ov}*.c")) + if re.search(rf'INCLUDE_ASM\("[^"]*",\s*{func}\);', open(cf).read())] + if len(hits) > 1: + sys.exit(f"jtbl_family_bank: {func} has {len(hits)} INCLUDE_ASM stubs in {ov}: {hits} — " + f"duplicate/stale stub; fix the source before banking (a first-match splice " + f"would hit the wrong TU's decl environment)") + return hits[0] if hits else None def region_files(ov): diff --git a/tools/jtbl_rodata_pads.py b/tools/jtbl_rodata_pads.py new file mode 100644 index 000000000..760cf5f9e --- /dev/null +++ b/tools/jtbl_rodata_pads.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Phase-29 §8e: reproduce the ORIGINAL's jump-table padding in a multi-table .rodata carve. + +The mechanism (byte-proven, .run/probe_jtbl/verdict.md): + - cc1 (Sony gcc 2.7.2) emits `.rdata` + `.align 3` + label before EVERY switch jump table. + - maspsx passes `.align` through VERBATIM (the maspsx/__init__.py:435 `continue` is in an + inventory-only pass; the output path re-emits `.align` unchanged). + - GNU as bakes the align pad into the section SECTION-RELATIVE — the linker can never remove it + (SUBALIGN(2) caps only the section's *external* placement alignment). + - The ORIGINAL image packs originally-separate TUs' tables TIGHT (the PSX linker 4-aligned + section placement), while intra-TU consecutive tables carry REAL zero-word pads where the + previous table ends ≡4 mod 8. + +So when a decomp TU holds MULTIPLE matched jr-functions (originally separate TUs merged), cc1's +`.align 3` fires at boundaries where the original packed tight -> a +4 interior pad -> every +downstream data symbol shifts -> %lo relocs break image-wide. And conversely a table's alignment +pad must appear exactly where the original HAS one. `.align` cannot express this (it is relative +to the section start, whose vram parity varies per overlay sibling) — only explicit pad bytes can. + +This filter sits between maspsx and as (Makefile `build/src/%.o` recipe, armed per-object by a +`JTBL_PADS` target-specific make var written by tools/jtbl_carve.py). It REPLACES each rodata +`.align` with the exact pad the original has at that table boundary: + + --pads 0,4,0 -> table 1: no pad (specs[0] is always 0 — the table starts the section), + table 2: one `.word 0` pad, table 3: no pad. + +Pads are derived by jtbl_carve from the carve intervals (pad[K] = start[K] - end[K-1], values in +{0,4}), so each overlay sibling gets its own spec from its own addresses. Unfiltered objects +(no JTBL_PADS var — every single-table carve) keep today's byte-identical pipeline. + +Fail-loud guards (R32) — each converts a silent downstream SHA1 mismatch into an immediate, +attributable build error: + - spec count != rodata `.align` count (table-count drift: a sibling's switch compiled + to a different number of tables) + - a rodata `.align` other than `.align 3` (unknown emission shape) + - rodata content other than $L labels / `.word $L...` / blank (strings, floats — the carve + model does not cover them) + - pads[0] != 0, or any pad not in {0, 4} +""" +import argparse +import re +import sys + +LABEL_RE = re.compile(r"^\$L\d+:$") +WORD_RE = re.compile(r"^\.word\s+\$L\d+$") + + +def run(pads, lines, out): + in_rodata = False + consumed = 0 + for line in lines: + s = line.strip() + if s == ".section .rodata": + in_rodata = True + out.write(line) + continue + if in_rodata and (s == ".text" or s.startswith(".section")): + in_rodata = False + out.write(line) + continue + if in_rodata: + if s.startswith(".align"): + parts = s.split() + if len(parts) != 2 or parts[1] != "3": + sys.exit(f"jtbl_rodata_pads: unexpected rodata align {s!r} " + f"(cc1 emits `.align 3` per jump table — see verdict.md)") + if consumed >= len(pads): + sys.exit(f"jtbl_rodata_pads: more rodata .align directives than pad specs " + f"({len(pads)}) — table-count drift vs the carve") + if pads[consumed] == 4: + out.write(".word 0 # jtbl_rodata_pads: original inter-table .align 3 pad\n") + consumed += 1 + continue + if s == "" or LABEL_RE.match(s) or WORD_RE.match(s): + out.write(line) + continue + sys.exit(f"jtbl_rodata_pads: unexpected rodata content {s!r} — " + f"the carve model covers only jump tables ($L labels + .word entries)") + out.write(line) + if consumed != len(pads): + sys.exit(f"jtbl_rodata_pads: consumed {consumed} rodata .align(s) but {len(pads)} pad " + f"spec(s) given — table-count drift vs the carve") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--pads", required=True, + help="comma list of pad BYTES before each rodata jump table, in emission " + "order; pads[0] must be 0") + a = ap.parse_args() + try: + pads = [int(x) for x in a.pads.split(",")] + except ValueError: + sys.exit(f"jtbl_rodata_pads: malformed --pads {a.pads!r}") + if not pads or pads[0] != 0: + sys.exit(f"jtbl_rodata_pads: pads[0] must be 0 (the first table starts the section): {pads}") + if any(p not in (0, 4) for p in pads): + sys.exit(f"jtbl_rodata_pads: every pad must be 0 or 4: {pads}") + run(pads, sys.stdin, sys.stdout) + + +if __name__ == "__main__": + main()