Files
Drew T 867f09221c feat(oracle): main gets its independent second oracle — contract §1.3 closed
The roadmap's completion contract requires both audit oracles green before any
100% claim on main, and main had none: audit-corpus covered overlays and
resident only, and R34 is explicit that the byte gate is a perfect CORRECTNESS
oracle and a NULL COVERAGE oracle — green whether a function was sliced right
or invented, because the .s pieces paste back either way.

sig_image gains multi-range signing, closing all three blockers
docs/second-oracle.md scoped:
  * the 0x800 PS-X EXE header -> --vram-base 0x8000F800 puts file offset 0 at
    vram, so the header falls below the first range
  * interleaved data + linked islands -> --segments derives 28 game-code ranges
    from the splat yaml's SEGMENT rows
  * one text range -> the signer loops ranges, bootstrapping INSIDE each, which
    is what stops the linear partition running through a data island and minting
    functions out of it (the detector manufacturing the class it detects)

INDEPENDENCE IS PRESERVED, NOT WORKED AROUND. Ranges come from segment TYPES,
never from splat's function boundaries; entries are still found by byte-derived
jal-closure. Seeding from splat's symbols would make every phantom look real —
the trap the design doc names. .run/sig.main.jsonl (the splat-SEEDED atlas sig)
is a different file and corpus.ORACLE_SIG keeps the audit off it.

RESULT: 986 functions signed. main audit = 0 PHANTOM, 0 TRUNCATED, 1 PAD-TAIL.
Fleet audit-corpus = 0 + 0, unchanged for resident and overlays.

NEW AUDIT CLASS, from the first real finding. func_80062144: splat .s 65 ins,
oracle 64 — the extra line is a nop one line BELOW endlabel. That is an
alignment pad the matching side already emits from C (§295; two S77 wave agents
did it on func_8005E13C and func_8005D538), not a mis-slice. Lumping it with
TRUNCATED would make the oracle's first finding look like a defect and bury the
class that is one.

COVERAGE ASSERTED both ways before trusting it (R32): all 30 game-code stubs
fall inside a range, and 0 of 199 addr-parseable LINKED stubs do.
2026-09-03 21:58:17 -06:00

345 lines
18 KiB
Python

#!/usr/bin/env python3
"""sig_image.py — Ghidra-FREE per-function signer for a flat PS1 image (Phase 11).
Signs a flat binary (an extracted overlay payload `0.4.dec`, or the resident `1.1`) at a known
vram base, emitting JSONL field-identical to tools/ghidra_scripts/DumpFunctionSignatures.java so a
function appearing in both an imported binary and a flat image hashes the SAME. This lets all ~134
overlays be signed WITHOUT importing each into Ghidra — the input to the cross-binary dedup report.
`h_exact` is the workhorse: it is SHA1 of the function's raw instruction bytes — format-independent,
so it needs only correct boundaries + a byte slice (no normalization). All overlays load at the same
vram (0x80128158), so a shared function at the same offset is byte-identical (h_exact) across overlays.
`h_norm` (structural tier) is a self-contained relocation normalizer (`norm_stream`, below): it masks
j/jal 26-bit targets, `lui` HI16, and the register-paired `lo` LO16 (tracking the hi/lo pairing from the
instruction stream alone, no reloc table needed), while keeping registers, true immediates, and
PC-relative branches. So for two copies of a function, `h_exact !=` but `h_norm ==` means they differ
ONLY in relocations (per-overlay symbol addresses) — the structural-family signal. It is CONSERVATIVE:
it can miss a match, never forge one (a proposed `--tier h_norm` share is still confirmed by the
per-overlay whole-binary byte-gate). `h_seq` = SHA1 of the mnemonic (opcode-name) sequence. All 134
overlays are signed via `make sig-overlays`. (Historical note: these tiers were once deferred to
"T5/T6"; they have been live — the real `norm_stream` — since Phase 11.)
Disassembly (rabbitizer — the same engine splat uses) is needed ONLY for boundary detection
(`jr $ra` ends, `jal` call targets) and (in T5) normalization.
Usage:
sig_image.py --image PATH --vram-base HEX [--name NAME] [--out PATH]
[--seeds PATH] [--text-lo HEX] [--text-hi HEX] [--bootstrap]
--seeds : known function entry addresses — a sig .jsonl (reads 'addr'+'name') or a plain
list of 0xADDR lines. Without --seeds, --bootstrap discovers entries by jal-closure.
--text-lo/--text-hi : restrict the code sweep (exclude data/rodata). Default: derived from seeds.
"""
import argparse, hashlib, json, pathlib, struct, sys
import rabbitizer as R
def make_insn(word, vram):
"""Decode one 32-bit word at vram. cop2/GTE words (opcode 0x12) need the GTE category."""
if (word >> 26) == 0x12:
try:
return R.Instruction(word, vram, category=R.InstrCategory.R3000GTE)
except Exception:
pass
return R.Instruction(word, vram)
def read_seeds(path):
"""Return ({addr: name}, {addr: nins}). Accepts a sig .jsonl (uses 'addr'/'name' and, when
present, 'nins'), plain 0xADDR lines, or `0xADDR NINS` lines (P31 T3: splat-true function
lengths for binaries — main — where the func_end heuristic mis-slices; a seeded nins is
authoritative and bypasses func_end entirely)."""
seeds, ends = {}, {}
for line in pathlib.Path(path).read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("{"):
r = json.loads(line)
a = int(r["addr"], 16)
seeds[a] = r.get("name", "")
if r.get("nins"):
ends[a] = int(r["nins"])
else:
parts = line.split()
a = int(parts[0], 0)
seeds[a] = ""
if len(parts) > 1:
ends[a] = int(parts[1], 0)
return seeds, ends
def detect_code_end(data, vram_base, lo, hi, run=3):
"""Find the code->data boundary as the first run of `run` consecutive INVALID instructions.
Overlay code decodes ~100% valid (verified: the SC01/077 code prefix is 100% valid, the data
tail drops to 43-95%), so the first sustained invalid run is the transition. A single invalid
word (a rare decode quirk) does not trip it; `run` consecutive does. Returns a vram <= hi."""
bad = 0
o = lo - vram_base
end_off = hi - vram_base
while o + 4 <= end_off:
if make_insn(struct.unpack_from("<I", data, o)[0], vram_base + o).isValid():
bad = 0
else:
bad += 1
if bad >= run:
return vram_base + o - (run - 1) * 4 # back up to the start of the invalid run
o += 4
return hi
def bootstrap_seeds(data, vram_base, entry, hi):
"""Discover entries with no Ghidra by LINEAR PARTITION of the contiguous code: walk from `entry`,
each function is [pos, func_end(pos)], the next starts right after. Stop at the first block with
NO return (func_end hits the hard bound) — that is the code->data transition (data has no regular
`jr $ra` epilogue). Overlays dispatch most code via function-pointer tables (not `jal`), so a
call-graph BFS finds almost nothing; linear partition recovers the whole contiguous-code prefix.
Coverage gap (documented): functions AFTER an embedded data island / jump table, or tail-call
functions ending in `j` (no `jr`), are not reached until splat boundaries land (Phase 13). For the
dedup scan this is conservative — every function found is real; byte-identical overlays match fully."""
seeds = set()
pos = entry
while pos < hi:
end = func_end(data, vram_base, pos, hi)
if end >= hi: # no return found in [pos, hi): left the code region -> stop
break
seeds.add(pos)
pos = end
return seeds
def func_end(data, vram_base, start, hard_end):
"""End = the first `jr $ra`(+delay slot) that lies at/after EVERY forward branch/jump target seen
so far. This (a) does not mistake an early-return `jr` for the end (a later branch jumps past it),
and (b) ignores a trailing orphan `jr;nop` after the real epilogue (the double-epilogue case). No
qualifying return (tail-call / data) -> hard_end (the next seed)."""
max_target = start
o = start - vram_base
end_off = hard_end - vram_base
while o + 4 <= end_off:
vram = vram_base + o
ins = make_insn(struct.unpack_from("<I", data, o)[0], vram)
if ins.isReturn() and vram >= max_target:
return min(hard_end, vram + 8) # jr + its delay slot
tgt = None
if ins.isBranch():
try: tgt = ins.getBranchVramGeneric()
except Exception: tgt = None
elif ins.isJump() and not ins.isFunctionCall():
try: tgt = ins.getInstrIndexAsVram()
except Exception: tgt = None
if tgt is not None and start <= tgt < hard_end:
max_target = max(max_target, tgt)
o += 4
return hard_end
# I-type opcodes whose 16-bit immediate is an ADDRESS low-half when the base/source register (rs)
# currently holds a lui-loaded address high (hi/lo pairing): loads, stores, addiu/ori/etc.
_ITYPE_ADDR = frozenset((0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, # lb lh lwl lw lbu lhu lwr
0x28, 0x29, 0x2A, 0x2B, 0x2E, # sb sh swl sw swr
0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E)) # addi addiu slti sltiu andi ori xori
def norm_stream(raw):
"""Self-consistent normalized byte stream: mask the address-sensitive fields so two
structurally-identical functions at DIFFERENT addresses normalize to the same bytes —
* j / jal : 26-bit target -> 0
* lui : 16-bit high -> 0 (and the dest register is flagged 'holds an addr-hi')
* lw/sw/addiu/… : 16-bit imm -> 0 ONLY when rs holds a lui-loaded addr-hi (hi/lo pair)
while KEEPING registers (regalloc matters), true constants, and PC-relative branch offsets
(already position-independent in the encoding). NOT byte-identical to Ghidra's normToken
(a deliberately different, simpler model — the scope-guard path); self-consistent WITHIN
sig_image so the overlay fleet's structural dups group. h_exact stays the format-independent
cross-tool tier. The hi/lo tracker is consistent (depends only on the instruction stream, not
absolute addresses), so any imprecision is conservative: it can miss a match, never forge one."""
pending_hi = set() # registers currently holding a lui address-high
out = bytearray()
for k in range(0, len(raw) - (len(raw) % 4), 4):
w = struct.unpack_from("<I", raw, k)[0]
op = w >> 26
rs = (w >> 21) & 0x1F
rt = (w >> 16) & 0x1F
nw = w
if op in (2, 3): # j / jal -> mask absolute target
nw = w & 0xFC000000
elif op == 0x0F: # lui -> mask high; rt now holds an addr-hi
nw = w & 0xFFFF0000
pending_hi.add(rt)
out += struct.pack("<I", nw)
continue
elif op in _ITYPE_ADDR:
if rs in pending_hi: # hi/lo pair -> the lo immediate is address-derived
nw = w & 0xFFFF0000
pending_hi.discard(rt) # rt is overwritten (no longer a stale hi)
elif op == 0: # R-type: dest rd overwritten
pending_hi.discard((w >> 11) & 0x1F)
out += struct.pack("<I", nw)
return bytes(out)
def sign_function(data, vram_base, start, end, name):
off = start - vram_base
n = end - start
raw = data[off:off + n]
h_exact = hashlib.sha1(raw).hexdigest()
mnem = []
calls = []
for k in range(0, n, 4):
word = struct.unpack_from("<I", raw, k)[0]
ins = make_insn(word, start + k)
mnem.append(ins.getOpcodeName())
if ins.isFunctionCall():
try:
calls.append(f"{ins.getInstrIndexAsVram():08x}") # 8-hex, NO 0x — matches the Ghidra dumper
except RuntimeError:
pass # jalr (register-indirect call): no static target, as in the reference-based dumper
h_norm = hashlib.sha1(norm_stream(raw)).hexdigest() # self-consistent (see norm_stream)
h_seq = hashlib.sha1((" ".join(mnem) + " ").encode()).hexdigest() if mnem else h_exact
return {
"addr": f"0x{start:08x}", "name": name or f"func_{start:08x}", "src": "IMAGE",
"nins": n // 4, "nbytes": n, "ncalls": len(calls),
"h_exact": h_exact, "h_norm": h_norm, "h_seq": h_seq, "calls": calls,
}
def sign_image(data, vram_base, seeds_map, lo, hi, ends=None):
"""seeds_map: {addr: name}. Functions are [seed, next_seed) trimmed to the real return end —
UNLESS `ends` carries a seeded nins for the seed (P31 T3), which is authoritative: the slice is
exactly [seed, seed + 4*nins), no heuristic. Needed for main, whose interleaved data islands
and tail-call shapes defeat func_end (measured 3/40 mis-slices)."""
seeds = sorted(a for a in seeds_map if lo <= a < hi)
rows = []
for i, s in enumerate(seeds):
hard = seeds[i + 1] if i + 1 < len(seeds) else hi
if ends and s in ends:
end = s + 4 * ends[s]
if end > hi:
raise SystemExit(f"sig_image: seeded end {end:#x} for {s:#x} exceeds hi {hi:#x} (R32)")
else:
end = func_end(data, vram_base, s, hard)
rows.append(sign_function(data, vram_base, s, end, seeds_map.get(s, "")))
return rows
def code_ranges_from_splat(yaml_path, vram_base, exclude=()):
"""[(lo, hi)] game-code vram ranges from a splat config's SEGMENT rows. (P31 S77)
THE INDEPENDENCE RULE THIS OBEYS. The main EXE is not one contiguous code region: it has a
0x800 header, rodata/data islands, and linked PsyQ blocks interleaved between game code, so the
single `[--text-lo, --text-hi)` sweep this tool was built on cannot sign it (docs/second-oracle.md
names exactly these three blockers). The fix uses the splat yaml's SEGMENT rows — `[file_off,
type, name]` — and NOTHING else. Segment types are coarse structure (where code is at all);
they are NOT splat's function boundaries, which is the thing this oracle exists to disagree with.
Seeding from splat's SYMBOLS would be the trap the design doc calls out: a phantom IS a
splat-invented address, so a symbol-seeded run makes every phantom look real and the oracle
becomes a mirror. Ranges keep the two oracles independent where it counts — inside a range,
entries are still discovered by byte-derived jal-closure and ends by `func_end`.
`exclude` drops subsegments by name (the LINKED PsyQ blocks: real library objects, not
decompiled work, and progress.py already excludes them from the game-code denominator)."""
import re as _re
rows, txt = [], pathlib.Path(yaml_path).read_text()
for m in _re.finditer(r"^\s*-\s*\[\s*(0x[0-9A-Fa-f]+)\s*,\s*([A-Za-z_][\w]*)\s*(?:,\s*([\w.]+))?\s*\]",
txt, _re.M):
rows.append((int(m.group(1), 0), m.group(2), m.group(3) or ""))
if not rows:
raise SystemExit("sig_image: no segment rows parsed from %s" % yaml_path)
rows.sort()
out = []
for i, (off, kind, name) in enumerate(rows):
nxt = rows[i + 1][0] if i + 1 < len(rows) else None
if kind != "c" or name in exclude or nxt is None:
continue
lo, hi = vram_base + off, vram_base + nxt
if out and out[-1][1] == lo: # merge adjacent kept ranges
out[-1] = (out[-1][0], hi)
else:
out.append((lo, hi))
return [tuple(r) for r in out]
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--image", required=True, help="flat payload (0.4.dec / 1.1)")
ap.add_argument("--vram-base", required=True, help="fileoff->vram delta (resident 0x800CEDF8, overlay 0x80128158)")
ap.add_argument("--name", default=None, help="output stem -> .run/sig.<name>.jsonl")
ap.add_argument("--out", default=None, help="explicit output path (overrides --name)")
ap.add_argument("--seeds", default=None, help="known entry addrs: a sig .jsonl or 0xADDR-per-line")
ap.add_argument("--text-lo", default=None, help="code region start vram (default: min seed)")
ap.add_argument("--text-hi", default=None, help="code region end vram (default: image end)")
ap.add_argument("--bootstrap", action="store_true", help="discover entries by jal-closure (no --seeds)")
ap.add_argument("--segments", default=None,
help="splat yaml: derive MULTIPLE game-code ranges from its segment rows "
"(types only, never function boundaries — see code_ranges_from_splat)")
ap.add_argument("--exclude-subsegs", default="",
help="comma-separated subseg names to skip (the LINKED PsyQ blocks)")
ap.add_argument("--ranges", default=None, help="explicit lo:hi[,lo:hi...] vram code ranges")
a = ap.parse_args()
data = pathlib.Path(a.image).read_bytes()
vram_base = int(a.vram_base, 0)
img_end = vram_base + len(data)
if a.seeds:
seeds_map, seed_ends = read_seeds(a.seeds)
elif a.bootstrap:
seeds_map, seed_ends = {}, {}
else:
sys.exit("sig_image: need --seeds or --bootstrap")
# lo defaults to vram_base (never below it): a sig .jsonl may carry out-of-range IMPORTED
# entries (e.g. 0x2000xxxx GTE-macro thunks) — excluded by the [lo,hi) seed filter.
lo = int(a.text_lo, 0) if a.text_lo else vram_base
hi = min(int(a.text_hi, 0), img_end) if a.text_hi else img_end
if a.bootstrap and not seeds_map:
# auto-bound the code region (overlays have no splat config yet): stop at the code->data
# transition so the data tail isn't mis-partitioned as functions.
if not a.text_hi:
hi = detect_code_end(data, vram_base, lo, hi)
seeds_map = {s: "" for s in bootstrap_seeds(data, vram_base, lo, hi)}
ranges = None
if a.ranges:
ranges = [tuple(int(x, 0) for x in r.split(":")) for r in a.ranges.split(",") if r.strip()]
elif a.segments:
ranges = code_ranges_from_splat(a.segments, vram_base,
exclude=set(x for x in a.exclude_subsegs.split(",") if x))
if ranges:
# MULTI-RANGE: sign each island separately. A single sweep over the union would let
# bootstrap's linear partition run straight through a data or linked island and mint
# functions out of it — the phantom class this oracle exists to detect, manufactured by the
# detector. Per range: discover entries inside it, sign inside it.
rows, seen = [], set()
for rlo, rhi in ranges:
rlo, rhi = max(rlo, vram_base), min(rhi, img_end)
if rhi <= rlo:
continue
sm = dict(seeds_map) if seeds_map else {}
sm = {s: n for s, n in sm.items() if rlo <= s < rhi}
if not sm:
sm = {s: "" for s in bootstrap_seeds(data, vram_base, rlo, rhi)}
for r in sign_image(data, vram_base, sm, rlo, rhi, ends=seed_ends):
if r["addr"] in seen:
continue
seen.add(r["addr"]); rows.append(r)
print("sig_image: %d code range(s), %d functions" % (len(ranges), len(rows)))
else:
rows = sign_image(data, vram_base, seeds_map, lo, hi, ends=seed_ends)
name = a.name or pathlib.Path(a.image).stem
out = pathlib.Path(a.out) if a.out else pathlib.Path(".run") / f"sig.{name}.jsonl"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("".join(json.dumps(r, separators=(",", ":")) + "\n" for r in rows))
if ranges:
# the legacy [lo..hi) is meaningless in multi-range mode and printing it read as a
# one-range run over 0x6c bytes — a true number about the wrong scope.
print(f"sig_image: {len(rows)} functions across {len(ranges)} range(s) -> {out}")
else:
print(f"sig_image: {len(rows)} functions [{lo:#010x}..{hi:#010x}) -> {out}")
if __name__ == "__main__":
main()