12 KiB
Function discovery — psxrecomp-analyze
A developer-facing static analysis tool for PS-X EXE images. It exists because the recompiler already derives everything a modder or decompiler wants — function boundaries, the call graph, jump tables, argument usage — and then throws it away after emitting C. This turns that knowledge into an artifact.
It is not part of the recompilation path and does not link runtime/.
recompiler/include/analysis_db.h schema + the two rules
recompiler/src/analysis_db.cpp the analysis passes
recompiler/src/analysis_export.cpp JSON / symbols.toml / Ghidra / TSV writers
recompiler/src/bios_call_names.cpp A0/B0/C0 kernel call names
recompiler/src/main_analyze.cpp the CLI
The two rules
1. Static only. No trace import, no overlay capture set, no executed-PC feedback, no runtime translation unit in the link. Every function, edge, and jump table reported was proven from the executable image alone. What static analysis cannot reach is printed as a gap:
Static coverage gap (NOT filled from any runtime source):
132 indirect transfer(s) have no proven target set.
255504 bytes (40.1%) of the image belong to no code function.
The guarantee is enforced structurally: recompiler/CMakeLists.txt lists the
tool's sources explicitly and none of them come from runtime/.
2. Tolerant, not fabricating. CLAUDE.md §0 says an unsupported opcode is a
hard failure — correct for the recompiler, wrong for a tool someone points at a
weird region. The analyzer degrades instead: a region it cannot fully decode is
marked partial with the offending PCs listed. That is not a stub. It emits no
code and asserts no semantics it did not prove. Reporting incomplete is
allowed here; reporting fabricated is not.
Usage
psxrecomp-analyze <EXE> [options]
# typical: analyse, write the JSON bundle, keep a diffable TSV
psxrecomp-analyze disc/SLUS_005.62 --symbols symbols.toml \
--out analysis --emit-tsv analysis/functions.tsv
Key options: --exact (reachability-only partition), --seeds <file>,
--emit-symbols (merge into symbols.toml), --emit-ghidra,
--emit-symbol-addrs, --disasm <file> --at <addr>, --diff <prev.tsv>.
Artifacts
| File | Contents |
|---|---|
analysis.json |
image metadata, stats, one record per function |
edges.json |
call edges: direct, jump-table, address-taken, resolved indirect |
indirect.json |
every indirect transfer, resolved or not, with context |
refs.json |
statically resolved memory references (largest; --no-refs skips) |
functions.tsv |
one line per function — grep/awk friendly, and the --diff input |
symbol_addrs.txt |
decomp-toolchain symbol map |
psxrecomp_import.py |
Ghidra script: creates functions, applies names, bookmarks unresolved sites |
The JSON files are the stable public interface for future front ends or project-level CLI wrappers. Consumers should read those artifacts, never the analyzer's in-process types.
What it recovers
Function boundaries come from FunctionAnalyzer (the same code the emitters
use), then get scored:
| Confidence | Meaning |
|---|---|
verified |
prologue + epilogue + jr $ra, fully decoded, called from reachable code |
high |
jr $ra exit, reachable from the entry point |
medium |
prologue-shaped, or address-taken with no proven call path |
low |
boundary inferred; unreachable, no prologue, or partial decode |
data |
classified as data masquerading as code |
Every row also carries confidence_reason, so a claim can be argued with.
Jump tables. resolve_exact_bounded_jump_table() in function_analysis.cpp
is tried first and is authoritative when it fires, but it accepts exactly one
instruction schedule because a wrong table there emits wrong code. That is the
wrong trade for a discovery tool: on Masters of Teras Kasi it resolved 0 of
210 indirect sites, because the common schedule puts the sll in the branch's
delay slot and materializes the table base between the sll and the addu.
recover_jump_table() walks the actual backward slice instead of matching a
fixed window, and labels how it terminated:
jump_table:proven— the strict resolver accepted it.jump_table:guarded— ansltiubound on the index register was found, so the case count is proven.jump_table:scanned— no bound found; the table was walked until a word stopped looking like a code address. A hypothesis, and labelled as one.
Indirect calls through a statically-known pointer — materialized inline, or
loaded from a fixed address holding a known function start — are resolved as
call_ptr:immediate / call_ptr:static_slot. Pointers from struct fields or
computed addresses stay unresolved, correctly.
Kernel dispatch thunks. PSY-Q titles call the BIOS through three-instruction
stubs (addiu $t2,$zero,0xA0; jr $t2; addiu $t1,$zero,<idx>). The generic rules
score these low — no prologue, no jr $ra — yet they are among the most-called
functions in any image. They are detected by exact pattern and named from the
A0/B0/C0 tables (bios_call_names.cpp, sourced from psx-spx), so
func_800749DC with 41 callers reads as bios_A0_3F_printf instead.
Signatures are inferred by a read-before-write scan: arguments in $a0–$a3,
return in $v0/$v1, callee-saved set from the prologue's stores, leaf/non-leaf,
GTE/MMIO/syscall use, frame size. This is a heuristic — a loop that reads a
register before the backward edge writes it over-reports an argument — so each
record carries sig.confident, false whenever the scan hit such a construct.
Measured on three titles
| Title | Functions | Tables recovered | Indirect left | Coverage |
|---|---|---|---|---|
Masters of Teras Kasi (SLUS_005.62) |
1331 | 46 (804 targets) | 132 | 59.9% |
Twisted Metal 4 (SCUS_945.60) |
1099 | 11 (236 targets) | 185 | 76.1% |
Klonoa (SLUS_005.85) |
230 | 2 (126 targets) | 74 | 71.5% |
Klonoa's boot EXE is 45 KB because the game streams in as overlays — a good illustration of the limit below.
Where static-only costs coverage
Two classes remain, and the tool says so rather than papering over them:
jalrthrough a runtime pointer — a vtable slot or callback filled in at init. No static answer exists in general.- Overlay bodies — for an overlay-heavy title, most of the code is not in
the boot EXE at all. Closing this statically means parsing the disc
filesystem, locating overlay blobs as files, and recovering load bases by
analysing the loader's own
CdRead/memcpycalls. Tractable, not done here.
tools/overlay_xref.py mines the runtime's capture set for exactly this, and it
works — but it is dynamic evidence. The rule this tool follows is not "runtime
never touched"; it is you always know which claims are static. If capture
import is ever added here, it must arrive as a separately-labelled source, never
silently merged.
Naming loop
symbols.toml (see SYMBOLS.md) is the persistence layer. Both
writers are append/edit-only and never regenerate the file: it holds more
than functions — Ape Escape has 11 [[object]] entries and a top-level game
key, Crash Team Racing has 33 [[site]] entries, all hand-researched — and an
earlier version that rebuilt the file from parsed [[func]] entries silently
deleted every one of them.
psxrecomp-analyze <EXE> --symbols symbols.toml --emit-symbols \
--min-confidence verified
New functions land as status = "guessed" with a note recording why the
analyzer trusted them. A name a human typed always outranks anything inferred,
and an entry this run did not rediscover is carried through rather than dropped.
Relationship to the emitters
psxrecomp-game answers "what C do I emit for this?" and must be conservative,
because a wrong answer is a broken build. psxrecomp-analyze answers "what is
this?" and can afford a labelled hypothesis, because a wrong answer is a wrong
report that a human will notice. They share the boundary detection and the
strict table resolver; they differ in what they are allowed to guess, and the
labels exist so a consumer can tell which is which.
Widescreen site scanning (--scan-widescreen)
Widescreen bring-up on a new title means finding a handful of exact instruction
addresses for [widescreen.cull]. Tomba's game.toml carries four bias/range
pairs and three a1 nops, each located by hand from disassembly or by mining
the runtime's capture set with tools/overlay_xref.py. Every one of them has a
crisp syntactic signature, so for a main-EXE title that search is static.
psxrecomp-analyze <EXE> --scan-widescreen --emit-ws-sites ws.toml
What it finds
The per-game screen extents. These are not fixed — Tomba tests 0x140/0x141
on a 320 display, Ape Escape 0x181 on 368, Wipeout 3 0x200/0x240 on 512.
Frequency alone picks the wrong values (Wipeout 3's real immediates appear in
only three funnels, drowned by ordinary bounds checks in 83 GTE functions), so
the scan scores co-occurrence: the screen-extent signature is a width
compare and a height compare in the same function. Pairs are weighted toward
GTE code and toward the console's actual display modes, then completed with the
inclusive-bound sibling when the image contains a compare against it. That last
step is load-bearing — Tomba's pairing keys on sltiu_imm - 2*bias == W, and
449 - 128 is 0x141, so without it three of its four pairs vanish.
When neither chosen set lands on a real console mode, the report and the emitted TOML say so rather than presenting a guess with a confident face.
auto_screen_x coverage. Functions carrying the screen-extent signature are
listed using ws_cull_detect.h — the same detector the recompiler and the
runtime interpreter use, so the verdict cannot drift. Knowing that 22 of Tomba's
functions are already handled without any per-address list is the most useful
thing to learn before hand-listing anything.
bias/range pairs, the masked-u16 X window:
80022E78 addiu $v0, $v0, 64 <- bias_sites (+halfwidth)
80022E7C andi $v0, $v0, 0xFFFF
80022E80 sltiu $v0, $v0, 449 <- range_sites (W + 2*halfwidth)
80022E84 beq $v0, $zero, reject
The arithmetic tie between the two immediates is what makes this safe to
propose. Without it, any neighbouring addiu/sltiu would qualify.
a1 margin sites — a repurposable nop before a caller-supplied margin
joins an X term. These classifiers are callees of the render funnel, not
funnels themselves (Tomba's uses no GTE op and has no static caller at all), so
the signature is local: the function takes $a1, folds it in with addu, and
has a nop just before that is not already a branch delay slot.
screen_x sites — width compares in functions auto_screen_x cannot reach,
which are exactly the addresses that still need listing by hand.
Measured against hand-found ground truth
Tomba's game.toml sites were found by hand over the course of the widescreen
work. Scanning SCUS_942.36 with everything auto-discovered:
| key | recall | candidates proposed |
|---|---|---|
bias_sites |
4/4 | 4 (no false positives) |
range_sites |
4/4 | 4 (no false positives) |
a1_sites |
3/3 | 17 |
Independently, the scan recovers Ape Escape's documented 0x181 and both of
Wipeout 3's configured 0x200/0x240.
Safety
Every proposal already satisfies the instruction form code_generator.cpp
requires — addi/addiu for bias, sltiu for range and screen_x, a bare nop
for a1 — because the emitter hard-errors on a mismatch in main-EXE mode. A
suggestion that would break the build is not a suggestion. What the scanner
cannot tell you is whether widening a given site helps; that is still
playtesting.