Address-reference scan
"Who calls this?" is the question that stalls a reverse-engineering thread most often, and the usual answer — "nothing does" — is usually wrong. This sweep looks for every way the game can reach an address, across every code image on the disc at once, so that answer becomes a measurement instead of a shrug.
At a glance
- Tool
scripts/ghidra-analysis/find-address-word-refs.py- host-side, runs againstextracted/, no Ghidra project needed- Corpus
SCUS_942.54+ all 31 recovered overlay images + (with--prot) the raw bytes of all 1233 archive entries- Forms
- Literal word,
lui+addiu/oripair,jal,j, PC-relative branch - all in one pass - Per hit
- Image + file offset + a triage classification (
dispatch-table/template-field/incidental-code) - Outcomes
- A table/template consumer (real work), an intra-function label (never work), or no reference anywhere (a documented negative)
- Feeds
- The port catalog's
unreferencedignore-list category
What this solves
A call-graph tool looks for jal, the MIPS call instruction. Legaia reaches much of its code some other way, and each is invisible to that search:
- Through a table. A menu renderer, a sub-screen dispatcher, an actor template's per-frame tick - the address sits in memory as a plain 32-bit word and something loads and jumps through it. No instruction on the disc ever names it.
- Through an address built in two halves. A 32-bit address does not fit in one instruction, so the assembler emits a
lui+addiu/oripair - and Ghidra's cross-reference index does not join the pairs back together (see Ghidra in Docker). - The mirror trap: an address with no caller of any kind might not be a function at all - a label inside somebody else's function, reached by a PC-relative branch. Ghidra invents a fake
FUN_entry at each of those.
Reach for this scan whenever a port-catalog row says "no caller", before you either port the routine or declare it dead. It is the instrument behind the rule that zero static callers in SCUS_942.54 does not mean dead in retail.
--rangeinput
2Sweep five formsword / lui+addiu / jal / j / branch, over SCUS + 31 overlays (+ raw PROT)find-address-word-refs.py
3Attribute + classifyeach hit named by image; word hits triaged as table / template / incidentalstatic-overlays.toml
4Outcomea consumer to chase, a label (never work), or "no reference exists" (documented negative)port-catalog ignore list
Quick start
scripts/ghidra-analysis/find-address-word-refs.py 8005126cfind-address-word-refs.py 8005126c --prot # widen to every extracted PROT entry
find-address-word-refs.py 801e58a8 --home field # mark branch hits from slot siblings as aliases
find-address-word-refs.py --range 800705fc:80070760 # who references this table?
find-address-word-refs.py --file addrs.txt --context # batch a worklist, print neighbours
find-address-word-refs.py 8004da00 --expect-scus 800767fc # self-check with exit status
The five forms it looks for
| Form | What it is | Carries its target? |
|---|---|---|
| Literal word | The address stored as a 32-bit data word - a pointer-table entry or template field. | Yes |
lui + addiu/ori | The two-instruction pair that materialises the address into a register. | Yes (in two halves) |
jal | Ordinary call. | Yes |
j | Absolute jump (tail calls, dispatcher exits). | Yes |
| PC-relative branch | beq/bne/... - a distance, not a destination. | No - only meaningful inside its own image |
What a hit means, and where it is
Reporting a hit as a memory address requires knowing where its containing file loads, and the three cases are kept apart because conflating them has put an impossible address into this project's own notes:
| Image | Load address comes from | A hit is reported as |
|---|---|---|
SCUS_942.54 | the PS-X EXE header t_addr | file offset and the one address it can be |
| Overlay images | crates/asset/data/static-overlays.toml (see static overlay pipeline) | offset and address, named by image |
Other archive entries (--prot) | none - streamed data | file offset only |
The middle row is the one that bites: many overlays load at the same address, so the same address is a different word in each of them - the image is as much of the answer as the offset. The bottom row is why streamed entries carry no address: inventing one would be exactly the phantom-VA defect the first row exists to avoid.
Classification of a word hit
Only a word-aligned hit can be a table entry, so unaligned ones are reported as incidental. For the aligned ones, three counts over the surrounding bytes are printed alongside the verdict so it can be second-guessed:
| Count | Measures |
|---|---|
code | jr ra and addiu sp, sp, ±N words within ±0x200 - real code carries several, a pointer table none |
entry | Neighbouring words (±4) that are addresses landing on a function prologue in this same image |
ptr / const | Neighbouring words that are RAM-band addresses, or small constants |
dispatch-table needs two or more entry neighbours; template-field is a lone pointer among constants; incidental-code is a word inside an instruction stream. The verdict is triage, not a substitute for reading the disassembly at the hit.
The template-in-code false negative, and its tell
A template inside a code region reads as incidental-code: code outranks the other counts, and a static actor template is a small constant record linked among the routines it seats, not in a data region - so its +0x8 tick word scores code > 0 with entry = 0 even though a lone pointer among constants is exactly the template shape. The tell is one word back: FUN_80020DE0 reads a template's +0x4 model selector, which is 0xFFFF for the transform-node helpers, so a routine's VA immediately after an ffff0000 word is a template's tick slot. 801D2298 (0897 field, template 0x801F2294) and 801D4098 (0980 dance, template 0x801D42FC) are both real, ported entries with exactly that shape. A template-seated routine collects one aligned word hit; an interior label collects no word hit at all.
A branch cannot cross images
Four of the five forms carry a copy of the address they reach, so a hit is evidence wherever it turns up. A branch encodes a distance, not a destination, so it can only reach code in its own image - a branch hit is evidence about that image's copy of the address and nothing else. Since overlays share load addresses, every one of them has a byte at every address: two routines on this project's inert-code list read as referenced for exactly this reason and are not (0x801CFE20 collects a branch from the field overlay, whose bytes there are an unrelated epilogue; 0x801E58A8 collects one from battle_action).
--home <image> makes the check mechanical: name the image that holds the routine (locate-entry-image.py answers that from the prologue) and branch hits from any other overlay print as br~ and tally under branch_alias. When a target's only hits are aliased branches, the tool says so outright; a typo'd name exits non-zero rather than quietly marking every hit an alias.
The three outcomes
- A table or template word. The consumer is whatever dispatches through that record - the stalled thread has a next step.
- Branch sites but no call sites. A label inside a larger function, not an entry point. The worklist row was never work.
- Nothing, anywhere. The routine is real but the game never reaches it - linked in because the linker keeps whole object files. This outcome is worth naming because it looks identical to unfinished work and is the opposite of it: reimplementing a routine the original never runs cannot make anything work. Such addresses go into the port catalog's
unreferencedignore-list category as a documented negative.
The retail-unreachable set (closed under the sweep)
Sweeping every anchor the port catalog's audit lists as disclosed inert answers whether a row is waiting on wiring or on nothing. Almost all are waiting on wiring; these are the ones that are not:
| Address | Image | Routine |
|---|---|---|
8005126C | SCUS | battle actor on-screen test |
80035274 | SCUS | item / equipment passive-name draw |
80050D40 | SCUS | 12-bit angle tween |
80025054 | SCUS | actor-template tick; unreachable through its record 0x80070614 |
801CFE20 / 801CFE5C | 0970 cutscene_str | MDEC in / out sync wrappers |
801D0230 | 0970 cutscene_str | MDEC status-word leaf; both call sites are inside the two wrappers above |
801D5780 | 0897 field | generic arc-hop spawn |
801D5C2C | 0972 fishing | 3-D segment clip + projection |
801DAD6C | 0899 menu | five-step menu open sequence |
801DBA90 | 0898 battle_action | reward-banner composer |
801E5834 / 801E58A8 | 0897 field | pooled actor spawn + actor anim-clip pick |
Every row was checked with locate-entry-image.py first, so each begins where it is said to begin. The scan settles reachability, not identity: a row here still says nothing about whether the decoded behaviour is right. Per-routine write-ups live in the function directory.
Controls
A tool is only worth its negatives if its positives are checked, so it is run against known answers before a "nothing found" is believed: a known template word (8004da00 → exactly one hit at SCUS 0x800767FC, classified template-field; --expect-scus turns that into a pass/fail exit status), a known call target (800195a8 → jal sites across SCUS and five overlays, no word hits), a known branch target (80051308 → one BR site and nothing else), and a known dispatch table (--range over the menu overlay's sub-screen pointer array at 0x801E4F40 recovers its single materialisation site).
Limits
- Compressed entries.
--protscans raw bytes, so a reference inside an LZS-compressed entry is invisible. Overlay code is stored raw, so this gap does not cover the images callers actually live in. - Split materialisation. The pair scan wants one
luiand oneaddiu/oriwithin a few instructions; the sibling gp-relative sweep's register walk covers an address assembled in more steps.table_base + indexis covered to the extent the bytes allow: both scans carry theluiregister through the indexingadduand report the array's base - never the element a runtime index picks - so a per-record negative over a table still needs the table base scanned before it means anything. - How a register is followed. Both scans share one walk (
scripts/ghidra-analysis/mips_walk.py, mirrored by the byte account'slui_forms): any other write drops the register, a copy or one runtime index carries it, a branch forks it, a call keeps only callee-saved registers past its delay slot, andjr raends the path. The earlier walk stopped only at anotherlui, and about a quarter of the pairs it formed were a high half paired with a register already rewritten; no recorded "unreferenced" verdict depended on one. - A loader parameter is not an address. A
PROT.DATentry is reached by its index, not its address, and an index the code builds with arithmetic occurs nowhere as a constant. Two entries read as "loaded by nothing" for exactly that reason - their index is formed asaddiu a0,s0,0x4c7with a runtime register. Sweep the base of the arithmetic, not the value it produces. - The verdict is triage. A
dispatch-tableclassification says the neighbours look like function entries, not that the runtime indexes them. - Aliased branches. A
BRhit in an image that does not hold the routine is not a reference to it; pass--homeand readbranch_alias.
Three Ghidra-side scripts cover pieces of this from inside a loaded program (find_lui_writers.py, find_addr_data.py / find_data_word.py, find_terrain_emitter_caller.py - catalogued on Ghidra in Docker). They stay the right tool when you want the containing function of a hit, which they can name and this cannot; the host-side scan adds corpus and closure - every image at once, the raw PROT entries no Ghidra project holds, all five forms in one pass.