At a glance

Tool
scripts/ghidra-analysis/find-address-word-refs.py - host-side, runs against extracted/, 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/ori pair, 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 unreferenced ignore-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/ori pair - 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.

1One addressa stalled worklist row, or a whole table via --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 8005126c
find-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

FormWhat it isCarries its target?
Literal wordThe address stored as a 32-bit data word - a pointer-table entry or template field.Yes
lui + addiu/oriThe two-instruction pair that materialises the address into a register.Yes (in two halves)
jalOrdinary call.Yes
jAbsolute jump (tail calls, dispatcher exits).Yes
PC-relative branchbeq/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:

ImageLoad address comes fromA hit is reported as
SCUS_942.54the PS-X EXE header t_addrfile offset and the one address it can be
Overlay imagescrates/asset/data/static-overlays.toml (see static overlay pipeline)offset and address, named by image
Other archive entries (--prot)none - streamed datafile 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:

CountMeasures
codejr ra and addiu sp, sp, ±N words within ±0x200 - real code carries several, a pointer table none
entryNeighbouring words (±4) that are addresses landing on a function prologue in this same image
ptr / constNeighbouring 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 unreferenced ignore-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:

AddressImageRoutine
8005126CSCUSbattle actor on-screen test
80035274SCUSitem / equipment passive-name draw
80050D40SCUS12-bit angle tween
80025054SCUSactor-template tick; unreachable through its record 0x80070614
801CFE20 / 801CFE5C0970 cutscene_strMDEC in / out sync wrappers
801D02300970 cutscene_strMDEC status-word leaf; both call sites are inside the two wrappers above
801D57800897 fieldgeneric arc-hop spawn
801D5C2C0972 fishing3-D segment clip + projection
801DAD6C0899 menufive-step menu open sequence
801DBA900898 battle_actionreward-banner composer
801E5834 / 801E58A80897 fieldpooled 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. --prot scans 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 lui and one addiu/ori within a few instructions; the sibling gp-relative sweep's register walk covers an address assembled in more steps. table_base + index is covered to the extent the bytes allow: both scans carry the lui register through the indexing addu and 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's lui_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, and jr ra ends the path. The earlier walk stopped only at another lui, 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.DAT entry 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 as addiu a0,s0,0x4c7 with a runtime register. Sweep the base of the arithmetic, not the value it produces.
  • The verdict is triage. A dispatch-table classification says the neighbours look like function entries, not that the runtime indexes them.
  • Aliased branches. A BR hit in an image that does not hold the routine is not a reference to it; pass --home and read branch_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.

See also