diff --git a/Makefile b/Makefile
index 5d5699a29..a95813f38 100644
--- a/Makefile
+++ b/Makefile
@@ -659,7 +659,7 @@ ifeq ($(BINARY),main)
# 6324C.data. Idempotent; keyed off splat's exact output (re-run = no-op).
# EXE-only (overlays have no rodata island) — gated to BINARY=main; --front/--tail
# name the sandwich .data objects (cookbook §8).
- $(PYTHON) tools/ld_interleave.py --front 53198.data.o --tail 6324C.data.o $(LD_SCRIPT)
+ $(PYTHON) tools/ld_interleave.py --front 53198.data.o --tail 63470.data.o $(LD_SCRIPT)
endif
# Phase-26 §8: overlays that carve a jr-function's jtbl into a dotted .rodata subseg run
# ld_interleave to place the migrated .rodata between the pre/post data-tail chunks (the
@@ -718,7 +718,7 @@ ASFLAGS_REORDER := -Iinclude -march=r3000 -mtune=r3000 -no-pad-sections -O2 -G0
build/src/%.o: src/%.c
@mkdir -p $(dir $@)
@echo " CC $@"
- @set -o pipefail; $(CPP) $(CPPFLAGS) -MMD -MP -MT $@ -MF $(@:.o=.d) $< | $(CC1_PSX) $(CC1FLAGS) | $(if $(filter $*,$(REORDER_TUS)),$(VENV_PY) tools/reorder_passthrough.py | $(AS) $(ASFLAGS_REORDER) -o $@,$(VENV_PY) $(MASPSX) --aspsx-version=$(ASPSX_VERSION) $(MASPSX_FLAGS) $(if $(JTBL_PADS),| $(VENV_PY) tools/jtbl_rodata_pads.py --pads $(JTBL_PADS),$(if $(filter md_%,$(BINARY)),| $(VENV_PY) tools/jtbl_rodata_pads.py --derive $(BINARY) --tu $(notdir $*))) | $(AS) $(ASFLAGS) -o $@)
+ @set -o pipefail; $(CPP) $(CPPFLAGS) -MMD -MP -MT $@ -MF $(@:.o=.d) $< | $(CC1_PSX) $(CC1FLAGS) | $(if $(filter $*,$(REORDER_TUS)),$(VENV_PY) tools/reorder_passthrough.py | $(AS) $(ASFLAGS_REORDER) -o $@,$(VENV_PY) $(MASPSX) --aspsx-version=$(ASPSX_VERSION) $(MASPSX_FLAGS) $(if $(JTBL_PADS),| $(VENV_PY) tools/jtbl_rodata_pads.py --pads $(JTBL_PADS),$(if $(filter md_% main,$(BINARY)),| $(VENV_PY) tools/jtbl_rodata_pads.py --derive $(BINARY) --tu $(notdir $*))) | $(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/config/splat.us.exe.yaml b/config/splat.us.exe.yaml
index 1773a5fa5..221fa5b49 100644
--- a/config/splat.us.exe.yaml
+++ b/config/splat.us.exe.yaml
@@ -204,6 +204,19 @@ segments:
# splat mis-detect it as func_80062998 (shadowing D_80062998). Carving it as the head of the
# front-data subseg forces the data labels deterministically. (ld_interleave FRONT_DATA matches.)
- [0x53198, data, 53198] # data table + front data (vram 0x80062998-0x80072A38)
- - [0x63238, .rodata, 800] # LZSS jtbl_80072A38 ONLY (vram 0x80072A38-0x80072A4C) -> migrates into LzssDecodeSector
- - [0x6324C, data, 6324C] # tail data: rest of island (raw) + globals (vram 0x80072A4C-0x80074800)
+ # P31 S72 — SPAN EXTENSION. The Phase-7 carve stopped at the LZSS table because a FULL
+ # island migration hits the interleaved game data and the library jtbls. But the island
+ # opens with a CONTIGUOUS run of game tables owned entirely by subseg 800:
+ # 0x80072A38 jtbl (LzssDecodeSector, matched, cc1-emitted)
+ # 0x80072A4C A7C A94 AB4 ADC B0C B24 B3C B64 B88 BFC (11 tables, 8 stubbed owners)
+ # 0x80072C70 loadDestPtrTable <- first non-table datum, the span's hard end
+ # One code object may contribute exactly ONE contiguous .rodata run, and this whole run is
+ # 800.o's, in address order = src/800.c source order. So the span carves as one piece and
+ # the 3-piece data->rodata->data sandwich is unchanged in SHAPE, only in where it splits.
+ # This is what blocked every main switch function: gcc emits the drafted function's table
+ # into .rodata while the raw copy stayed here, so the image GREW (measured +28/+52/+76/+84
+ # on four drafts) and all 238 symbols above 0x80072A4C shifted. 25 of main's 59 frontier
+ # functions (6,215 of 12,912 instructions) are in that class.
+ - [0x63238, .rodata, 800] # LZSS jtbl + the 11 contiguous game jtbls (vram 0x80072A38-0x80072C70)
+ - [0x63470, data, 63470] # tail data: loadDestPtrTable onward (vram 0x80072C70-0x80074800)
- [0x65000]
diff --git a/src/800.c b/src/800.c
index aafdfb1e4..073b4527f 100644
--- a/src/800.c
+++ b/src/800.c
@@ -7054,7 +7054,120 @@ void func_8001A0FC(void) {
D_800AE70C = 0;
}
-INCLUDE_ASM("asm/nonmatchings/800", func_8001A114);
+
+/* CD error-recovery state machine (0x8001A114), stepped from CdReadStateMachine's state 10.
+ * One step per call; returns 1 only when the path table has been re-resolved (recovery done).
+ * 0: CdFlush-ish reset + CdlNop -> 1 1: CdSync poll (2 -> advance, 0x10 -> restart)
+ * 2: CdlSetmode(0x80) 3: burn 3 frames 4: CdSync poll
+ * 5: re-run CdSearchFile on the path entry (up to 16 tries) -> done / restart
+ *
+ * §ADD-8 (re-tie barrier): the two constant arguments of the state-0 CdControl must issue
+ * BEFORE the `la $s0,cdReq_cdResult`; sched1 otherwise ranks the address load first (it feeds
+ * $a2 and so has the longer chain). The zero-byte `__volatile__` re-ties pin them to source
+ * order. §20/§243 (held-pointer): cdReq_retry in state 3 and D_800AE6F4 on the shared
+ * "advance" tail are spelled through a named pointer — that is what turns their %hi/%lo pairs
+ * into a single base register, and keeping the two tails textually distinct is what stops
+ * cross-jumping from merging .L8001A260 with .L8001A2AC. */
+
+extern void func_800434BC(void);
+extern int func_80043830(int com, u8 *param, u8 *result);
+extern int func_80046630(int mode);
+extern int func_8004674C(void);
+
+extern s32 D_800AE6F4; /* recovery state */
+extern u8 cdReq_cdResult; /* CdControl status byte (bit 0x10 = error) */
+extern u8 D_800AE740; /* CdlSetmode mode-byte buffer; cdReq_cdResult is at -8 */
+extern int cdReq_retry;
+extern CdlFILE D_80063028; /* CdPathTable entry; its name string sits at -0x14 */
+
+int func_8001A114(void) {
+ int ret;
+ u8 *p;
+ int r;
+ int i;
+ CdlFILE *fp;
+ int *rp;
+ s32 *sp;
+ int c0;
+ int c1;
+
+ ret = 0;
+ switch (D_800AE6F4) {
+ case 0:
+ func_800434BC();
+ c0 = 1;
+ __asm__ __volatile__("" : "=r"(c0) : "0"(c0));
+ c1 = 0;
+ __asm__ __volatile__("" : "=r"(c1) : "0"(c1));
+ p = &cdReq_cdResult;
+ func_80043830(c0, (u8 *)c1, p);
+ if ((*p & 0x10) != 0) {
+ break;
+ }
+ D_800AE6F4 = D_800AE6F4 + 1;
+ /* fallthrough */
+ case 1:
+ r = func_80046630(0);
+ if (r == 2) {
+ goto bump;
+ }
+ if (r != 0x10) {
+ break;
+ }
+ reset:
+ D_800AE6F4 = 0;
+ break;
+ case 2:
+ p = &D_800AE740;
+ *p = 0x80;
+ if (func_80043830(0xE, p, p - 8) == 0) {
+ break;
+ }
+ if ((p[-8] & 0x10) != 0) {
+ goto reset;
+ }
+ cdReq_retry = 0;
+ D_800AE6F4 = D_800AE6F4 + 1;
+ break;
+ case 3:
+ rp = &cdReq_retry;
+ *rp = *rp + 1;
+ if (*rp < 3) {
+ break;
+ }
+ *rp = 0;
+ D_800AE6F4 = D_800AE6F4 + 1;
+ break;
+ case 4:
+ r = func_8004674C();
+ if ((r == 1) || (r == 0x10) || (r == 0)) {
+ D_800AE6F4 = 0;
+ }
+ if (r != 2) {
+ break;
+ }
+ bump:
+ sp = &D_800AE6F4;
+ *sp = *sp + 1;
+ break;
+ case 5:
+ i = 0;
+ fp = &D_80063028;
+ do {
+ r = (int)CdSearchFile(fp, (char *)fp - 0x14);
+ if (r != -1) {
+ break;
+ }
+ i = i + 1;
+ } while (i < 0x10);
+ if ((u32)(r + 1) < 2) {
+ goto reset;
+ }
+ ret = 1;
+ break;
+ }
+ return ret;
+}
#ifdef NON_MATCHING
typedef struct { short x, y, w, h; } RECT; /* libgpu RECT (VRAM rectangle) */
@@ -7312,7 +7425,61 @@ void func_8001AAA0(s32 arg0) {
func_8001ABBC(1, arg0, 0, 0, 0);
}
-INCLUDE_ASM("asm/nonmatchings/800", func_8001AAD0);
+extern s32 D_800BA1B4;
+extern u8 D_80062C38;
+extern s32 func_8001ABBC(s32 a0, s32 a1, s32 a2, s32 a3, s32 a4);
+
+void func_8001AAD0(s32 arg0, s32 arg1) {
+ s32 idx;
+
+ switch (arg0) {
+ case 0:
+ idx = 9;
+ break;
+ case 1:
+ idx = 10;
+ break;
+ case 2:
+ idx = 11;
+ break;
+ case 3:
+ idx = 12;
+ break;
+ case 4:
+ idx = 13;
+ break;
+ case 5:
+ idx = 14;
+ break;
+ case 6:
+ idx = 15;
+ break;
+ case 7:
+ idx = 16;
+ break;
+ case 8:
+ idx = 17;
+ break;
+ case 9:
+ idx = 18;
+ break;
+ case 10:
+ idx = 19;
+ break;
+ case 11:
+ idx = 20;
+ break;
+ default:
+ idx = 0;
+ break;
+ }
+
+ if (D_800BA1B4 == 5) {
+ D_800BA1B4 = 0;
+ }
+
+ func_8001ABBC(3, arg1, (s32)(&D_80062C38 + idx * 0x30), 0, 0);
+}
void func_8001ABB4(void) {
}
@@ -7463,7 +7630,98 @@ s32 func_8001AF04(void) {
return 2;
}
-INCLUDE_ASM("asm/nonmatchings/800", func_8001AF34);
+extern s32 D_800BA1B4;
+extern s32 D_800AE6E4;
+extern s32 D_800BA318;
+extern s32 D_800C6D2C;
+extern s32 D_800A6550;
+extern s32 cdReq_curSector;
+extern void *cdReq_dest;
+extern s32 cdReq_size;
+extern void *cdReq_cdlFile;
+extern s32 D_800AE724;
+extern s32 D_800AE720;
+extern s32 cdReq_result;
+extern s32 D_800AE640;
+extern s32 D_800A6430;
+extern s16 D_800A6430_h __asm__("D_800A6430");
+extern s32 D_800747E4;
+extern s32 CdQueueBusy(void);
+extern void CdReadStateMachine(int);
+extern s32 func_8001B394(s32);
+extern s32 func_8001B7C4(void *);
+extern s32 func_8001B0D4(void *, s32);
+
+void func_8001AF34(void) {
+ s32 *cdlFile;
+ s32 dest;
+ s32 size;
+ s32 mode;
+ s32 sector;
+ s32 ret;
+
+ CdQueueBusy();
+ switch (D_800BA1B4) {
+ case 0:
+ return;
+ case 1:
+ cdlFile = (s32 *)D_800AE6E4;
+ dest = D_800BA318;
+ size = D_800C6D2C;
+ mode = D_800A6550;
+ if (CdQueueBusy() != 0) {
+ ret = 0;
+ goto chk;
+ }
+ if (cdReq_curSector == 0) {
+ sector = *cdlFile;
+ } else {
+ sector = *cdlFile;
+ if (sector != cdReq_curSector) {
+ ret = 0;
+ goto chk;
+ }
+ }
+ cdReq_dest = (void *)dest;
+ cdReq_size = size;
+ cdReq_cdlFile = (void *)cdlFile;
+ D_800AE724 = mode;
+ D_800AE720 = 0;
+ cdReq_curSector = sector;
+ if (mode == 0) {
+ D_800AE720 = 1;
+ }
+ CdReadStateMachine(0);
+ ret = cdReq_result;
+ chk:
+ if (ret == 0) {
+ return;
+ }
+ D_800BA1B4 = 3;
+ return;
+ case 2:
+ if (func_8001B394(D_800AE640) == 0) {
+ return;
+ }
+ D_800BA1B4 = 3;
+ return;
+ case 3:
+ return;
+ case 4:
+ if (func_8001B7C4((void *)D_800AE6E4) == 0) {
+ return;
+ }
+ D_800BA1B4 = 3;
+ return;
+ case 5:
+ if (func_8001B0D4((void *)D_800AE6E4, D_800A6430_h) == 0) {
+ return;
+ }
+ D_800747E4 = 0;
+ D_800BA1B4 = 3;
+ return;
+ }
+}
INCLUDE_ASM("asm/nonmatchings/800", func_8001B0D4);
diff --git a/tools/gate_main.py b/tools/gate_main.py
index e31f3de40..c5c786970 100644
--- a/tools/gate_main.py
+++ b/tools/gate_main.py
@@ -563,7 +563,12 @@ def clean_build():
can pass without building is worse than no verifier."""
run("rm -f build/us/SLUS_007.26")
run("make extract BINARY=main")
- r = run("make build BINARY=main")
+ # `-j`. `make build BINARY=main` without it is SINGLE-THREADED on a 32-core box; the
+ # Makefile's own JOBS knob is parallelism ACROSS binaries, which a one-binary build never
+ # reaches (memory `pass-j-to-every-build`, measured 6.1x elsewhere and byte-identical). A gate
+ # is run hundreds of times a session, so this is the difference between a probe you take and a
+ # probe you talk yourself out of.
+ r = run(f"make build BINARY=main -j{os.cpu_count() or 8}")
if r.returncode != 0:
# `make build BINARY=main` runs the SHA check itself, so rc!=0 does NOT mean "no
# binary": a linked-but-MISMATCHED build also exits nonzero. Returning None here routed
@@ -578,12 +583,83 @@ def clean_build():
return None, r # build truly failed -> no hash, and never a pass
return sha(), r
+FAILDIR = '.run/gate_main_fail'
+
+
+def _preserve_and_localize(entries, got):
+ """Snapshot the RED image + its map, then name the symbols that actually diverged.
+
+ WHY THIS EXISTS (P31 S72). Every red verdict this gate has ever produced was two hashes and
+ nothing else -- and the R40 baseline control that runs immediately after a failure REBUILDS
+ THE TREE GREEN, overwriting `build/us/SLUS_007.26` and its map. The one artifact that could
+ say WHERE the image moved was destroyed, every time, before anyone could look at it.
+
+ That is not a cosmetic gap. S71 substituted 11 main drafts one at a time, saw 7 come back with
+ a different hash, and recorded all 11 as "PROVEN gate-rejects". A hash cannot distinguish
+ "your body is wrong" from "your body is perfect and the substitution changed a CALLER" -- the
+ §376 shape, where the TU keeps a stale `extern void f(void*)` while the definition is
+ `void f(s32)`, so every call site's argument codegen moves. Six of those eleven are that
+ class, and this gate's own pre-check names them (see resolve_conflicts) -- but the four that
+ reached a build were judged with no instrument that could tell the two apart.
+
+ So: copy the image and the map aside FIRST, attribute per byte, and print the verdict. With a
+ single-entry slate the verdict is the routing decision (body reject vs plumbing reject)."""
+ try:
+ import main_diff_locate as MDL
+ except Exception as e: # never let diagnostics sink a gate
+ print(f" (diff localization unavailable: {e})")
+ return
+ tag = entries[0]['fn'] if len(entries) == 1 else f"batch{len(entries)}"
+ d = os.path.join(FAILDIR, f"{tag}_{(got or 'nobin')[:8]}")
+ os.makedirs(d, exist_ok=True)
+ for f in ('build/us/SLUS_007.26', 'build/us/SLUS_007.26.map'):
+ if os.path.exists(f):
+ run(f"cp {f} {d}/")
+ built = os.path.join(d, 'SLUS_007.26')
+ mp = os.path.join(d, 'SLUS_007.26.map')
+ if not (os.path.exists(built) and os.path.exists(mp) and os.path.exists(MDL.REF)):
+ print(f" (red image preserved at {d}, but localization inputs are incomplete)")
+ return
+ try:
+ sections, syms = MDL.parse_map(mp)
+ per, ndiff, _sz = MDL.attribute(open(built, 'rb').read(), open(MDL.REF, 'rb').read(),
+ sections, syms)
+ except Exception as e:
+ print(f" (red image preserved at {d}; localization failed: {e})")
+ return
+ rows = sorted(per.values(), key=lambda x: -x['bytes'])
+ print(f" RED IMAGE PRESERVED -> {d}")
+ print(f" {ndiff} differing byte(s) across {len(rows)} symbol(s):")
+ for e in rows[:12]:
+ a = f"0x{e['first_addr']:08x}" if e['first_addr'] is not None else '?'
+ print(f" {e['bytes']:>6} {a} {e['symbol']}")
+ if len(rows) > 12:
+ print(f" ... {len(rows)-12} more ({sum(x['bytes'] for x in rows[12:])} bytes)")
+ if len(entries) == 1:
+ fn = entries[0]['fn']
+ inside = per.get(fn, {}).get('bytes', 0)
+ outside = ndiff - inside
+ if inside and not outside:
+ print(f" VERDICT {fn}: BODY REJECT — divergence confined to the function itself.")
+ elif outside and not inside:
+ print(f" VERDICT {fn}: PLUMBING REJECT — the function is BYTE-IDENTICAL; all "
+ f"{outside} differing bytes are elsewhere. Route to the §376/§378 chain "
+ f"(fix_arity_callers --any-proto -> cast_self_callers -> re-gate); do NOT "
+ f"record this as a body reject.")
+ elif inside and outside:
+ print(f" VERDICT {fn}: MIXED — {inside} bytes inside, {outside} outside. The body "
+ f"verdict is UNPROVEN until the outside bytes are fixed and it is re-gated.")
+
+
def try_batch(entries):
run("git checkout -- " + " ".join(main_tus()))
run("make extract BINARY=main") # regenerate .s for the reverted stubs (hazard 2)
substitute(entries)
got, r = clean_build()
- return got == GOOD, got, r
+ ok = got == GOOD
+ if not ok and got is not None and entries:
+ _preserve_and_localize(entries, got)
+ return ok, got, r
def main():
ap = argparse.ArgumentParser()
@@ -694,6 +770,21 @@ def main():
if dropped:
print(" (dropped drafts are usually CORRECT -- recover with a cast-at-use: adopt the")
print(" other declaration verbatim and adapt at the use site, e.g. (&D_x)[i].)")
+ # A DROP IS A ROUTE, NOT A VERDICT (P31 S72). This list is the §376 pile: the draft's
+ # definition disagrees with a forward declaration the TU already carries, which is a
+ # PLUMBING problem with a named fix chain -- not evidence about the body. Printed-only,
+ # it kept getting read as a rejection: S71 recorded six of these as "PROVEN gate-rejects,
+ # §376 in its purest form -- do not re-slate", and they were never re-slated. Writing it
+ # to disk with the chain spelled out makes the recovery the obvious next command instead
+ # of a paragraph someone has to remember.
+ json.dump(dropped, open('.run/gate_main_dropped.json', 'w'), indent=1)
+ print(f" -> .run/gate_main_dropped.json ({len(dropped)} to reconcile). The chain is:")
+ print(f" tools/fix_arity_callers.py --apply --any-proto --funcs "
+ f"{','.join(d['fn'] for d in dropped)} \\\n"
+ f" --drafts
--journal .run//arity.json")
+ print(f" tools/cast_self_callers.py --binary main --funcs --drafts "
+ f"--apply --journal .run//cast.json")
+ print(f" tools/gate_main.py --apply # the byte-gate arbitrates")
if not a.apply:
print("\nDRY RUN. Re-run with --apply to substitute and clean-rebuild.")
return
diff --git a/tools/jtbl_rodata_pads.py b/tools/jtbl_rodata_pads.py
index 95d86a101..9bd219ba7 100644
--- a/tools/jtbl_rodata_pads.py
+++ b/tools/jtbl_rodata_pads.py
@@ -121,9 +121,38 @@ def run(pads, lines, out):
# ---------------------------------------------------------------------------------------------
import os, struct
+def _splat_yaml(binary):
+ """main's config is `splat.us.exe.yaml`; every other binary is `splat..yaml`.
+
+ `corpus.splat_config` says the same thing, but this filter sits in the hot `build/src/%.o`
+ recipe (once per object, every build), so it stays free of the corpus layer's import cost.
+ Kept to one expression so the two cannot drift apart in shape."""
+ return "config/splat.us.exe.yaml" if binary == "main" else "config/splat.%s.yaml" % binary
+
+
+def _file0_vram(y):
+ """The vram that byte 0 of the target file corresponds to.
+
+ For a flat overlay blob this IS the segment vram: the payload starts at file 0. main is a
+ PS-X EXE whose code segment starts at FILE offset 0x800 (the header), so the vram matching
+ raw[0] is `vram - start` = 0x80010000 - 0x800. Returning the FILE-0 vram rather than the
+ segment vram is what makes both `raw[a - vram]` (address -> bytes) and `vram + `
+ (yaml piece -> address) correct in BOTH shapes with one expression instead of two code paths.
+ Derived from the same yaml the build reads (R33); `family_remap.vram_of` derives it the same
+ way for the family engine."""
+ m = re.search(r"-\s*name:\s*\w+\s*\n\s*type:\s*code\s*\n\s*start:\s*(0x[0-9A-Fa-f]+)"
+ r"\s*\n\s*vram:\s*(0x[0-9A-Fa-f]+)", y)
+ if m:
+ return int(m.group(2), 16) - int(m.group(1), 16)
+ m = re.search(r"^\s*vram:\s*(0x[0-9A-Fa-f]+)", y, re.M)
+ if not m:
+ sys.exit("jtbl_rodata_pads: no vram in the splat config (R32 — refusing a default)")
+ return int(m.group(1), 16)
+
+
def _module_target(binary):
- y = open("config/splat.%s.yaml" % binary).read()
- vram = int(re.search(r"^\s*vram:\s*(0x[0-9A-Fa-f]+)", y, re.M).group(1), 16)
+ y = open(_splat_yaml(binary)).read()
+ vram = _file0_vram(y)
tgt = re.search(r"^\s*(?:target_)?path:\s*(\S+)", y, re.M).group(1)
return vram, open(tgt, "rb").read()
@@ -204,8 +233,8 @@ def _tu_piece(binary, tu):
stream has no anchor before its first C table (an isolated §260 object)."""
if not tu:
return None
- y = open("config/splat.%s.yaml" % binary).read()
- vram = int(re.search(r"^\s*vram:\s*(0x[0-9A-Fa-f]+)", y, re.M).group(1), 16)
+ y = open(_splat_yaml(binary)).read()
+ vram = _file0_vram(y)
segs = [(int(a, 16), k, n) for a, k, n in re.findall(r"^\s*- \[0x([0-9A-Fa-f]+), (\S+), (\S+?)\]", y, re.M)]
for i, (a, k, n) in enumerate(segs):
if k == ".rodata" and n == tu:
diff --git a/tools/main_diff_locate.py b/tools/main_diff_locate.py
new file mode 100644
index 000000000..b4cc8e959
--- /dev/null
+++ b/tools/main_diff_locate.py
@@ -0,0 +1,289 @@
+#!/usr/bin/env python3
+"""main_diff_locate.py -- turn a RED whole-binary gate into a NAMED list of divergent symbols.
+
+WHY THIS EXISTS (P31 S72). A main gate's entire output is two hashes:
+
+ [FAIL] build/us/SLUS_007.26
+ got 817987d141d07ced0cc74e8bb0f8bb33eb41cfd1
+ want 143dbb89f34491258bbc27810d0a12ec8b43a8dd
+
+That is a correctness oracle with ZERO diagnostic content, and the campaign has been paying for
+it. S71 substituted 11 main drafts one at a time, watched 7 of them come back with a different
+hash, and recorded all 11 as "PROVEN gate-rejects -- §376 in its purest form". But a hash says
+only THAT the image moved, never WHERE: a draft whose own body is byte-perfect still moves the
+image if the substitution perturbed a CALLER (the classic §376 shape -- the TU keeps a stale
+`extern void f(void*)` forward declaration while the new definition is `void f(s32)`, so every
+call site's argument codegen changes). Attributing that to the drafted function is the R40
+failure mode: blaming the subject for a harness effect, with no instrument that could tell them
+apart.
+
+WHAT IT DOES
+ diff the built image against the retail reference, coalesce the differing bytes into runs, and
+ name the symbol each run lands in using the linker map. The answer that matters is one line:
+ is the divergence INSIDE the function you drafted, or somewhere else?
+
+ * diff inside the drafted function only -> a real body reject (and, if `match_one` said
+ closeness 0, evidence of a match_one blind spot -- §405-A: it compares .text only, so a
+ switch's .rodata jump table is invisible to it).
+ * diff in a CALLER / elsewhere -> a plumbing reject, not a body reject. Route to
+ the §376/§378 chain (fix_arity_callers -> cast_self_callers -> --sync-decls), re-gate.
+ * diff in both -> report both; the body verdict is unproven until
+ the plumbing half is fixed and it is re-gated.
+
+DERIVED, NOT HARDCODED (R33). The file-offset mapping comes from the map's own
+`load address 0x...` on each output section, not from the PS-X EXE's 0x800 header constant, so a
+resegmentation cannot silently skew every reported address by a fixed amount.
+
+NEGATIVE CONTROL (R39, and `check-against-a-known-true-case`). `--self-test` flips one byte at a
+caller-supplied address in a copy of the reference and asserts the tool names the containing
+symbol, then asserts a byte-identical pair reports zero. A localizer that cannot be shown to
+finger a KNOWN perturbation is not evidence about an unknown one.
+
+Usage:
+ tools/main_diff_locate.py # build/ vs extracted/retail, table
+ tools/main_diff_locate.py --focus func_8001A114 # verdict relative to one function
+ tools/main_diff_locate.py --json # machine-readable
+ tools/main_diff_locate.py --self-test 0x8001a114
+"""
+import argparse, bisect, functools, json, os, re, sys
+
+print = functools.partial(print, flush=True)
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+BUILT = 'build/us/SLUS_007.26'
+REF = 'extracted/retail/SLUS_007.26'
+MAP = 'build/us/SLUS_007.26.map'
+
+# ld prints an output section as `.main 0x80010000 0x64800 load address 0x00000800`, and the
+# LMA is the only statement in the whole toolchain of where a vaddr lands in the FILE. Reading it
+# beats restating the 0x800 PS-X header here (R33) -- and it is per-section, which a constant is not.
+SECTION = re.compile(r'^(\.\S+)\s+0x([0-9a-fA-F]+)\s+0x([0-9a-fA-F]+)\s+load address 0x([0-9a-fA-F]+)')
+# A symbol line is an address and a name, indented, nothing else on the line. The `\S+$` anchor is
+# what keeps input-section lines (`.text 0x80010000 0x23f0 build/src/boot.o`) out: those carry a
+# size column, so they never reduce to one trailing token after the address.
+SYMBOL = re.compile(r'^\s+0x([0-9a-fA-F]+)\s{2,}(\S+)$')
+INPUT_SEC = re.compile(r'^\s(\.\S+)\s+0x([0-9a-fA-F]+)\s+0x([0-9a-fA-F]+)\s+(\S+)$')
+
+
+def parse_map(path):
+ """-> (sections, syms) where sections is [(vma, size, lma)] and syms is sorted [(addr, name, obj)].
+
+ Two map facts this has to survive:
+ * splat emits `func_X.NON_MATCHING` and `func_X` at the SAME address for every stubbed
+ function. Keeping both doubles every row of the report and makes an attribution look
+ ambiguous when it is not, so the `.NON_MATCHING` alias is dropped in favour of the real
+ name whenever both sit on one address.
+ * `ld` has no per-symbol size here. A symbol's extent is [its address, the next DISTINCT
+ address) -- the next-address rule is the only end marker available, and it is exact for
+ contiguous code.
+ """
+ sections, raw, obj_of, cur_obj = [], {}, {}, None
+ for line in open(path, errors='replace'):
+ line = line.rstrip('\n')
+ m = SECTION.match(line)
+ if m:
+ sections.append((int(m.group(2), 16), int(m.group(3), 16), int(m.group(4), 16)))
+ continue
+ m = INPUT_SEC.match(line)
+ if m:
+ cur_obj = '%s(%s)' % (m.group(4), m.group(1))
+ continue
+ m = SYMBOL.match(line)
+ if m:
+ addr, name = int(m.group(1), 16), m.group(2)
+ if name.endswith('= .') or '=' in name:
+ continue
+ prev = raw.get(addr)
+ # prefer the real name over splat's `.NON_MATCHING` alias at the same address
+ if prev is None or (prev.endswith('.NON_MATCHING') and not name.endswith('.NON_MATCHING')):
+ raw[addr] = name
+ obj_of[addr] = cur_obj
+ syms = sorted((a, n, obj_of.get(a)) for a, n in raw.items())
+ return sections, syms
+
+
+def off_to_addr(sections, off):
+ """Map a file offset back to a RAM address, preferring the SMALLEST containing section.
+
+ The output sections OVERLAP in file offsets here: `.main` spans 0x800..0x65000 and every
+ linked PsyQ library block sits inside that range with its own `load address`. Both mappings
+ agree today (the libs are contiguous inside .main), but "first match wins" would silently
+ depend on map ordering the day they stop agreeing, so the tightest section wins."""
+ best = None
+ for vma, size, lma in sections:
+ if lma <= off < lma + size and (best is None or size < best[1]):
+ best = (vma, size, lma)
+ return None if best is None else best[0] + (off - best[2])
+
+
+def addr_to_sym(syms, addrs, addr):
+ """Name the symbol containing `addr`, or None before the first symbol."""
+ i = bisect.bisect_right(addrs, addr) - 1
+ if i < 0:
+ return None, None
+ return syms[i][1], syms[i][2]
+
+
+def diff_runs(a, b, gap=64):
+ """Differing byte offsets, coalesced into runs separated by more than `gap` identical bytes.
+
+ Regalloc drift scatters single differing WORDS across a whole function; emitting one run per
+ word buries the answer in hundreds of rows. Merging across a small gap groups them back into
+ the one region a human (or a router) actually reasons about, while `nbytes` keeps the honest
+ count of bytes that actually differ (R41 -- the run count is not the magnitude)."""
+ n = min(len(a), len(b))
+ runs, start, last, ndiff = [], None, None, 0
+ for i in range(n):
+ if a[i] != b[i]:
+ ndiff += 1
+ if start is None:
+ start, last = i, i
+ elif i - last > gap:
+ runs.append((start, last + 1))
+ start = i
+ last = i
+ if start is not None:
+ runs.append((start, last + 1))
+ return runs, ndiff, (len(a) != len(b))
+
+
+def attribute(built, ref, sections, syms, gap=64):
+ """-> (per_symbol, total_diff_bytes, size_mismatch).
+
+ Attribution is PER BYTE, not per run: a run that straddles two functions is credited to both
+ in the right proportion. Crediting a whole run to the symbol its first byte lands in is how a
+ one-byte spill into the next function gets reported as "two functions diverged"."""
+ addrs = [s[0] for s in syms]
+ runs, ndiff, size_mismatch = diff_runs(built, ref, gap)
+ per = {}
+ for lo, hi in runs:
+ for off in range(lo, hi):
+ if built[off] == ref[off]:
+ continue
+ addr = off_to_addr(sections, off)
+ if addr is None:
+ key, obj = '', None
+ else:
+ name, obj = addr_to_sym(syms, addrs, addr)
+ key = name or ''
+ e = per.setdefault(key, {'symbol': key, 'obj': obj, 'bytes': 0,
+ 'first_off': off, 'first_addr': addr, 'last_addr': addr})
+ e['bytes'] += 1
+ e['last_addr'] = addr
+ return per, ndiff, size_mismatch
+
+
+def report(focus=None, as_json=False, built_path=BUILT, ref_path=REF, map_path=MAP, gap=64):
+ for p in (built_path, ref_path, map_path):
+ if not os.path.exists(p):
+ print(f"REFUSED — missing {p}", file=sys.stderr)
+ return 2
+ built, ref = open(built_path, 'rb').read(), open(ref_path, 'rb').read()
+ sections, syms = parse_map(map_path)
+ if not sections or not syms:
+ print(f"REFUSED — {map_path} yielded {len(sections)} sections / {len(syms)} symbols; "
+ f"the map format is not what this tool parses, and a localizer that silently "
+ f"attributes nothing is worse than none (R43).", file=sys.stderr)
+ return 2
+ per, ndiff, size_mismatch = attribute(built, ref, sections, syms, gap)
+ rows = sorted(per.values(), key=lambda e: -e['bytes'])
+
+ if as_json:
+ print(json.dumps({'diff_bytes': ndiff, 'file_bytes': len(ref),
+ 'size_mismatch': size_mismatch, 'focus': focus,
+ 'symbols': rows}, indent=1))
+ return 0 if ndiff == 0 else 1
+
+ if size_mismatch:
+ print(f"!! SIZE MISMATCH — built {len(built)} bytes, reference {len(ref)} bytes. "
+ f"Offsets past the shorter file are not compared.")
+ if ndiff == 0:
+ print(f"IDENTICAL — 0 differing bytes of {len(ref)}.")
+ return 0
+ print(f"{ndiff} differing bytes of {len(ref)} ({100.0*ndiff/len(ref):.4f}%), "
+ f"across {len(rows)} symbol(s):\n")
+ print(f" {'bytes':>7} {'first addr':>10} symbol")
+ for e in rows[:40]:
+ a = f"0x{e['first_addr']:08x}" if e['first_addr'] is not None else f"@{e['first_off']}"
+ print(f" {e['bytes']:>7} {a:>10} {e['symbol']}"
+ + (f" [{e['obj']}]" if e['obj'] else ''))
+ if len(rows) > 40:
+ print(f" ... and {len(rows)-40} more symbol(s) not listed "
+ f"({sum(r['bytes'] for r in rows[40:])} bytes)")
+
+ if focus:
+ inside = per.get(focus, {}).get('bytes', 0)
+ outside = ndiff - inside
+ print()
+ if inside and not outside:
+ print(f"VERDICT — divergence is CONFINED TO {focus} ({inside} bytes). A real body "
+ f"reject. If match_one said closeness 0, suspect its .text-only blind spot "
+ f"(§405-A: a switch's .rodata jump table is invisible to it).")
+ elif outside and not inside:
+ print(f"VERDICT — {focus} is BYTE-IDENTICAL; all {outside} differing bytes are "
+ f"ELSEWHERE. This is a PLUMBING reject, not a body reject: the substitution "
+ f"perturbed other code (§376 -- a stale forward declaration changes caller "
+ f"codegen). Route to fix_arity_callers -> cast_self_callers -> --sync-decls.")
+ elif inside and outside:
+ print(f"VERDICT — MIXED: {inside} bytes inside {focus}, {outside} elsewhere. The body "
+ f"verdict is UNPROVEN until the {outside} outside bytes are fixed and it is "
+ f"re-gated (a perturbed caller can also perturb the callee's own codegen).")
+ else:
+ print(f"VERDICT — {focus} is not among the divergent symbols and neither is anything "
+ f"attributable to it; check the name (it must match the linker map exactly).")
+ return 1
+
+
+def self_test(addr_hex, map_path=MAP, ref_path=REF):
+ """Flip one byte at a KNOWN address and assert the tool names the containing symbol.
+
+ R39 / `check-against-a-known-true-case`. The identical-pair direction is asserted too: a
+ localizer that reports a diff on identical inputs would make every verdict above worthless."""
+ addr = int(addr_hex, 16)
+ ref = open(ref_path, 'rb').read()
+ sections, syms = parse_map(map_path)
+ addrs = [s[0] for s in syms]
+ want, _ = addr_to_sym(syms, addrs, addr)
+ off = None
+ for vma, size, lma in sections:
+ if vma <= addr < vma + size:
+ off = lma + (addr - vma)
+ if off is None:
+ print(f"SELF-TEST REFUSED — 0x{addr:08x} is in no output section")
+ return 2
+ ok = True
+
+ # direction 1: identical inputs must report zero
+ per, ndiff, _ = attribute(ref, ref, sections, syms)
+ print(f" identical-pair -> {ndiff} differing bytes, {len(per)} symbols "
+ f"{'OK' if ndiff == 0 and not per else 'FAIL'}")
+ ok &= (ndiff == 0 and not per)
+
+ # direction 2: a known one-byte perturbation must be attributed to the containing symbol
+ mut = bytearray(ref)
+ mut[off] ^= 0xFF
+ per, ndiff, _ = attribute(bytes(mut), ref, sections, syms)
+ got = list(per)
+ hit = (ndiff == 1 and got == [want])
+ print(f" 1-byte flip at 0x{addr:08x} (file offset {off}) -> {ndiff} byte(s) in {got}; "
+ f"expected exactly ['{want}'] {'OK' if hit else 'FAIL'}")
+ ok &= hit
+ print('SELF-TEST', 'PASS' if ok else 'FAIL')
+ return 0 if ok else 1
+
+
+if __name__ == '__main__':
+ os.chdir(REPO)
+ ap = argparse.ArgumentParser()
+ ap.add_argument('--built', default=BUILT)
+ ap.add_argument('--ref', default=REF)
+ ap.add_argument('--map', dest='map_path', default=MAP)
+ ap.add_argument('--focus', help='the function you substituted; adds an attribution verdict')
+ ap.add_argument('--gap', type=int, default=64, help='coalesce runs separated by <= N bytes')
+ ap.add_argument('--json', action='store_true')
+ ap.add_argument('--self-test', metavar='ADDR',
+ help='negative control: flip one byte at ADDR and assert attribution')
+ a = ap.parse_args()
+ if a.self_test:
+ sys.exit(self_test(a.self_test, a.map_path, a.ref))
+ sys.exit(report(a.focus, a.json, a.built, a.ref, a.map_path, a.gap))