pcrecomp

September 23, 2026 · View on GitHub

    ____  ______   ____  ________________  __  ___ ____
   / __ \/ ____/  / __ \/ ____/ ____/ __ \/  |/  // __ \
  / /_/ / /      / /_/ / __/ / /   / / / / /|_/ // /_/ /
 / ____/ /___   / _, _/ /___/ /___/ /_/ / /  / // ____/
/_/    \____/  /_/ |_/_____/\____/\____/_/  /_//_/

         "everything old is new again"

The unified toolbox for tearing apart old PC software and putting it back together, better.

This repo collects every tool, runtime, and hard-won trick from our PC static recompilation projects into one place. Next time we want to crack open a dusty .exe from 1995 and make it run on Windows 11, we start here.


What's In The Box

pcrecomp/
  tools/           Analysis & transformation tools (Python, a few C and Java)
    pe/            PE: headers, imports, resources, protection, symbol recovery
    ne/            NE (16-bit Windows / OS-2): parse, disassemble, Win16 imports,
                   and the generators that close lift -> compile -> link
    disasm/        Disassemblers, call graphs, and scoring a catalog
    lift/          Lifters for x86-16, x86-32 and x86-64, whole-image drivers,
                   and a differential tester for each
    cpp/           C++ recovery: RTTI, vtables, name (de)mangling
    classify/      Sorting functions: SDK vs custom, library vs game
    ghidra/ ida/   Headless scripts for both
    drm/           SafeDisc memory dumping
    assets/        Installers, archives and disc images
    formats/       Decoders for formats nobody else reads (C)
    audit_repo.py  Is this repo safe to make public?
  runtime/         What the lifted C compiles and links against
    recomp16/      16-bit DOS: CPU state, INT handlers, HAL, SDL2
    win16/         16-bit Windows NE: CPU header + link-on-day-one stubs
    recomp32/      32-bit, global registers: memory, dispatch, loader, crash report
    recomp32_cpu/  32-bit, explicit CPU struct (reentrant)
    recomp64_cpu/  64-bit, explicit CPU struct, plus guest C++ exception handling
    hybrid/        The lifted <-> real boundary, for keeping MFC or the CRT real
    compat/        Win32 -> SDL2 mapping
  templates/       Starter CMakeLists and .gitignore for a new project
  docs/            Pipeline, philosophy, the hybrid boundary, publishing

The pipeline, by architecture

Every target goes analyse -> find the functions -> lift -> generate the build tree -> link against a runtime -> test the lift. The tool at each step depends on what the binary is:

Stage16-bit DOS (MZ)16-bit Windows (NE)32-bit PE64-bit PE
Analysedisasm/decode16ne/ne_parse, ne/ne_xrefpe/pe_analyze, pe/catalog, pe/extract_importssame as 32-bit
Find functionsdisasm/analyze, disasm/largemodel16ne/ne_decode (or IDA via ida/ida_export)disasm/disasm32, cpp/rtti, cpp/vtable_scan, lift/recover, disasm/seed_from_logan IDA catalog, closed by lift/generate64
Liftlift/lift16lift/ne_liftlift/lift32 (global registers) or lift/lift32_cpu (CPU struct)lift/lift64_cpu
Generate the build treethe project's own driverne/gen_segments_h, ne/gen_unresolved_stubs, ne/gen_win16_stubs, ne/gen_imagepython -m tools (lift/translator), lift/generatelift/generate64
Runtimeruntime/recomp16runtime/win16runtime/recomp32 or recomp32_cpu, plus hybridruntime/recomp64_cpu
Test the liftlift/difftest16, recomp16/cpu_selftest.clift/difftest16 --nelift/difftest (Unicorn), recomp32_cpu/cpu_selftest.clift/difftest64 (the host CPU)

disasm/score_recovery scores any of the catalogs against a linker map, a PDB or IDA, whatever the architecture.

Every tool, one line each

Reading the binary (tools/pe/)

ToolWhat it does
pe_analyze.pyHeaders, sections, imports, exports; --json feeds the rest of the pipeline
extract_imports.pyImports of one module, or the shared API surface across a whole install folder
delay_imports.pyThe delay-load table that the plain import table leaves out
analyze_sections.pyPacking and DRM detection (SafeDisc, SecuROM, UPX, ...)
catalog.pyCatalog every binary in a folder, naming NE/LE/MZ images rather than calling them broken
stdcall_argc.pyEach import's stdcall stack purge, from the SDK's _Name@N decorations instead of a hand-typed table
rsrc.pyList and extract .rsrc (bitmaps get their file header back, so they open)
map_names.pyNames from an MSVC linker MAP; port carries library names onto a binary that shipped without one
debug_symbols.pyWhich source file each function came from, from surviving __FILE__ strings
merge_names.pyEvery name source (MAP > RTTI > __FILE__ > anything else) merged into one C-safe name per address

Finding the functions (tools/disasm/, tools/cpp/, tools/lift/recover.py)

ToolWhat it does
disasm/disasm32.py32-bit recursive descent from the entry point, exports and --seed-functions
disasm/decode16.pyTable-driven 16-bit decoder: resident image, one overlay, or a byte range
disasm/analyze.py16-bit function boundaries (MSC 5.x patterns) and a symbol table
disasm/largemodel16.pyLibrary: clips DGROUP off the code and resolves large-model far calls
disasm/fpu_decode.pyx87 decoding for the 16-bit side
disasm/callgraph.pyDirect callers/callees, hot functions and leaves, no Ghidra or IDA needed
disasm/seed_from_log.pyFeed the unresolved ICALL/ITAIL targets a run printed back into the next disassembly
disasm/score_recovery.pyPrecision/recall of a catalog against a reference, false positives split into split and invented
cpp/rtti.pyClasses, vtables, virtual methods and inheritance from MSVC RTTI; every method is a proven entry point
cpp/vtable_scan.pyThe same vtables without RTTI, found as runs of code pointers in data
lift/recover.pyEntries a catalog missed: jmp-thunk chains, stored function pointers, alternate entries inside merged functions

Lifting (tools/lift/)

ToolWhat it does
lift16.pyx86-16 -> C for DOS MZ; a library (from lift16 import Lifter)
ne_lift.pyNE-aware x86-16 -> C: far calls through relocations, Win16 imports as MODULE_API(cpu), x87, segment-aware memory
lift32.pyx86-32 -> C against global registers (recomp32); a library
lift32_cpu.pyx86-32 -> C against an explicit CPU struct, reentrant, needed for hybrid builds. x87 (80-bit operands, fprem, fcmov), MMX, packed SSE/SSE2, lock, bit-string ops, bswap
lift64_cpu.pyx86-64 -> C: 32-bit writes zero-extend, RIP-relative operands rebuilt, 16 GPRs + 16 XMM, SSE2 packed integer, quadword string ops, cpuid forwarded. x87 and MMX deliberately emit RECOMP_TODO
translator.pypython -m tools: the default 32-bit pipeline, analyse -> disassemble -> lift -> split into files
generate.pyFast 32-bit generation: one linear sweep, split on known boundaries
generate64.pyWhole-PE x86-64 driver: chunked TUs, declarations, dispatch table, import map. It also finishes the catalog (below)
difftest.pyLifted C vs Unicorn for x86-32, every register, flag and byte compared
difftest16.pyThe same for x86-16, on raw bytes or a real NE segment (--ne)
difftest64.pyLifted C vs the host CPU running the original bytes (difftest64.asm): registers, memory operands, lock atomics

Win16 NE (tools/ne/, see tools/ne/README.md)

ToolWhat it does
ne_parse.pySegments, relocations, imports, entry points; flags a VBRUN-only import list as p-code
ne_decode.pyNE-aware disassembly with cross-segment far calls and imports resolved; --ida-json follows IDA's code map
ne_xref.pySegment call graph, clusters, import usage, --dot
win16.pyOrdinal -> API name, and the PASCAL stack-purge table keyed by (MODULE, API)
idt_to_json.pyThe ordinal map, built from the .idt files IDA ships, with no IDA run
gen_win16_stubs.pyPrototypes, plus a correctly purging stub for every import with no shim yet; refuses an import whose purge it does not know
gen_segments_h.pysegments.h generated from the lifted definitions, so prototypes cannot drift
gen_unresolved_stubs.pyDefines the call targets nothing lifted, as stubs that abort rather than return
gen_image.pyThe flat memory image and mem_layout.h, internal relocations applied

Everything else

ToolWhat it does
classify/classify_functions.py, combined_classify.py, deep_classify.pySDK vs custom code in a GoldSrc game: SDK names, string references, call-graph and address clustering
classify/resolve_stubs.pyMaps far_SSSS_XXXX stubs to real functions and names the MSC 5.x C library
cpp/msvc_mangler.py, cross_mangler.py, mac_unmangler.py, parse_vtables.jsMSVC mangling, Metrowerks (Mac) demangling and Mac -> MSVC translation, vtable parsing
ghidra/*.javaHeadless: decompile everything or by address, export functions, stats, xrefs, range disassembly, function bounds
ida/ida_funcs.py, ida_export.py, ida_xrefs.py, ida_probe_segs.pyHeadless: function catalog with FLIRT flags, instruction-head code map, call graph + import use + FPU density, segment probe
drm/safedisc_dump.py, inject_and_run.cDump SafeDisc-decrypted .text from a running process; a version.dll injector for SafeDiscLoader2
assets/extract_wise.py, isextract.py, extract_cab.sh, pk3_inspect.py, bin2iso.jsWise and InstallShield installers, CABs, PK3/ZIP, BIN/CUE -> ISO
assets/iso_peek.pyRead an ISO's directory, locally or over HTTP range requests, and pull one file out without downloading the image
formats/FIF/FTC fractal image, M20/MVB, SPAM, DAT and string-table decoders (C)
audit_repo.pyGame material or lifted output tracked in a repo, in HEAD or in history; see docs/PUBLISHING.md

What the 64-bit path does that the 32-bit one never needed

The x86-64 lifter came in from systemes3recomp, where it runs Star Wars Battle Pods: a shipping UE3 build, 3.6M instructions, 92,552 functions, 0 lift errors. That binary forced four things, and all of them live in generate64.py and runtime/recomp64_cpu/:

  • The catalog is closed, not trusted. .pdata is not a function list (it splits functions into chunks and leaves out frameless leaves), so the catalog comes from IDA and generate64 finishes it. It reads back every literal target the generated C dispatches to and adds the ones that are not entries, repeating until nothing new appears (6 rounds, +1,101). It sweeps the DIR64 relocations for stored pointers into .text, which finds the functions only a vtable points at exactly, with no heuristic (+412). It takes entries from lea r64, [rip+disp] for callbacks whose address is only ever computed (+217). And it catalogs the int3-padded lone jmp rel32 thunks that MSVC emits and vtable slots point at, which IDA names only most of the time.
  • Switch arms are reachable. MSVC x64 computes a switch target in a register. So a function with an indirect jump labels every instruction and ends in a local dispatch switch. It falls back to the global dispatcher only for a real cross-function tail call.
  • Guest C++ exceptions work. eh64.c walks the guest stack with the original .pdata/.xdata and reads __CxxFrameHandler3's FuncInfo. It runs the catch funclet as guest code, then longjmps into the landing pad the lifter emits for every function that had a handler. The throw is intercepted at _CxxThrowException.
  • Imports are forwarded, not reimplemented. A Win64 guest on a Win64 host shares the calling convention, so the loader puts the real function address in the IAT slot. The import map generate64 emits is for reading crash trails.

The Projects That Built This

Every tool here was forged in the fires of an actual recompilation project. These are the PC games and apps we've taken apart so far. Statuses are what the project's own README claims; the numbers are function counts from its last pipeline run.

ProjectWhatEraEngine/TechStatus
wholeearthThe Electronic Whole Earth Catalog1988Macintosh HFS CD-ROM, HyperCard 1.2.2Format project - no PC binary on the disc; ships the classic-HFS lister, hands the stacks to macrecomp
civCivilization199116-bit DOS / MSC 5.xRuns! 672 functions, interactive boot/menu, 164K lines
operationneptuneOperation Neptune1991 / Win32 1998Borland PE32, ships its own linker mapPlays! CRT -> WinMain -> opening -> in the submarine
skifreeSkiFree1991Win16/Win32 (ski32.exe)Playable rebuild from decompiled C, cross-platform + extras
missileattackMissile Attack!199216-bit Win16 NE, MS linker 5.14P0 - the 16-bit pipeline's fixture: 87 KB NE, one code segment, 21 KB of code, no segmentation at all
timThe Even More Incredible Machine1993Borland C++ Win16 NEP0 - 27 code segments, imports nothing but KERNEL/USER/GDI, names its own window procs
boloBolo Adventures III199316-bit DOS, PKLITE-packed QuickBASICBoots & runs! 932 functions, 70K lines, EGA/DOS shimmed to SDL2; shipped tools/unpklite.py
coasterRoller Coaster Construction Set199316-bit DOS, Microsoft C (Code To Go)Boots - 886 functions lifted, runs the MSC startup into main() and through device setup, DOS on SDL2
dinoparkDinoPark Tycoon199316-bit DOS / Borland large modelBoots! Whole game lifted (~90K lines), renders .PIC screens + .ACT dinosaurs in colour
elfishEl-Fish199316-bit NE + TSXLIB extenderLifted & links - 2,236 functions, 121 segments, startup executes
msbusThe Magic School Bus Explores the Human Body1994Win16 NE, 18 binariesP0 - ~60 KB of code driving 190 MB of content
worldempireWorld Empire1994Visual Basic 3 p-code over VBRUN300.DLL (Win16 NE)Runs! The interpreter is the target: 99/99 segments, 14,826 functions, 226K lines, links and executes a real Win16 init path
tvTerminal Velocity1995Watcom C, DOS/4GW LEP0 - P1 is building the LE/LX front end this toolbox does not have yet
bobMicrosoft Bob1995Win16 NE + MFC, Jet/Access, WinGBring-up - all three modules lift link-clean, InitInstance runs Bob's real startup, frontier is inside Jet
hellbenderHellbender1996Terminal Reality voxel engine (Win32/MSVC)Bring-up - lifts clean (5,262 functions, 0 errors), 507 import bridges; same toolchain as Fury³
fury3Fury³1995Terminal Reality voxel engine (Win32/MSVC)Playable! Flies the canyon - 1,945 functions, SDL2+imgui frontend, real joystick
mtmMonster Truck Madness 1 + 21996 / 1998Terminal Reality, voxel runtime as a standalone DLLP0 - the Fury³ engine a third time, renderer split into named DLLs
catzCatz199616-bit NE engine DLL (PF Magic)Runs! Win32 window, original frame loop, toys and saving work
encartaEncarta 97 Encyclopedia1996MFC 4.0 + proprietaryRuns! Whole app lifted (7,326 fns); hybrid boundary puts the app body in recompiled code - 10,242 real MFC virtual dispatches land lifted per session
gtaGrand Theft Auto1997DMA "Race'n'Chase"Builds & runs - 4,094 functions, 444K lines, runtime bringup
podPOD Gold1997Ubi Soft MMX software rasteriserCompiles & links - 3,405 functions, 0 lift errors, 422K lines
ejayDance eJay 1 + 21997PXD Musicsoft audio engine behind a VB front endPlays and draws! Decodes its intro, streams to a real sound card at 22,050 Hz and animates its cursor - out of recompiled 16-bit code
xvtX-Wing vs TIE Fighter + Balance of Power1997Totally Games (the X-Wing Alliance engine's predecessor)Boots to a window - 1,790 functions, the guest CRT runs into WinMain and messages dispatch into recompiled code; DirectDraw next
fallout1-reFallout1997Custom (Interplay)Fork - native + HTML5 web port, multiplayer
fallout2-reFallout 21998Custom (Interplay)Fork - decompilation ~complete (alexbatalov upstream)
trespasserJurassic Park: Trespasser1998DreamWorks Interactive rigid-body engine (MSVC 6.0)P0 - reconnaissance. Ships a linker map, which makes it the calibration target for function recovery
nocturneNocturne1999Terminal Reality, Watcom C/C++32Phase 7 - 6,027 functions lift with 0 errors; real window, 42 MB image mapped, IAT dispatch, 95 of 171 imports live
mechwarrior3-recompMechWarrior 31999Zipper GOS engine (VC6 + MFC42, DirectX 6)Compiles - 2,805 functions, 0 lift errors, 158K lines, all 7 TUs build as a static lib
recoil-recompRecoil1999Zipper GOS engine (VC6 + MFC42)Compiles - 3,490 functions, 0 lift errors, 321K lines, 8/8 TUs build
xwaX-Wing Alliance1999Custom (LucasArts)Active - D3D11 port, concourse UI runs, 2,702 functions
sofSoldier of Fortune2000Quake II + GHOULActive - SDL2 port, 8 subsystems, full maps render
gunmanGunman Chronicles2000GoldSrc (Half-Life)Phase 2 - 3,990 functions, weapons/entities rebuilt
heavymetalHeavy Metal: FAKK22000id Tech 3 + UberToolsFoundation - 57 source files, core systems scaffolded
crimsonskiesCrimson Skies2000Zipper GOS engineCompiles & links - 6,232 functions, 826K lines, runtime bringup
forcecommanderStar Wars: Force Commander2000LucasArts Ronin engine (MSVC 6)In game! The campaign's briefing room renders in 3D - 39,038 functions, 10.7M lines, 0 lift errors
bwBlack & White2001Lionhead customActive - all 569 types done, 10 Hz game loop runs
omfbgOne Must Fall: Battlegrounds2003Diversions Entertainment, C++ module DLLsP0 - 10,374 mangled C++ exports name the engine; SafeDisc covers 1% of the code
bw2Black & White 22005Lionhead customP0 partial - white.exe (21.7 MB, the largest target yet) not extracted from the discs
rolRise of Nations: Rise of Legends2006Big Huge Games rts2 (MSVC 7.1)Phase 3 - the largest binary this toolchain has faced: 13.25 MB, 25,513 vtable-only functions, no RTTI

Every row above links to a repo that is actually there. See docs/PROJECTS.md for what each one taught the toolbox, including the three fixes One Must Fall: Battlegrounds forced before its disc would even open.

Sibling toolboxes

Same idea, different instruction set or platform. They are separate repos, not submodules:

  • xboxrecomp -- original Xbox (XBE), and therefore also x86-32. It is the closest relative this repo has, and the two have been converging: see docs/CONSOLIDATION.md for what has already been ported across and what is queued next.
  • macrecomp -- 68k Macintosh, A-trap dispatch and a QuickDraw/Toolbox HAL.
  • systemes3recomp -- Sega System ES3 arcade, which is Windows on x86-64. The 64-bit lifter, its runtime and difftest64 were written there against Star Wars Battle Pods and merged into this repo; see the 64-bit path.

Quick Start

"I have a mystery .exe and I want to know what's inside"

# What are we dealing with? (prints a summary; --json names the output file)
python tools/pe/pe_analyze.py mystery.exe --json analysis.json

# What DLLs does it import? (including delay-loaded ones). Point
# extract_imports at the whole install folder to get the shared-API view
# across every module at once -- those are the shims to write first.
python tools/pe/extract_imports.py mystery.exe
python tools/pe/extract_imports.py /path/to/install
python tools/pe/delay_imports.py mystery.exe

# Is it packed or copy-protected? (SafeDisc/SecuROM/UPX/...)
python tools/pe/analyze_sections.py mystery.exe

# Catalog every binary in the install folder at once
python tools/pe/catalog.py /path/to/install --json > catalog.json

# Got Ghidra? Decompile everything in one shot
# (run in Ghidra's headless analyzer)
analyzeHeadless /path/to/project MyProject -import mystery.exe \
  -postScript tools/ghidra/DecompileAll.java output.c

"I want to turn an old 32-bit exe into C code"

# Full automated pipeline: analyze -> disassemble -> lift -> compile
python -m tools game.exe --all --output src/recomp/gen/

# Or step by step:
python tools/pe/pe_analyze.py game.exe --json config/pe_analysis.json
python tools/disasm/disasm32.py game.exe --output functions.json --pe-json config/pe_analysis.json

# `python -m tools` is tools/lift/translator.py, and that is the only lifter
# CLI. lift32.py itself is a library: `from lift32 import Lifter`, one
# `Lifter` per function. Every project past the default pipeline drives it
# from its own run_lift.py -- that is where per-project relocation handling,
# import bridging and file splitting belong, not in a flag.

"It's a 16-bit DOS game from 1991"

# Disassemble: the whole resident image, one overlay, or a byte range
python tools/disasm/decode16.py GAME.EXE --resident
python tools/disasm/decode16.py GAME.EXE --overlay 3
python tools/disasm/decode16.py GAME.EXE 0x1200 0x400

# Find function boundaries (MSC 5.x patterns) and write the symbol table
python tools/disasm/analyze.py GAME.EXE -symbols work/symbols.toml

# Large model (Borland/MSC): `largemodel16` is a library that extends the
# analyzer in place -- detect_code_end() clips the DGROUP data blob off the
# code, build_call_graph() resolves the far calls analyze.py leaves dangling.
#   from largemodel16 import detect_code_end, build_call_graph
#
# Lifting is a library too, same as the 32-bit side: `from lift16 import Lifter`.
# Copy a project's driver to start -- dinopark/tools/lift_full.py (DOS MZ,
# large model). For NE, tools/lift/ne_lift.py is in this repo; see below.

"Did the lifter get the semantics right?"

# Run the lifted C and a real x86 (Unicorn) over the same bytes and compare
# every register, flag and byte of memory. Needs unicorn + a C compiler.
python tools/lift/difftest.py -v

# The 16-bit CPU model's hand-written flag logic (BCD, ADC/SBB carry-in),
# against values taken from hardware
cc -Iruntime/recomp16 runtime/recomp16/cpu_selftest.c -o selftest && ./selftest

"Did the disassembler find the real functions?"

# Score a recovered catalog against a reference -- a linker map, a PDB export,
# or another tool's analysis. Reports precision/recall on function starts.
python tools/disasm/score_recovery.py --reference ida_funcs.json \
                                      --candidate functions.json

# The reference can come from IDA, headless, in about a minute:
py -3.11 tools/ida/ida_funcs.py GAME.EXE ida_funcs.json

A false positive is two different defects wearing one name, so when the reference carries function ranges (IDA, a PDB) the score separates them: split means the address landed inside a known function, so one function got entered twice -- it duplicates code in a lift. invented means it landed outside every known function, so data was probably decoded as code -- that lifts to garbage. They need opposite fixes.

A reference is only ground truth if it came from symbols. Otherwise it is a second opinion, and a disagreement means one of the two is wrong -- go look at which before quoting the number.

"It's a 16-bit Windows / OS-2 program (NE format)"

# Structure: segments, relocations, imports, entry points
python tools/ne/ne_parse.py GAME.EXE

# NE-aware disassembly (resolves cross-segment far calls + imports)
python tools/ne/ne_decode.py GAME.EXE --summary
python tools/ne/ne_decode.py GAME.EXE --seg 3

# Segment call graph / clusters / import usage
python tools/ne/ne_xref.py GAME.EXE --clusters
python tools/ne/ne_xref.py GAME.EXE --imports

# The import surface as C: prototypes + a correctly-purging stub for every
# import with no hand-written shim yet
python tools/ne/gen_win16_stubs.py GAME.DLL     --api runtime/runtime_api.h --stubs runtime/win16/win16_stubs.c     --shims runtime/win16 --guard MYGAME

Then lift it. ne_lift.py is a library, same as the other lifters -- set PREFIX and call lift_segment(ne, n) per segment from your own driver:

import ne_lift
ne_lift.PREFIX = 'mygame'
ne_lift.lift_segment(ne, seg.index)     # prints C to stdout

The ordinal map is not per-project. KERNEL.90 is lstrlen in every Win16 binary, so win16.py now falls back to tools/ne/win16_imports.json when the project has none of its own. Build it once with idt_to_json.py and every Win16 target sees it. It stays gitignored -- it is IDA's data, not ours.

Win16 is PASCAL, and that is the thing that bites. The callee pops the arguments, so a shim that pops the wrong number does not fail at the call - it shifts the caller's frame, and the caller's epilogue then restores DS (or BP, or a return address) from the wrong slot. The crash lands somewhere else entirely, in code that is fine. tools/ne/win16.py carries the accumulated purge table keyed by (MODULE, API); gen_win16_stubs.py fails rather than generating a stub for an import that has no entry in it.

Names come from IDA, which ships the Win16 ordinal maps as .idt files, so no IDA run is needed. win16.py looks for work/win16_imports.json, then analysis/win16_imports.json, walking up from the project root, then falls back to tools/ne/win16_imports.json. Without any of them, imports resolve to MODULE_OrdN and the purge lookups all miss:

python tools/ne/idt_to_json.py --ida "C:/Program Files/IDA Professional 9.1" \
                               -o tools/ne/win16_imports.json

Then close the lift -> compile -> link loop. Each of these is generated from the target or from the lifted sources, so none of them is committed:

python tools/ne/gen_segments_h.py --src work/src --out runtime/segments.h --ne GAME.DLL
python tools/ne/gen_unresolved_stubs.py --src work/src --out work/src/_unresolved_stubs.c
python tools/ne/gen_image.py GAME.DLL --image work/mem_image.bin --header work/runtime/mem_layout.h

Link against runtime/win16/: cpu.h plus recomp_stubs.c, which gets a fresh target from "compiles" to "links" on day one and aborts loudly at the first thing that is not implemented.

"It's a 64-bit exe"

# 1. A function catalog from a real disassembler, one "0xADDR size name" per
#    line. .pdata is NOT one (see "the 64-bit path" above).
# 2. Lift the whole image into a build tree. The catalog is closed here:
#    dispatch targets, stored code pointers, lea-computed callbacks, jmp thunks.
py -3.11 tools/lift/generate64.py game.exe funcs.txt build/gen --split 400

# One function, to read what the lifter makes of it
py -3.11 tools/lift/lift64_cpu.py game.exe funcs.txt out.c 0x140001000

# Check the lifter against the host CPU, on instructions taken from THIS
# binary plus hand-picked edge cases. Needs MSVC x64 (ml64 + cl).
py -3.11 tools/lift/difftest64.py game.exe funcs.txt --count 2000

The generated C compiles against runtime/recomp64_cpu/cpu64.h. Add eh64.c when the guest uses C++ exceptions -- a UE3 build does, for every "failed to find object".

"Is this repo safe to make public?"

# Game files or lifted C tracked anywhere, including in history
python tools/audit_repo.py ../myproject

docs/PUBLISHING.md says what to do about what it finds, including how to strip history with git-filter-repo.

"The exe has SafeDisc DRM"

# Confirm it statically first (entry point inside a high-entropy section?)
python tools/pe/analyze_sections.py game.exe

# Dump decrypted code from a running process (Steam/CD version)
python tools/drm/safedisc_dump.py game.exe decrypted.exe
python tools/drm/safedisc_dump.py --pid 1234 game.exe decrypted.exe

"It's a Wise installer and I want the files out"

# Find the overlay, inflate the install script, list embedded files
python tools/assets/extract_wise.py setup.exe out_dir/

"Who calls this function? What does it call?"

# Direct callers (no Ghidra/IDA needed) + most-referenced functions
python tools/disasm/callgraph.py game.exe --callers 0x401D10
python tools/disasm/callgraph.py game.exe --hot 25

# Function-level graph + leaf detection (pair with DumpBounds.java output)
analyzeHeadless proj P -process game.exe -postScript tools/ghidra/DumpBounds.java bounds.csv
python tools/disasm/callgraph.py game.exe --bounds bounds.csv --leaves

"I have IDA and want its analysis to drive the lifters"

# Export IDA's verified code map (functions + instruction heads), then feed it in
py -3 tools/ida/ida_export.py GAME.EXE code_map.json --key ne
python tools/ne/ne_decode.py GAME.EXE --ida-json code_map.json

Requirements

Python 3.10+ with:

  • capstone - disassembly engine. Required.
  • pefile - PE parsing. Optional; there is a pure-struct fallback.
  • lief - advanced binary analysis. Optional.
  • unicorn - the reference x86 that lift/difftest.py and difftest16.py check the lifted C against. Only needed to run the differential tests. difftest64.py needs no emulator: the reference is the host CPU, built with MSVC x64 (ml64 + cl).

For Ghidra scripts: Ghidra 11.0+

For IDA scripts: IDA 7.4+ with its bundled Python (ida_funcs.py and ida_export.py run headless under idat -A -S).

For format tools: C compiler (MSVC or GCC)

For runtime: CMake 3.20+, Visual Studio 2022 or compatible

pip install capstone pefile lief unicorn

Starting a New Project

  1. Copy templates/CMakeLists.txt.template and templates/.gitignore.template
  2. Run pe_analyze.py on your target binary
  3. Pick your pipeline:
    • 32-bit PE: python -m tools game.exe --all (disasm32 -> lift32, driven by lift/translator.py). Write your own run_lift.py when the defaults stop fitting.
    • 16-bit DOS: decode16 -> analyze -> lift16 (with DOS compat runtime)
    • 16-bit Windows/OS-2 (NE): ne/ne_parse -> ne/ne_decode -> lift/ne_lift -> ne/gen_* -> runtime/win16 (see tools/ne/README.md)
    • 64-bit PE: IDA catalog -> lift/generate64 -> runtime/recomp64_cpu, checked with lift/difftest64
    • GoldSrc/SDK game: DecompileAll.java -> combined_classify.py (SDK separation)
    • C++ heavy: GhidraStats.java + msvc_mangler.py + parse_vtables.js
  4. Score the recovery before you lift it. disasm/score_recovery.py against a linker map, a PDB or IDA. Lifting a catalog you have not scored means finding its gaps at runtime, 30,000 calls deep, instead of now.
  5. Drop in the appropriate runtime/ files
  6. Build with CMake, fix, repeat
  7. Before going public, run audit_repo.py and read docs/PUBLISHING.md. Lifted output never goes in git.

Where the docs are

DocWhat it covers
docs/PHILOSOPHY.mdWhy static recompilation, and the approach end to end
docs/PIPELINE.mdEach phase, which tool, what it emits
docs/HYBRID.mdRunning lifted code inside a real program - the lifted/real boundary, its three non-obvious correctness rules, and how to bisect a hybrid build when it breaks 30,000 calls deep
docs/PROJECTS.mdWhich project contributed which tool, and why it exists
docs/PUBLISHING.mdWhat must not be in a public repo, how to strip it from history, and the order to do it in
tools/ne/README.mdThe Win16 NE front end in detail
tools/ida/README.mdRunning the IDA scripts headless
docs/CONSOLIDATION.mdWhat pcrecomp and xboxrecomp should share, what has been ported, what is queued

Philosophy (the short version)

Any PC application ever compiled can be systematically deconstructed and rebuilt for modern hardware. It's not magic, it's just work -- and with the right tools, it's less work every time.

We've proven this across DOS, Win16, Win32, Win64, MFC, Quake-family engines, GoldSrc, id Tech 3, and completely custom engines. The pattern is always the same: Analyze -> Disassemble -> Classify -> Lift -> Shim -> Build -> Debug -> Ship.

Read the full philosophy in docs/PHILOSOPHY.md.

Credits

Built on other people's tools, and on published reverse-engineering work:

  • capstone (BSD-3-Clause) — the disassembly engine under every lifter here.
  • pefile (MIT) — PE parsing, including the .reloc walk the 32-bit lifters rely on.
  • Ghidra (Apache-2.0) and IDA Pro — hosts for the headless scripts in tools/ghidra/ and tools/ida/; function boundaries and decompilation.
  • Kostya Shishkov and Alyssa Milburn — their published work on the FVF/IFS fractal codec family underpins tools/formats/ftcdecode/.

All of the above are used as libraries or hosts under their own licences. None of them are vendored here.

License

MIT — see LICENSE. Use these tools to bring back whatever software you love.

One thing the licence cannot give you: rights to the software you point these tools at. Lifting a binary produces a derivative work of that binary, so the output carries whatever licence the original does. Own what you take apart.


Built with stubbornness and too much coffee by sp00nznet