Ghidra in Docker
Ghidra is the static-analysis side of the project: it disassembles the game's executable and produces the per-function dumps every other page cites. It runs headlessly in a container and is driven by scripts, not a GUI - you ask a question, a Jython script answers it into a file on the host. Reach for it to learn what a function is: its instructions, its decompiled C, who calls it.
At a glance
- Where
docker-compose.ymlserviceghidra; scripts inghidra/scripts/; dumps inghidra/scripts/funcs/<addr>.txt(gitignored - Sony-derived)- Image
blacktop/ghidra:latest(Ghidra 12.x, OpenJDK 21) wrapped bydocker/ghidra.Dockerfileso output files come back owned by you- Input
extracted/SCUS_942.54and overlay images - run extraction first; the container mountsextracted/read-only- Scripts
- Jython 2.7, ASCII-only, each opening with
# @runtime Jythonand# @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.
docker compose exec.Quick start
docker compose up -d ghidradocker compose exec ghidra /ghidra/support/analyzeHeadless /projects legaia -process SCUS_942.54 -noanalysis -postScript /scripts/dump_funcs.pyLeave 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.
| Mount | Mode | Holds |
|---|---|---|
./extracted → /data | read-only | Disc-extracted files |
./ghidra/projects → /projects | read-write | The Ghidra project database (gitignored) |
./ghidra/scripts → /scripts | read-write | Analysis 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:defaultdocker compose exec ghidra /ghidra/support/analyzeHeadless /projects legaia -process SCUS_942.54| Option | Value | Why |
|---|---|---|
-loader-baseAddr | 0x8000F800 | File offset = 0x800 + va - 0x80010000, the mapping every citation assumes |
-processor | MIPS:LE:32:default | Ghidra 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 |
0x80010000 shifts every address by 0x800It 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
| Question | Script | Notes |
|---|---|---|
| What writes / reads this global? | find_lui_writers.py | Edit LO / HI to the target range |
| Nothing writes it, but something must | find_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.py | Direct 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 scan | Host-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 raw | Try 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
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.
| Family | Script | Purpose |
|---|---|---|
| Symbols | apply_known_symbols.py | Re-apply the project's pinned function names and one-line comments to a fresh import (SCUS range only) |
dump_globals.py | Authoritative DAT_ names + lengths for the probe symbol resolver | |
| Dumps | dump_funcs.py | Disassembly + C for a TARGETS list of SCUS entry points |
dump_pending_helpers.py | The overlay-aware pattern: one target list, in_program() skips absent addresses, output prefixed per program | |
dump_scus_gaps.py | Dumps every function inside address RANGES from the disc-coverage gap worklist; FORCE_RANGES force-disassembles uncovered runs | |
repair_truncated_dumps.py | Rebuilds 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.py | Create + dump functions Ghidra missed (JALR-only entries); targets from force_disasm_targets.txt | |
dump_<subsystem>_*.py | Per-cluster dumpers (battle render tail, effect overlay 0967, field locomotion, arts input, 4C jump tables, menu inventory refs, terrain trigger, field loader trace) | |
| Address resolution | find_lui_writers.py | Generic LUI+ADDIU resolver over a [LO, HI] range |
find_addr_materializers.py | Per-address materializer sites with use-classification context | |
find_addr_data.py, find_data_word.py | The address as a 4-byte word in memory - function-pointer tables, with surrounding dwords | |
find_terrain_emitter_caller.py | Reference manager + LUI/ORI + jal/j sweep for a target set across every imported overlay | |
find_string_xrefs.py | Dev-path string literals to RAM addresses and every code site referencing them | |
| Callers | find_callers_of.py | Direct callers of TARGETS_HEX |
dispatcher_callers.py | Callers of the asset dispatcher and the LZS decoder | |
find_jalr_handlers.py | Dispatch-table indirect calls (lw R, +0x10(...) then jalr R) | |
| Overlays | find_overlay_candidates.py | Host-side: ranks PROT entries by MIPS-code likelihood |
import_overlay.sh, dump_overlay.lua | Import a captured RAM window as raw binary; the PCSX-Redux dumper that captures it | |
find_overlay_calls.py, find_overlay_asset_loads.py | Every call into the overlay region; every jal to a known asset loader with its constant PROT index | |
inventory_overlay.py, list_overlay_functions.py | Per-program function inventory CSV; size-sorted listing | |
list_programs.py, report_program_bases.py | What is imported, and where each program thinks it lives - the fact that decides whether its dumps are usable | |
| Game modes | find_game_mode_*.py, find_gp_init_and_mode_table.py, find_per_mode_callers.py | The game-mode dispatcher, its writers, the 28-entry mode table at 0x8007078C and its handlers' callers |
| Whole-program | explore.py | JSON report: LZSS-fingerprint score per function, every string and its inbound xrefs |
Subsystem-targeted scanners (one global or buffer each)
| Script | Targets |
|---|---|
find_sound_path_builders.py | Sound-driver string cluster 0x8007B380..0x8007B3D0 |
find_debug_flag_writers.py | Debug-flag band 0x8007B400..0x8007BCFF, writers and readers |
find_move_table_consumers.py | MOVE / MOVE2 buffers 0x8007B888 / 0x8007B840 |
find_anm_buffer_users.py, find_mes_buffer_users.py | ANM pointer _DAT_8007b7c8; MES pointer _DAT_8007b8a8 |
find_tmd_renderer.py, find_gte_users.py | TMD pointer table 0x8007C018; COP2 instruction counts per function |
find_streaming_consumers.py, find_prot_consumers.py, asset_table_xrefs.py | DATA_FIELD buffer trail; constant PROT indices into the LBA resolver; the in-RAM TOC 0x801C70F0 |
find_scene_name_writers.py, find_field_loader_callers.py | Scene-name buffer 0x80084548; field loaders FUN_8001f7c0 / FUN_800255b8 |
find_effect_bundle_consumers.py | Effect-bundle init / spawn / walker on an imported battle overlay |
find_mesh_chain_writer.py | Writer of the actor mesh-chain pointer actor+0x44 (FUN_80024d78 from DAT_8007C018) |
find_xp_table_readers.py, find_xp_table_all_overlays.py | Superseded - the XP curve is DAT_80076AF4, read by FUN_801E9504 |
fix_field_locomotion_flow.py | Database 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.
| Artifact | Looks like | What settles it |
|---|---|---|
| Dropped register arguments | f(1) where the callee reads three arguments - the signature is inferred from one call site | Check whether a1/a2 are written before the jal |
|| as nested ifs | Two siblings sharing one branch pair look like different operators | Compare 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 annotations | A label such as path_opener read as fact; it is someone's earlier guess | Read the body - that one is a dev-station host trap, not a filesystem |
0 instructions dumps | C with an empty disassembly section - nothing to cross-check | Disassemble from the extracted image directly |
| Dump-sweep negatives | "No dump references X" - but a sixth of dumps hold no instructions | Sweep bytes (capstone over the images), and state coverage |
| Mis-based dumps | Every address off by a constant (often exactly 0xE818) with plausible text | Verify against the extracted image; filename prefixes prove nothing |
unaff_* / in_stack_* | Read as proof of a mid-function fragment | A 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 only | The gp-relative form carries a different immediate; 0x8007B8C2's writer is sh v0,0x5aa(gp) |
| Label-calls | iVar = FUN_801xxxxx(); return iVar; inside a big dispatcher | If 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 inreadef.DAT, one per documented slot. - Structural fit is not validation. Sizes that fit and counts that look plausible survive a shallow check. Byte
0x48of the monster record agrees with the steal item for 31 of 185 ids - because it isdrop_itemand 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
| Item | Address / source | What it proves |
|---|---|---|
| Import base check | FUN_8002735C opens on addiu sp,sp,-0x158 | The whole-file base 0x8000F800 puts text at 0x80010000 |
| Missed writer example | FUN_80026B4C storing into DAT_8007C018 via addu | Why the reference manager needs the materializer scripts |
| gp-relative blind spot | 0x80015F08 sh v0,0x5aa(gp) writes 0x8007B8C2 | Absolute-only sweeps miss real writers |
| Dev host trap | FUN_8003E6BC: strcpy then break 0x103 | A plausible label can hide an unservable dev-station call |
| Field loader dual mode | FUN_8001f7c0, PROT index at 0x80084540, resolver FUN_8003e8a8 | Retail resolves scene files by PROT index; the fopen path is dev-only (trace_field_loader.py) |