At a glance

Where
docker-compose.yml service ghidra; scripts in ghidra/scripts/; dumps in ghidra/scripts/funcs/<addr>.txt (gitignored - Sony-derived)
Image
blacktop/ghidra:latest (Ghidra 12.x, OpenJDK 21) wrapped by docker/ghidra.Dockerfile so output files come back owned by you
Input
extracted/SCUS_942.54 and overlay images - run extraction first; the container mounts extracted/ read-only
Scripts
Jython 2.7, ASCII-only, each opening with # @runtime Jython and # @category Legaia
Limit
Most game logic lives in RAM overlays, not the executable - a function with zero static callers is not dead. See overlay capture and the static overlay pipeline
Used by
port catalog, function directory, PCSX-Redux probes (symbol names)

What this solves

A PlayStation executable is a flat block of MIPS machine code with no symbols. Ghidra finds the function boundaries, recovers cross-references and renders each routine as C. The project keeps Ghidra's database local and commits only what is derived from it: format specs, function tables, and the port itself.

SCUS_942.54in extracted/ importbase 0x8000F800 analyzefunctions + xrefs -postScriptone query funcs/<addr>.txtasm + C read-only mount once per program minutes, once one exec per question cited by docs + PORT tags
Bring the service up once; every later question is one docker compose exec.

Quick start

docker compose up -d ghidra
docker compose exec ghidra /ghidra/support/analyzeHeadless /projects legaia -process SCUS_942.54 -noanalysis -postScript /scripts/dump_funcs.py

Leave the container running and issue one exec per query. -noanalysis skips re-analysis on an already-analysed program; the script's output lands under ghidra/scripts/ on the host.

MountModeHolds
./extracted/dataread-onlyDisc-extracted files
./ghidra/projects/projectsread-writeThe Ghidra project database (gitignored)
./ghidra/scripts/scriptsread-writeAnalysis scripts and the per-function dumps they write

Importing the executable

A PSX-EXE is a 0x800-byte header followed by the text section, which the header places at 0x80010000. Basing the whole file at 0x8000F800 reproduces that mapping with no extra loader option: the header occupies the first 0x800 bytes and the first text byte lands on 0x80010000.

docker compose exec ghidra /ghidra/support/analyzeHeadless /projects legaia -import /data/SCUS_942.54 -loader BinaryLoader -loader-baseAddr 0x8000F800 -processor MIPS:LE:32:default
docker compose exec ghidra /ghidra/support/analyzeHeadless /projects legaia -process SCUS_942.54
OptionValueWhy
-loader-baseAddr0x8000F800File offset = 0x800 + va - 0x80010000, the mapping every citation assumes
-processorMIPS:LE:32:defaultGhidra rejects R3000; the R3000A is a strict subset of MIPS-I
second command(no flags)Runs auto-analysis: functions, references, decompilation. A few minutes, once
A base of 0x80010000 shifts every address by 0x800

It reads as the natural choice - it is the text address - but it loads the header there too. Every printed entry point comes out 0x800 high while the instruction text stays plausible. Check before dumping: the TMD renderer at 0x8002735C must open on addiu sp,sp,-0x158. The whole defect class is on dump-corpus-integrity.

The LUI+ADDIU gap

MIPS builds a 32-bit constant from two 16-bit halves, and Ghidra's reference manager does not join them across instructions. A direct "who references 0x801C70F0" query returns nothing even when the address is used everywhere. Every "find writers" script on this page exists to close that gap by tracking per-register lui immediates itself.

lui   v0, 0x8008
lw    v1, -0x488c(v1)     ; index counter
addiu v0, v0, -0x3fe8     ; v0 = 0x8007C018  <- the materializer site
sll   v1, v1, 0x2
addu  v1, v1, v0          ; mixes a loaded value in: the tracker bails here
sw    a0, 0(v1)           ; the store Ghidra never attributes
QuestionScriptNotes
What writes / reads this global?find_lui_writers.pyEdit LO / HI to the target range
Nothing writes it, but something mustfind_addr_materializers.py 0x8007C018 ...Reports each addiu that lands on the target plus the next six instructions, so the role (store base, load base, argument) is visible. Addresses via args or -e GHIDRA_FIND_ADDRS
Who calls this function?find_callers_of.py, find_addr_data.pyDirect jals, then the address as data (function-pointer tables). Zero hits in both bounds where the caller can live; it does not prove dead code
Does anything reference this address?address reference scanHost-side, over SCUS + every overlay image + raw PROT, in all five reference forms
What format is this PROT entry?asset categorize, asset stream, lzs-decode rawTry the parsers first; reverse the loading call site only if none matches

Runtime-computed indexing (lw r4, 0x18(r3) where r3 came from a table) stays invisible to any static scan; that is the point at which a live watchpoint takes over.

Adding a function dump

1Add the entry pointAppend the address to TARGETS in dump_funcs.py - SCUS-resident addresses only. Overlay addresses (>= 0x801C0000) go in a per-overlay dump_<label>_overlay.py, whose output is prefixed overlay_<label>_ so aliased VAs cannot collide.ghidra/scripts 2Dump just that onedocker compose exec -e DUMP_ONLY=8002735c ghidra ... -postScript /scripts/dump_funcs.py. Without DUMP_ONLY the whole catalogue re-dumps. The variable is read inside the container, so it must travel via exec -e.dump_funcs.py 3Read the disassemblyOpen funcs/<addr>.txt. Port and document from the instruction stream; the C is a hint about where to look (see artifacts).funcs/ 4Record itNotable entry points join the function directory; a port carries a // PORT: FUN_<addr> tag the catalog reads.docs · crates

Script catalogue

All Jython, run inside the container with -postScript; edit the constants at the top of a script to aim it. Without the # @runtime Jython header the analyzer routes a .py to the PyGhidra provider the image does not enable.

FamilyScriptPurpose
Symbolsapply_known_symbols.pyRe-apply the project's pinned function names and one-line comments to a fresh import (SCUS range only)
dump_globals.pyAuthoritative DAT_ names + lengths for the probe symbol resolver
Dumpsdump_funcs.pyDisassembly + C for a TARGETS list of SCUS entry points
dump_pending_helpers.pyThe overlay-aware pattern: one target list, in_program() skips absent addresses, output prefixed per program
dump_scus_gaps.pyDumps every function inside address RANGES from the disc-coverage gap worklist; FORCE_RANGES force-disassembles uncovered runs
repair_truncated_dumps.pyRebuilds dumps whose body ends before the routine does (tracks the highest forward branch target, so an early jr ra is not the end)
force_disasm_dump.pyCreate + dump functions Ghidra missed (JALR-only entries); targets from force_disasm_targets.txt
dump_<subsystem>_*.pyPer-cluster dumpers (battle render tail, effect overlay 0967, field locomotion, arts input, 4C jump tables, menu inventory refs, terrain trigger, field loader trace)
Address resolutionfind_lui_writers.pyGeneric LUI+ADDIU resolver over a [LO, HI] range
find_addr_materializers.pyPer-address materializer sites with use-classification context
find_addr_data.py, find_data_word.pyThe address as a 4-byte word in memory - function-pointer tables, with surrounding dwords
find_terrain_emitter_caller.pyReference manager + LUI/ORI + jal/j sweep for a target set across every imported overlay
find_string_xrefs.pyDev-path string literals to RAM addresses and every code site referencing them
Callersfind_callers_of.pyDirect callers of TARGETS_HEX
dispatcher_callers.pyCallers of the asset dispatcher and the LZS decoder
find_jalr_handlers.pyDispatch-table indirect calls (lw R, +0x10(...) then jalr R)
Overlaysfind_overlay_candidates.pyHost-side: ranks PROT entries by MIPS-code likelihood
import_overlay.sh, dump_overlay.luaImport a captured RAM window as raw binary; the PCSX-Redux dumper that captures it
find_overlay_calls.py, find_overlay_asset_loads.pyEvery call into the overlay region; every jal to a known asset loader with its constant PROT index
inventory_overlay.py, list_overlay_functions.pyPer-program function inventory CSV; size-sorted listing
list_programs.py, report_program_bases.pyWhat is imported, and where each program thinks it lives - the fact that decides whether its dumps are usable
Game modesfind_game_mode_*.py, find_gp_init_and_mode_table.py, find_per_mode_callers.pyThe game-mode dispatcher, its writers, the 28-entry mode table at 0x8007078C and its handlers' callers
Whole-programexplore.pyJSON report: LZSS-fingerprint score per function, every string and its inbound xrefs
Subsystem-targeted scanners (one global or buffer each)
ScriptTargets
find_sound_path_builders.pySound-driver string cluster 0x8007B380..0x8007B3D0
find_debug_flag_writers.pyDebug-flag band 0x8007B400..0x8007BCFF, writers and readers
find_move_table_consumers.pyMOVE / MOVE2 buffers 0x8007B888 / 0x8007B840
find_anm_buffer_users.py, find_mes_buffer_users.pyANM pointer _DAT_8007b7c8; MES pointer _DAT_8007b8a8
find_tmd_renderer.py, find_gte_users.pyTMD pointer table 0x8007C018; COP2 instruction counts per function
find_streaming_consumers.py, find_prot_consumers.py, asset_table_xrefs.pyDATA_FIELD buffer trail; constant PROT indices into the LBA resolver; the in-RAM TOC 0x801C70F0
find_scene_name_writers.py, find_field_loader_callers.pyScene-name buffer 0x80084548; field loaders FUN_8001f7c0 / FUN_800255b8
find_effect_bundle_consumers.pyEffect-bundle init / spawn / walker on an imported battle overlay
find_mesh_chain_writer.pyWriter of the actor mesh-chain pointer actor+0x44 (FUN_80024d78 from DAT_8007C018)
find_xp_table_readers.py, find_xp_table_all_overlays.pySuperseded - the XP curve is DAT_80076AF4, read by FUN_801E9504
fix_field_locomotion_flow.pyDatabase repair: re-create functions split by a non-returning-call data hole

Host-side companions under scripts/: ghidra-analysis/call-graph.py (callees / callers / xref over the dumps), ghidra-analysis/bulk-import-overlays.sh, ghidra-analysis/extract-mednafen-overlay.py, ghidra-analysis/analyze-overlay.sh, ci/function-coverage.py.

Known dev paths in the binary

The executable carries leftover Windows paths from the developers' build machine (h:\PROT\FIELD\, data\field\player.lzs, h:\prot\battle\etim.dat, \move.mdt, ...). Retail never opens them - there is no h: drive on a PlayStation - but each one names where a subsystem's data lives inside PROT, which makes them a good first guess at a format family. find_string_xrefs.py resolves them to the code that references them.

Decompiler artifacts that produce false claims

Ghidra's C is a rendering of the instruction stream. Each row below has already put a wrong statement into this project's docs. Port and document from the disassembly; treat the C as a pointer to where to look.

ArtifactLooks likeWhat settles it
Dropped register argumentsf(1) where the callee reads three arguments - the signature is inferred from one call siteCheck whether a1/a2 are written before the jal
|| as nested ifsTwo siblings sharing one branch pair look like different operatorsCompare the branch pairs; De Morgan makes them the same predicate
Reordered or dropped stores"Only three of four slots are written"Take store order and count from the instructions
Hand-written annotationsA label such as path_opener read as fact; it is someone's earlier guessRead the body - that one is a dev-station host trap, not a filesystem
0 instructions dumpsC with an empty disassembly section - nothing to cross-checkDisassemble from the extracted image directly
Dump-sweep negatives"No dump references X" - but a sixth of dumps hold no instructionsSweep bytes (capstone over the images), and state coverage
Mis-based dumpsEvery address off by a constant (often exactly 0xE818) with plausible textVerify against the extracted image; filename prefixes prove nothing
unaff_* / in_stack_*Read as proof of a mid-function fragmentA fragment needs a missing addiu sp,sp,-N plus unsaved callee-saved reads
Absolute-only sweeps"No static writers" from a scan of the lui+offset form onlyThe gp-relative form carries a different immediate; 0x8007B8C2's writer is sh v0,0x5aa(gp)
Label-callsiVar = FUN_801xxxxx(); return iVar; inside a big dispatcherIf the address is a j target in the same function, it is a label - catalogued on script VM
Transcription hazard: lui plus a negative displacement

A lui a0,0x8008 several instructions above sw v0,-0x454c(a0) writes 0x8007BAB4, not a 0x8008xxxx address. Transcribing the halves separately lands the name a whole 64 KiB page high, and the wrong name is plausible because 0x8008xxxx is real RAM. Resolve the pair - (lui_imm << 16) + sign_extend(disp) - before naming a global; suspect any 0x8008xxxx global whose cited site uses a negative displacement.

Two rules for any negative result
  • A negative needs a positive control. Run the same scanner and validator over a corpus where the thing is known present and show it finds exactly the known instances. The "ME" archive sweep on battle-data pack is the worked example: 0 validating hits in the player files is licensed by 8 of 151 raw hits validating in readef.DAT, one per documented slot.
  • Structural fit is not validation. Sizes that fit and counts that look plausible survive a shallow check. Byte 0x48 of the monster record agrees with the steal item for 31 of 185 ids - because it is drop_item and both draw from the same 39-item pool. A rate above chance is a prompt to explain the mechanism, not a hit.

How we know

ItemAddress / sourceWhat it proves
Import base checkFUN_8002735C opens on addiu sp,sp,-0x158The whole-file base 0x8000F800 puts text at 0x80010000
Missed writer exampleFUN_80026B4C storing into DAT_8007C018 via adduWhy the reference manager needs the materializer scripts
gp-relative blind spot0x80015F08 sh v0,0x5aa(gp) writes 0x8007B8C2Absolute-only sweeps miss real writers
Dev host trapFUN_8003E6BC: strcpy then break 0x103A plausible label can hide an unservable dev-station call
Field loader dual modeFUN_8001f7c0, PROT index at 0x80084540, resolver FUN_8003e8a8Retail resolves scene files by PROT index; the fopen path is dev-only (trace_field_loader.py)

See also