A third of HARDWARW.EXE was not in any Ghidra function
What to know
2026-10-24. Ghidra 12.1.4, Hardwar's RecoverFunctions.java, GameFrame's exportcov.
What was missing
Ghidra's first export of HARDWARW.EXE (2026-10-23) had 2,633 functions. Their bodies held 774,013 of the code section's 1,142,272 bytes: 67.8% (measured, from the function bodies). WinMain, the vehicle control function, most of the rasteriser and the Direct3D, DirectPlay and sample-loading code lay in the gaps. Any trace that passed through them stopped there.
After the recovery: 4,251 functions, 4,169 of them decompiled with no failures (the rest are thunks to imports), and 1,118,934 bytes in functions: 98.0% (measured). What is left is mostly data in the code section (the runtime's tables at 0x5256c0-0x526c81) and switch cases still split off (below).
!Map of the code section before and after
Why Ghidra missed it
Three causes, in order of size (observed):
- Functions reached only through stored addresses. Watcom code passes callbacks as immediates (
mov [global], offset f,push offset f) and keeps them in tables inDGROUP. Ghidra's analysis follows calls, not these. The program's own entry point is the first case:0x4e92cestores0x414068(win_main) at0x5a8434and jumps into the runtime, which calls through it. The 12.8 KBvh_slot_control(0x47b1e0) is reached through 9 stored addresses and no call. - Code nothing refers to. 991 of the new functions (112 KB) have no call, no jump and no stored copy of their address anywhere in the file (observed, a search of every 4-byte value and every rel32 branch). They are probably code the linker kept but nothing runs (inferred). Examples:
0x413fc8besidewin_main, which also startsHardman.exe, and the 74-byte routine at0x47b16cjust beforevh_slot_controland its switch table at0x47b1b8: it lowers a Moth's power and stops at 0 (vh_power_use,docs/flight.md), so it is no dispatcher, but nothing calls it. Checkanalysis/notes/recovery/audit.tsvbefore tracing one as live. - Two functions wrongly marked as never returning.
mem_free(0x499c48) and0x499970both end inRETon every path, but Ghidra's "Non-Returning Functions - Discovered" analyser marked them so. Every caller ended at the call (the recovered program has 205 and 135 calls to them), and the rest of its code fell into the gaps.game_savewas one (docs/formats/sav.md). Clearing the mark is not enough: the analyser marks them again on the next analysis unless it is switched off.
Two smaller faults came from earlier one-off decompiles saved into the project: a function at 0x44b6e0 started in the middle of an instruction of init_light_zone (0x44b664) and stopped it at 0x44b6dd. That was the "range Ghidra decodes poorly" in docs/lighting.md. init_light_zone is whole now: 226 bytes, ending RET 0xc at 0x44b743.
How it was recovered
Hardwar's RecoverFunctions.java, run on the analysed project, in six phases, with Ghidra's analysis following the new code after each (Hardwar's run.sh RecoverFunctions.java REPORT.tsv`; about 45 seconds):
| Phase | What it does | Result |
|---|---|---|
| misaligned | Removes a function nothing refers to that starts inside an instruction the function before it falls into; lets that one grow back | 1 removed (0x44b6e0) |
| noreturn | Switches the "Non-Returning Functions - Discovered" analyser off, clears the mark on functions whose body has a RET, resets the flow at their call sites | 0x499c48, 0x499970 |
| pointers | Makes functions at code addresses kept in data (relocations outside the code) and at instruction operands that point into code nothing claims | 399 functions |
| gaps | In every run of unclaimed code: skips Watcom's padding (NOP, MOV EAX,EAX, LEA EAX,[EAX+0] and its longer forms, zeros, INT3), joins code that the instruction before falls into back onto its function, and otherwise makes a function at the first instruction; repeats until nothing changes (6 passes) | 1,206 functions |
| switches | For JMP [REG*4 + TABLE] whose table Ghidra had not read: reads the table, adds the cases, folds functions made at cases into the switch's function | 1 switch, 64 cases, 48 folded |
| merge | Folds a made function that only jumps from one other function reach back into it | 31 folded |
Any function it makes whose body has a bad instruction or a run of zero bytes is removed again as data (11 rejections, all in the runtime's tables near 0x525980). Then GameFrame's ApplyLabels.java applied labels/names.tsv (240 functions, 3 globals) and ExportAll.java wrote the export, first into analysis/export.new/ and then moved over analysis/export/HARDWARW.EXE/. The first export is kept in analysis/export-2026-10-23/ and the project before the recovery in analysis/ghidra-project.bak-2026-10-24/.
Measuring coverage
functions.tsv gives each function's entry and its body's size in bytes. GameFrame's exportcov treats that as one run from the entry, which is wrong when a body is in pieces. The rasteriser's are: 0x4be4cc jumps 52 KB forward into its own code. On the new export exportcov reports 93.7%, the body ranges 98.0%. Hardwar's ExportBodies.java writes the exact ranges, and the export now has them as bodies.tsv (start, end, entry). Hardwar's coverage_map.py draws the map above from two such files.
Still open
- Switches that load the case address into a register before jumping are not read by the switches phase. Their cases are still small separate functions ending in
JMP, reached only from a table: about 110, for example the 10-byte pieces at0x493a2e-0x4940ad(table at0x492060). - The rasteriser is hand-written: it stores code addresses into globals and jumps through them (
0x4be510stores0x4bf792). Each such target is its own function. That is right for entry points, but the pseudocode of these pieces is fragmentary; read the disassembly. init_light_zonekeeps two stale error bookmarks from the removed function.
For GameFrame
RecoverFunctions.java, ExportBodies.java and coverage_map.py belong in GameFrame's toolkit: the padding forms, the non-returning re-marking and the far-flung bodies are Watcom traits, not Hardwar's. exportcov should read body ranges, and its padding list lacks 8B C0 and 8D 40 00, which Watcom uses between functions.