Files
ppsspp/docs/reverse-engineering.md
T
Henrik RydgårdandClaude Opus 5 fa577eaa83 Document --re-module in AGENTS.md and docs/reverse-engineering.md
A tool nobody knows about is a tool nobody uses. AGENTS.md gets a short section
pointing at it, plus the two things most likely to be got wrong when reading
the output: that a function's arity can't be inferred from the registers it
reads, since MIPS code passes arguments through untouched, and that a finding
is worth much more when the comment says which module it came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:32:23 -06:00

4.2 KiB

Reverse-engineering a PSP firmware module

PPSSPPHeadless --re-module loads a single PRX on its own - no game, no boot - and writes a report about it: the module header, its exports and imports with NIDs resolved to names, one annotated disassembly file per function, and the call graph.

This is a developer tool for understanding the PSP, not something a user ever runs.

Running it

./build/PPSSPPHeadless --memstick ~/.config/ppsspp \
    --re-module flash0:/kd/libmp3.prx \
    --re-out /tmp/re

--re-module takes either a host path or a PSP-style flash0:/kd/foo.prx, which is resolved against the configured NAND directory - so --memstick (or --nand) has to point at a dump. PPSSPP can produce one itself from a firmware updater with --unpack-updater; see PsarFileFormat.md.

Option
--re-module PATH The module to load. Host path, or flash0:/kd/foo.prx.
--re-out DIR Where the report goes. Created if missing; defaults to re-out.
--re-func NAME Only disassemble this one function, by name or 0x08801234.
--re-syms FILE A .ppsym file of names to apply first, so the disassembly reads properly.

What comes out

  • <module>.index.md - header, segments, export table (library, NID, address, name), import table, and every function with its size, caller/callee counts and whether it writes v0.
  • <module>/<addr>_<name>.asm - one file per function.
  • <module>.xref.json - the call graph, for asking "who calls this" without grepping.

Two things in the disassembly are worth knowing about:

lui/addiu pairs are folded and reported as the address they form, with a symbol name where one is known (; = 08004a10 <sampleRateTable>). Every global and constant table is reached through such a pair, so this is usually how you find the data a function works on.

Each function gets a register evidence block rather than a guessed signature. It reports, for a0-a3, whether each was read before being written, written before being read, never touched, or

  • the interesting case - never read but still live across a call:
;   a0   READ before written  -> used as a parameter here
;   a1   never read, but live across a call -> FORWARDED from our caller

That last one matters because MIPS code routinely takes an argument it never touches and leaves it in place for a callee to pick up. A tool that inferred "this function takes one argument" from the reads alone would be wrong, and so would you. Treat FORWARDED as evidence that the real arity is larger than what is read here, and settle it by looking at what the callees do with the register.

Naming things

The loader's scan names every function it finds z_un_<address>, which makes for unreadable disassembly. The tool names what it can automatically - exported functions get their name from the NID via PPSSPP's own HLE tables, and every import stub is named after the function it resolves to - but the rest is up to you.

Accumulated names go in a .ppsym file, the same module-relative format the emulator saves from hle.module.saveSymbols and the ImDebugger, and are applied with --re-syms. Names compound: once a function is named, every call site that reaches it reads as that name, so later functions get progressively cheaper to work out.

Those files are keyed by module name and crc32 (PSP/SYSTEM/SYMBOLS/<name>_<crc>.ppsym), so a set of names only ever attaches to the exact build of the module it was written against. The report's header line prints the crc to match.

Notes and caveats

  • Modules load at a fixed base (0x08000000 for kernel modules), and the index prints a +offset column, so addresses can be compared against another tool's view of the same module.
  • Some modules - sysmem.prx and loadcore.prx among them - have modinfo pointers that are file offsets rather than addresses, so the loader's own function scan finds nothing in them. The tool falls back to scanning the module's text range directly. Those modules are hand-written assembly without standard prologues, so expect few, large "functions".
  • Nothing is executed. The tool brings up the memory map, timing, the HLE tables and the kernel allocators, then runs the real module loader and stops.