mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-10-08 01:34:14 -04:00
305 lines
15 KiB
Python
305 lines
15 KiB
Python
#!/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 parse_spec(spec):
|
|
"""'0,4,0t1' -> [(0,0),(4,0),(0,1)]: per jump table, (leading pad bytes, trailing pad WORDS).
|
|
P31 S62 T3a: `t<n>` is the module-island shape (§154-A) — a matched body re-emits only the real
|
|
entries while the retail island carries n zero words after the table."""
|
|
out = []
|
|
for tok in spec.split(","):
|
|
m = re.fullmatch(r"(0|4)(?:t(\d+))?", tok.strip())
|
|
if not m:
|
|
sys.exit(f"jtbl_rodata_pads: malformed pad token {tok!r} (want 0, 4, 0t<n> or 4t<n>)")
|
|
out.append((int(m.group(1)), int(m.group(2) or 0)))
|
|
return out
|
|
|
|
|
|
def run(pads, lines, out):
|
|
"""Table-aware (P31 S62 T3a): only an `.align 3` that is FOLLOWED by a `$L` label is a jump
|
|
table (cc1's emission shape); every other rodata line — const data (`.align 2`, `D_…:`,
|
|
`.byte`), string pools, `.include`s — passes through untouched, so the filter now serves
|
|
module TUs (whose .rodata mixes tables with data) as well as the overlay carves."""
|
|
lines = list(lines)
|
|
in_rodata = False
|
|
consumed = 0
|
|
in_table = False
|
|
saw_words = False
|
|
trailing = 0
|
|
def end_table():
|
|
nonlocal in_table, trailing, saw_words
|
|
if in_table:
|
|
for _ in range(trailing):
|
|
out.write(".word 0 # jtbl_rodata_pads: original trailing pad word (module island)\n")
|
|
in_table, trailing, saw_words = False, 0, False
|
|
for k, line in enumerate(lines):
|
|
s = line.strip()
|
|
if s == ".section .rodata" or s == ".rdata":
|
|
end_table(); in_rodata = True; out.write(line); continue
|
|
if in_rodata and (s == ".text" or s.startswith(".section")):
|
|
end_table(); in_rodata = False; out.write(line); continue
|
|
if not in_rodata:
|
|
out.write(line); continue
|
|
if in_table and (WORD_RE.match(s) or (not saw_words and LABEL_RE.match(s))):
|
|
if WORD_RE.match(s):
|
|
saw_words = True
|
|
out.write(line); continue
|
|
if in_table:
|
|
end_table()
|
|
if s.startswith(".align"):
|
|
parts = s.split()
|
|
nxt = next((l.strip() for l in lines[k + 1:] if l.strip()), "")
|
|
if len(parts) == 2 and parts[1] == "3" and LABEL_RE.match(nxt):
|
|
if consumed >= len(pads):
|
|
sys.exit(f"jtbl_rodata_pads: more rodata jump tables than pad specs "
|
|
f"({len(pads)}) — table-count drift vs the carve")
|
|
lead, trailing = pads[consumed]
|
|
if lead == 4:
|
|
out.write(".word 0 # jtbl_rodata_pads: original inter-table .align 3 pad\n")
|
|
consumed += 1
|
|
in_table, saw_words = True, False
|
|
continue
|
|
out.write(line); continue
|
|
out.write(line)
|
|
end_table()
|
|
if consumed != len(pads):
|
|
sys.exit(f"jtbl_rodata_pads: consumed {consumed} rodata jump table(s) but {len(pads)} pad "
|
|
f"spec(s) given — table-count drift vs the carve")
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# --derive <binary> (P31 S62 T3a): the MODULE path. No stored spec: the pads are DERIVED at build
|
|
# time from the retail island + the stream (R33 — nothing to drift). Items in rodata emission order:
|
|
# .include "….s" a stub's/blob's block, span read from its `/* off vaddr … */` comments (anchor)
|
|
# D_XXXXXXXX: C const data, start = the address in its name (anchor), size from directives
|
|
# .align 3 + $L a C jump table: lead 4 iff the retail word at the position is zero, N in-range
|
|
# code words, then trailing zeros up to the next anchor (or the next non-zero)
|
|
# A walk that misses an anchor refuses with the offset — a CARVE-DRIFT verdict at build time.
|
|
# ---------------------------------------------------------------------------------------------
|
|
import os, struct
|
|
|
|
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)
|
|
tgt = re.search(r"^\s*(?:target_)?path:\s*(\S+)", y, re.M).group(1)
|
|
return vram, open(tgt, "rb").read()
|
|
|
|
def _s_rodata_span(path):
|
|
"""[lo, hi) vaddr span of everything the included .s emits into .rodata (comments carry vaddr)."""
|
|
lo, hi, in_ro = None, None, False
|
|
for ln in open(path, errors="replace"):
|
|
st = ln.strip()
|
|
if st.startswith(".section"):
|
|
in_ro = ".rodata" in st or ".rdata" in st
|
|
continue
|
|
if not in_ro:
|
|
continue
|
|
m = re.match(r"/\*\s*[0-9A-Fa-f]+\s+([0-9A-Fa-f]{8})(?:\s+([0-9A-Fa-f]+))?\s*\*/\s*(\S+)\s*(.*)$", st)
|
|
if m:
|
|
a = int(m.group(1), 16)
|
|
if m.group(2):
|
|
n = len(m.group(2)) // 2
|
|
else:
|
|
d, rest = m.group(3), m.group(4)
|
|
if d in _DIRSIZE:
|
|
n = _DIRSIZE[d] * len([x for x in rest.split(",") if x.strip()])
|
|
elif d in (".ascii", ".asciz"):
|
|
body = rest.strip()
|
|
txt = body[1:-1].encode().decode("unicode_escape") if body.startswith('"') else ""
|
|
n = len(txt) + (1 if d == ".asciz" else 0)
|
|
else:
|
|
continue
|
|
lo = a if lo is None else min(lo, a); hi = a + n if hi is None else max(hi, a + n)
|
|
return lo, hi
|
|
|
|
_DIRSIZE = {".word": 4, ".long": 4, ".half": 2, ".short": 2, ".byte": 1, ".float": 4, ".double": 8}
|
|
|
|
def _items(lines):
|
|
"""rodata items in emission order: ('s', path) | ('cdata', addr, size, align) | ('ctable', n)."""
|
|
items, in_ro, k = [], False, 0
|
|
cur = None # current cdata: [addr, size, align]
|
|
pend_align = 0
|
|
while k < len(lines):
|
|
st = lines[k].strip(); k += 1
|
|
if st == ".section .rodata" or st == ".rdata":
|
|
in_ro = True; cur = None; continue
|
|
if in_ro and (st == ".text" or st.startswith(".section")):
|
|
in_ro = False; cur = None; continue
|
|
m = re.match(r'\.include\s+"([^"]+)"', st)
|
|
if m: # a stub's .s switches sections itself — record it in ANY context
|
|
if m.group(1).endswith(".s") and "/nonmatchings/" in m.group(1):
|
|
items.append(("s", m.group(1)))
|
|
cur = None; continue
|
|
if not in_ro or not st:
|
|
continue
|
|
if st.startswith(".align"):
|
|
n = int(st.split()[1])
|
|
nxt = next((l.strip() for l in lines[k:] if l.strip()), "")
|
|
if n == 3 and LABEL_RE.match(nxt):
|
|
cnt, j = 0, k + 1
|
|
while j < len(lines) and WORD_RE.match(lines[j].strip()):
|
|
cnt += 1; j += 1
|
|
items.append(("ctable", cnt)); cur = None; k = j; continue
|
|
pend_align = 1 << n; cur = None; continue
|
|
m = re.match(r"^(D_[0-9A-Fa-f]{8}):$", st)
|
|
if m:
|
|
cur = [int(m.group(1)[2:], 16), 0, pend_align]; pend_align = 0
|
|
items.append(("cdata", cur)); continue
|
|
d = st.split()[0]
|
|
if cur is not None and d in _DIRSIZE:
|
|
cur[1] += _DIRSIZE[d] * len([x for x in st[len(d):].split(",") if x.strip()])
|
|
elif cur is not None and d in (".ascii", ".asciz"):
|
|
body = st[len(d):].strip()
|
|
txt = body[1:-1].encode().decode("unicode_escape") if body.startswith('"') else ""
|
|
cur[1] += len(txt) + (1 if d == ".asciz" else 0)
|
|
elif cur is not None and d in (".space", ".skip"):
|
|
cur[1] += int(st.split()[1])
|
|
return items
|
|
|
|
def _tu_piece(binary, tu):
|
|
"""(start, end) vaddr of the yaml `.rodata` piece bound to TU — the frame for a TU whose rodata
|
|
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)
|
|
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:
|
|
end = segs[i + 1][0] if i + 1 < len(segs) else None
|
|
return vram + a, (vram + end) if end is not None else None
|
|
return None
|
|
|
|
def derive(binary, lines, tu=None):
|
|
vram, raw = _module_target(binary)
|
|
lo_code, hi_code = vram, vram + len(raw)
|
|
word = lambda a: struct.unpack_from("<I", raw, a - vram)[0]
|
|
items = _items(lines)
|
|
if not any(it[0] == "ctable" for it in items):
|
|
return [] # nothing to pad -> nothing to derive, nothing to refuse
|
|
piece = _tu_piece(binary, tu)
|
|
pos, spec = None, []
|
|
def zero_gap(a, b): # assembler alignment padding between two blocks
|
|
return 0 < b - a < 4 and all(raw[x - vram] == 0 for x in range(a, b))
|
|
def anchor_start(it):
|
|
if it[0] == "s":
|
|
return _s_rodata_span(it[1])[0]
|
|
if it[0] == "cdata":
|
|
return it[1][0]
|
|
return None
|
|
for idx, it in enumerate(items):
|
|
if it[0] == "s":
|
|
lo, hi = _s_rodata_span(it[1])
|
|
if lo is None:
|
|
continue
|
|
if pos is None:
|
|
pos = lo
|
|
if lo != pos and zero_gap(pos, lo):
|
|
pos = lo
|
|
if lo != pos:
|
|
sys.exit("jtbl_rodata_pads --derive %s: %s starts at 0x%X but the walk is at 0x%X "
|
|
"(%+d) — island layout drift" % (binary, os.path.basename(it[1]), lo, pos, lo - pos))
|
|
pos = hi
|
|
elif it[0] == "cdata":
|
|
addr, size, al = it[1]
|
|
if pos is None:
|
|
pos = addr
|
|
ap = pos if al <= 1 else (pos + al - 1) // al * al
|
|
if ap != addr and zero_gap(ap, addr):
|
|
ap = addr
|
|
if ap != addr:
|
|
sys.exit("jtbl_rodata_pads --derive %s: C data D_%08X expected at 0x%X (walk 0x%X, align %d)"
|
|
% (binary, addr, ap, pos, al))
|
|
pos = addr + size
|
|
else:
|
|
n = it[1]
|
|
if pos is None:
|
|
if piece is None:
|
|
sys.exit("jtbl_rodata_pads --derive %s: a C jump table precedes every anchor and "
|
|
"no yaml .rodata piece is bound to TU %r — cannot place it" % (binary, tu))
|
|
pos = piece[0]
|
|
lead = 4 if word(pos) == 0 else 0
|
|
pos += lead
|
|
for e in range(n):
|
|
w = word(pos)
|
|
if not (lo_code <= w < hi_code):
|
|
sys.exit("jtbl_rodata_pads --derive %s: C table entry %d at 0x%X is %08X, not a code "
|
|
"address — island layout drift" % (binary, e, pos, w))
|
|
pos += 4
|
|
nxt = next((anchor_start(j) for j in items[idx + 1:] if anchor_start(j) is not None), None)
|
|
if piece and piece[1] is not None and (nxt is None or piece[1] < nxt):
|
|
nxt = piece[1]
|
|
trailing = 0
|
|
while (nxt is None or pos < nxt) and pos + 4 <= hi_code and word(pos) == 0:
|
|
trailing += 1; pos += 4
|
|
spec.append((lead, trailing))
|
|
sys.stderr.write("jtbl_rodata_pads --derive %s: %s\n" % (binary, ",".join(
|
|
"%d%s" % (l, ("t%d" % t) if t else "") for l, t in spec) or "(no C jump tables)"))
|
|
return spec
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--derive", metavar="BINARY",
|
|
help="MODULE path: derive the pads from the retail island + this stream (no stored spec)")
|
|
ap.add_argument("--tu", help="with --derive: the TU name (its yaml .rodata piece frames an anchorless stream)")
|
|
ap.add_argument("--pads", required=False,
|
|
help="comma list, one token per rodata jump table in emission order: the pad "
|
|
"BYTES before the table (0|4), optionally t<n> = n trailing zero words")
|
|
a = ap.parse_args()
|
|
if a.derive:
|
|
lines = sys.stdin.readlines()
|
|
run(derive(a.derive, lines, a.tu), lines, sys.stdout)
|
|
return
|
|
if a.pads is None:
|
|
sys.exit("jtbl_rodata_pads: give --pads SPEC or --derive BINARY")
|
|
run(parse_spec(a.pads), sys.stdin, sys.stdout)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|