Decompile with Ghidra
Skill
re-ghidra. Decompile a game's binaries with Ghidra in headless mode and work with the result as text - the full export (decomp.c, functions, strings, imports, exports), queries with decomp_query.py, one-off decompiles of functions Ghidra missed, vtable and reference listings, constants read from the binary, and checks against the disassembly. Use whenever you need pseudocode for a native binary, including 16-bit DOS (MZ) and NE programs.
Copy this prompt into your coding agent. Replace the part in angle brackets.
In my GameFrame project, use the re-ghidra skill for <the task>.
Read the skill first and follow its steps in order.
Report the evidence it asks for, with its label: observed, measured, inferred or unknown.Ghidra turns machine code into C-like pseudocode. It has no original names: a function is FUN_1400be770 (the function at address 0x1400be770), a field is *(int *)(param_1 + 0x44) (the int 0x44 bytes into the first argument). Pseudocode helps you read the code, but it can be wrong. Check anything exact against the disassembly or the emulator.
1. Export once
Import the game's own binaries together, so calls between the EXE and its DLLs resolve:
scripts/ghidra-export.sh game/Game.exe game/GameEngine.dllIf you have names for functions and globals (debug symbols, a map file, earlier labels), give them to the export: Ghidra names everything and creates the functions it missed.
python3 tools/dos/tdinfo.py game/GAME.EXE labels > analysis/td-labels.tsv # DOS: Borland symbols
scripts/ghidra-export.sh --labels analysis/td-labels.tsv game/GAME.EXE
scripts/ghidra-run.sh GAME.EXE labels analysis/notes/labels.txt labels/names.tsv # an existing projectAfter every export, measure how much code it covers. Ghidra misses functions reached only through pointers (handler tables, per-object step functions, callbacks), and the code between them:
.venv/bin/python tools/triage/exportcov.py game/GAME.EXE analysis/export/GAME.EXE/functions.tsv \
--starts analysis/notes/starts.txt
scripts/ghidra-run.sh GAME.EXE decompile analysis/notes/missed.txt $(cat analysis/notes/starts.txt)Below about 90% the export is missing whole systems. The batch decompile makes a function at each likely start, skips an address that nothing refers to unless it follows a ret or padding, and reports a bad address without stopping (docs/knowhow/decompile-missed-pointer-functions.md).
It writes, per binary, into analysis/export/<binary>/ (ignored by git):
| File | Contents |
|---|---|
decomp.c | Every function's pseudocode, each headed // ==== <address> <name> |
functions.tsv | address, size in bytes, name, thunk for thunks |
strings.txt | address and value of every defined string |
imports.txt, exports.txt | library!function; address and name of each export |
Time: about a minute for a small program, 10 to 30 minutes for a large game. Run it in the background and keep working. It needs memory: MAXMEM=16G for games over 50 MB of code.
2. Query the export
T=tools/triage/decomp_query.py; P=.venv/bin/python
$P $T stats # functions, decompiled, labelled share
$P $T fn 1400be770 # one function, with your labels applied
$P $T callers FUN_1400be770 # who calls it (or refers to it through a table)
$P $T callees 1400be770 # what it calls, labelled and largest first
$P $T find 'Saves/' # functions whose pseudocode contains a string or constant
$P $T strings savegame # strings containing a word, and the functions that use them
$P $T writers 0x44 # functions that store to +0x44 (who sets this field?)
$P $T layout 140010a0 140011f0 # one structure's fields from several functions, and a draft struct
$P $T biggest 40 # the largest unlabelled functions (where the work is)Then measure how much of the code is in functions. Ghidra's analysis misses code reached only through pointers, and Watcom code that opens with a row of register pushes. In two Watcom games a quarter to a third of the code was missing, the main loop among it:
./gameframe run exportcov GAME.EXE analysis/export/GAME.EXE/functions.tsv --starts analysis/notes/starts.txt # share in functions, gaps, likely startsThen make functions at those starts and export again, in one step:
./gameframe run ghidra-run GAME.EXE fill-gaps analysis/notes/fill.txt analysis/notes/starts.txt # report in fill.txt
./gameframe run exportcov GAME.EXE analysis/export/GAME.EXE/functions.tsv # what is leftA start that fails (data that looked like code) is reported in the report and the rest go on. Repeat until exportcov finds no more starts. Recover the gaps before tracing anything that might live in them.
Plain grep and awk on decomp.c work too: awk '/^\/\/ ==== 1400be770 /{p=1;print;next} /^\/\/ ==== /{p=0} p' analysis/export/Game.exe/decomp.c prints one function.
Every time you identify a function, add address<TAB>label<TAB>evidence to labels/names.tsv; decomp_query.py and tools/pe/vtable.py show labels everywhere from then on. Fields go in labels/fields.tsv. This shared vocabulary is what makes later sessions fast.
3. One-off work in the analysed project
Reuse the project the export made (no re-analysis, about a minute of start-up, so batch):
scripts/ghidra-run.sh Game.exe decompile analysis/notes/ai.txt 0x1400be770 0x1400be680
scripts/ghidra-run.sh Game.exe named analysis/notes/rand.txt Random Rand
scripts/ghidra-run.sh Game.exe refs analysis/notes/refs.txt 0x140178000
scripts/ghidra-run.sh Game.exe vtable analysis/notes/vt.txt Citizen 0x140165120 40
scripts/ghidra-run.sh Game.exe callargs analysis/notes/sfx.tsv PlaySound play_sfx
scripts/ghidra-run.sh boot.bin blocks analysis/notes/blocks.txt derived/loadmap.tsvdecompilealso creates functions Ghidra missed: callbacks reached only through tables of pointers are often not functions in the bulk export.namedsearches by name: best for a DLL that exports C++ names (Random,Serialize).refslists every reference to an address (a global, a table, a function) with the referring function decompiled: the fastest way to find who uses a constant or a singleton.vtablelists a table of function pointers.tools/pe/vtable.py BINARY 0xTABLE --names analysis/export/BINARY/functions.tsvdoes the same without Ghidra.tools/pe/relocs.py BINARYfinds every table of pointers in the data from the base relocations, including tables of functions that no call reaches directly.callargslists every call to the named functions with the constant, string and global arguments at each call, from P-Code, for any processor: every sound and its id, every file name, every random range in one pass. Addregs=watcom(EAX, EDX, EBX, ECX) orregs=EAX,ECXfor arguments passed in registers, which the decompiler drops under a stack convention: each call then also shows the constant loaded into each register before it, in the same block (EAX=0x4d2 (1234)), or?.blocksadds raw pieces of memory from a load map (a booter, a memory dump, ROM banks) and analyses them.
Ghidra locks a project: run one job per project at a time.
Signatures carry names without an export: tools/triage/sigscan.py make BINARY --names labels/names.tsv > SIGS, then sigscan.py scan SIGS NEW_BINARY --labels OUT.tsv.
A new build of the game? tools/triage/match_functions.py OLD_EXPORT NEW_EXPORT --labels labels/names.tsv --out labels/names-new.tsv matches the functions and carries your labels to the new addresses. docs/working/ghidra.md covers this, Version Tracking, BSim, Function ID and processors Ghidra lacks.
4. Constants and data
Pseudocode often shows a float constant as a global (DAT_14016a2b0). Read the real value:
.venv/bin/python tools/pe/readdata.py game/Game.exe f32 DAT_14016a2b0
.venv/bin/python tools/pe/readdata.py game/Game.exe f32 0x14016a2c0 --count 12 # a table
.venv/bin/python tools/pe/readdata.py game/Game.exe cstr 0x1401800105. Check against the disassembly
Before you reimplement arithmetic, loops or bit tricks, read the instructions; Ghidra drops loop bounds, mis-types floats, merges stack slots and loses register arguments:
objdump -d --no-show-raw-insn --start-address=0x1400be680 --stop-address=0x1400be770 \
game/Game.exe > analysis/notes/be680.sWhat to look for: cvttss2si (float to int, truncating) against cvtss2si (rounding by the current mode); minss/maxss operand order (it decides which value wins with NaN); movss (32-bit float) against movsd (64-bit double); x87 instructions (fld, fstp) in 32-bit games, which compute in 80-bit precision until stored; imul/idiv (signed) against mul/div (unsigned); sar (keeps the sign) against shr.
Pitfalls
- Inlining. A small function may exist only inside its callers. If
callersfinds none but the code clearly runs, search for its body's distinctive constants instead. - Magic numbers as integers. A check for the bytes "SMPL" compiles to
== 0x4c504d53(little-endian). Search for the hex value as well as the text. - Decimal offsets. Ghidra prints some offsets in decimal:
param_1 + 100is+0x64.writersmatches both. - Thunks. A
FUN_that only jumps elsewhere is a thunk: follow it (functions.tsv marks them). - Wrong signatures. Ghidra guesses argument counts and types; a call through a register it did not track shows fewer arguments than the callee uses. The callee's own pseudocode and the disassembly at the call site settle it.
- 32-bit games pass arguments on the stack (cdecl, stdcall) and
thisin ecx (thiscall); seedocs/ABI.md. - Watcom register calls. Watcom C passes the first four arguments in EAX, EDX, EBX and ECX. With Ghidra's default convention the pseudocode drops them, and the stores that fill them look dead. For any Watcom function the port depends on, read the disassembly (
ghidra-run.sh GAME.EXE disasm OUT ADDR) and check every register set before each call (./gameframe advise --entry watcom-pseudocode-drops-register-arguments). - One Ghidra job per project. Ghidra locks the project while a headless job runs, so a second session's
ghidra-run.shfails withUnable to lock project. Take turns, or give each session a copy with--project DIR. Git worktrees do not holdanalysis/: use the main checkout's (./gameframe advise --entry worktree-ignored-folders-missing). - Paths with a dot folder. Ghidra refuses a project path with a folder that starts with "." (for example a git worktree in
.claude/worktrees/). The scripts handle it with a link in$TMPDIR(ghidra_project_dirinscripts/lib.sh); callanalyzeHeadlessyourself the same way.
16-bit programs (DOS MZ, NE)
The same export works. Addresses are seg:off: NE segment N is 1000 + 8 * (N - 1) (1000:873a, FUN_1000_873a, DAT_1048_12de); an MZ image starts at 1000:0000. decomp_query.py accepts these addresses and labels. Pitfalls (details in docs/DOS.md):
- The export passes
-loader-libraryLoadDepth 0: Ghidra's library search crashed on an NE program whose imported module (RTM) had a plain DOS file of that name next to it. - Ghidra does not know
INT 21h AH=4Chor Pascal'sHaltnever return: the next function is glued onto the caller. Create the function at the right address (labels, ordecompile). - Pascal procedures pop their own arguments and take them left to right: Ghidra's
param_1is the last source argument. Count from[bp+6]upwards, the first argument highest. - Pascal strings (length byte first) are missing from
strings.txt: usetools/dos/dosinfo.py BINARY --pstrings 6(it prints the length byte's address) andtools/dos/readseg.py BINARY pstr ADDR. - Disassembling the file directly shows far calls as
lcall 0:ffff(unrelocated). Usetools/dos/nedis.py BINARY SEG:OFF LEN: relocations applied, calls and globals named. - A Pascal string is referenced by its length byte (one before the text). A procedure's strings sit just before its code. A constant equal to a DS offset may print as
&global. INT 34h-3Dhin Borland code are floating-point instructions (the FPU emulator).- Constants:
tools/dos/readseg.py BINARY TYPE ADDRreads typed values atseg:off(u16,i32,ptr,real48for Turbo Pascal's 6-byte Real,pstr,hex).
Agents and tools
Agents that preload this skill: re-decompiler, re-tracer, re-identifier, re-mapper, re-typer, re-accelerator.
Tools this skill names:
- readdata: Read typed values from a Windows binary at Ghidra addresses (the DAT_ globals in pseudocode).
- relocs: List the pointers in a PE file's data, found from its base relocations.
- vtable: List the slots of a table of function pointers (a C++ vtable) with the functions they hold.
- dosinfo: First look at a DOS or Windows 3.x executable (MZ, NE): what it is, how it was built, what it uses.
- nedis: Disassemble 16-bit code with its relocations applied and its names shown.
- readseg: Read typed values from a DOS (MZ) or 16-bit NE executable at Ghidra's seg:off addresses.
- tdinfo: Read Borland Turbo Debugger symbols (TDINFO, signature FB52) from a DOS or NE executable.
- decomp_query: Answer questions about a Ghidra export (scripts/ghidra-export.sh) without reading it whole.
- exportcov: How much of a program's code a Ghidra export covers, where the gaps are, and where the functions it missed probably start.
- match_functions: Match the functions of two builds of a game, and carry your labels from one to the other: a patch, another release, a demo, a version for another platform with the same compiler.
- sigscan: Byte signatures with wildcards: make them from functions you have named, and find them in another build, a memory dump or a game that uses the same library.
- ghidra-export: Analyse one or more binaries with Ghidra (no window) and export them as text an agent can search: decomp.c, functions.tsv, strings.txt, imports.txt, exports.txt per binary.
- ghidra-run: Run one of the tools/ghidra scripts on a binary in an existing, analysed Ghidra project, without analysing it again (about a minute of start-up, so batch addresses in one run).
- lib: Shared settings for the scripts: where Ghidra, Java and the Python environment are.