sots-re/guides/re-windows-2000s-howto.md

30 KiB
Raw Blame History

Reverse-engineering mid-2000s Windows C++ games — a how-to grounded in the literature and in our SOTS1 campaign

Scope: 32-bit PE, MSVC 2005–2010, C++ with RTTI, DirectX 9, no source, no symbols. The target is a functional reimplementation (behaviour-equivalent, asset-external, OpenRCT2-style shim first), not a byte-matching decomp. Written 2026-09-07 against our Sword of the Stars (2006) work; "our experience" call-outs cite findings/ and ~/sots-engine/docs/.


1. Annotated bibliography

1.1 Prior art and communities

Source Why it matters
OpenRCT2 — https://github.com/OpenRCT2/OpenRCT2 (early README preserved in forks, e.g. https://github.com/jvlomax/OpenRCT2) The canonical incremental model: openrct2.dll exported a WinMain that a patched rct2.exe called at startup; each procedure rewritten in C individually while the DLL read/wrote the original's memory and called its procedures. IDA 5.0 was the disassembler.
OpenRCT2 independence milestone — https://rct.fandom.com/wiki/OpenRCT2 The project called into RCT2.EXE for unimplemented functions until 15 Oct 2015, when the last original code was displaced (assets still required). The whole arc took ~18 months.
OpenRCT2 Replay System — https://github.com/OpenRCT2/OpenRCT2/wiki/Replay-System Records every game command, replays in CI, fails on sprite checksum divergence; desync logs for triage. Note: "replays on x64 and x86 platforms will generate different sprite checksums."
OpenLoco — https://github.com/OpenLoco/OpenLoco , https://openloco.io/about/ Same method on a 2004 MSVC/asm engine; unimplemented code is Interop::call(addr); grep count of those calls was the progress metric. Reimplementation declared complete Sept 2025.
OpenJKDF2 HOOKS.md — https://github.com/shinyquagsire23/OpenJKDF2/blob/master/HOOKS.md Cleanest write-up of a hook-and-replace DLL against a 1997 MSVC exe: IPS patch replaces Window_Main with shellcode that LoadLibraryAs the DLL and calls hook_init_win; hook_function() per decompiled function; unimplemented functions fall back to JK.EXE.
Devilution — https://github.com/galaxyhaxz/devilution ; https://www.pcgamer.com/a-coder-spent-1200-hours-reverse-engineering-diablos-source-code/ 1,200 hours; first version was a raw IDA dump with thousands of errors fixed until it compiled. Asserts/debug strings in a debug build recovered file names and even line numbers.
DevilutionX — https://github.com/diasurgical/devilutionX The asset-external port that grew from it; requires DIABDAT.MPQ or the shareware spawn.mpq.
D2MOO — https://github.com/ThePhrozenKeep/D2MOO Diablo II reimplementation as DLL patches via a D2.Detours submodule; original binaries and assets required; explicitly "no bug fixes yet" to stay equivalent. Closest MSVC-C++-era analogue to our shim.
Spore ModAPI "Detouring" — https://emd4600.github.io/Spore-ModAPI/_detouring.html MSVC-2008 C++ game with RTTI, modded via static_detour / member_detour / virtual_detour classes — the taxonomy we need for __thiscall vs vtable hooks.
SpaceCadetPinball — https://github.com/k4zmu2a/SpaceCadetPinball Full decomp of a 1995 MSVC C++ game using the public PDB of XP's pinball.exe; "all subroutines were decompiled and C pseudo code was converted to compilable C++". Shows what symbols buy you.
isledecomp / reccmp — https://github.com/isledecomp/reccmp Byte-matching toolchain for MSVC 4.2 (LEGO Island): // FUNCTION: LEGO1 0x100b12c0 annotations drive automatic verification of recompiled functions, vtables and data offsets. The matching-decomp counterpart to our address-manifest idea.
TRX (TR1X/TR2X) — https://github.com/LostArtefacts/TRX Tomb Raider I–III reimplementation from late-90s MSVC exes; mature example of decomp → enhancements.
dethrace — https://github.com/dethrace-labs/dethrace , http://1amstudios.com/articles/dumping-debug-symbols-for-carma1/ Watcom DETHRSC.SYM symbols from a different build were still worth painstaking manual re-matching. Lesson: partial/mismatched symbols still pay.
re3/reVC saga — https://www.gamedeveloper.com/business/rockstar-parent-take-two-sues-modders-behind-i-gta-i-reverse-engineering-project-re3- ; https://torrentfreak.com/take-two-dismisses-claims-against-lead-defendants-in-gta-mods-lawsuit-230405/ DMCA'd 2021, sued, settled 2023, never returned. The cautionary case for MSVC-era PC decomps of still-sold IP.
Ship of Harkinian FAQ — https://www.shipofharkinian.com/faq User-supplied ROM → local asset extraction (oot.otr); the project ships no IP. Model for our $SOTS_DATA_DIR rule.
Sonic Mania / RSDKv5 decomp — https://github.com/RSDKModding/Sonic-Mania-Decompilation Requires the user's Data.rsdk; same posture.
decomp.me — https://www.decomp.me/faq , https://github.com/decompme/compilers Collaborative per-function matching "scratches" with a live diff score; MSVC toolchains exist in the compilers repo. Useful even for a non-matching project on the handful of functions whose exact semantics matter (RNG, checksum).
Gal Zaban, Behind Enemy Lines: RE C++ in Modern Ages (CppCon 2019) — https://www.youtube.com/watch?v=ZJpvdl_VpSM ; slides https://corecppil.github.io/CoreCpp2019/Presentations/Gal_Behind_Enemy_Lines_Reverse_Engineering_Cpp_in_Modern_Ages.pdf Hierarchy/vcall reconstruction from the RE side; introduces Virtuailor (dynamic vtable resolution in IDA).
PCGamingWiki SOTS — https://www.pcgamingwiki.com/wiki/Sword_of_the_Stars ; RE StackExchange — https://reverseengineering.stackexchange.com Known fixes/quirks; Q&A archive for MSVC idioms.
RetroReversing on PDBs — https://www.retroreversing.com/PDBFileReversing Why you always check for shipped/leaked PDBs first; they "take away the bulk of the work".

1.2 MSVC-vintage specifics

Source Why it matters
Skochinsky, Reversing MSVC Part I: Exception Handling — https://www.openrce.org/articles/full_view/21 FuncInfo, unwind maps, try-blocks, __CxxFrameHandler; how EH data doubles as scope/destructor evidence.
Skochinsky, Reversing MSVC Part II: Classes, Methods and RTTI — https://www.openrce.org/articles/full_view/23 The reference for TypeDescriptor / CompleteObjectLocator / ClassHierarchyDescriptor / BaseClassArray, ctor/dtor patterns, ms_rtti4.idc.
Skochinsky, Recon 2012 Compiler Internals: Exceptions and RTTI — http://www.hexblog.com/wp-content/uploads/2012/06/Recon-2012-Skochinsky-Compiler-Internals.pdf Updated slides incl. x64 and GCC; the single best crib sheet.
Ghidra RTTI analyzer (MicrosoftCodeAnalyzer) — https://github.com/NationalSecurityAgency/ghidra/blob/master/Ghidra/Features/MicrosoftCodeAnalyzer/src/main/java/ghidra/app/plugin/prototype/MicrosoftCodeAnalyzerPlugin/RttiAnalyzer.java Creates RTTI data + vftable symbols only for VS-compiled PEs. It labels; it does not build classes.
Ghidra RecoverClassesFromRTTIScript — https://github.com/NationalSecurityAgency/ghidra/blob/master/Ghidra/Features/Decompiler/ghidra_scripts/RecoverClassesFromRTTIScript.java ; issues https://github.com/NationalSecurityAgency/ghidra/issues/8896 The script that actually creates class namespaces, ClassDataTypes structs, typed vftables, ctor/dtor names and this types. Needs the RTTI analyzer and Decompiler Parameter ID to have run; has NPE bugs on some PEs.
IDA ClassInformer — https://github.com/kweatherman/IDA_ClassInformer_PlugIn ; PyClassInformer — https://plugins.hex-rays.com/herosi/pyclassinformer IDA's equivalent vftable finder/lister for MSVC RTTI.
OOAnalyzer (CMU SEI Pharos) — https://github.com/cmu-sei/pharos/blob/master/tools/ooanalyzer/ooanalyzer.pod ; paper https://edmcman.github.io/papers/ccs18.pdf ; Ghidra import https://www.sei.cmu.edu/blog/using-ooanalyzer-to-reverse-engineer-object-oriented-code-with-ghidra/ Prolog-based recovery of class layouts, method-to-class assignment and inheritance from MSVC binaries; >78 % of methods assigned correctly on their corpus. Targets exactly our compiler family.
Raymond Chen, Adjustor thunks — https://devblogs.microsoft.com/oldnewthing/20040206-00/?p=40723 ; MS vtordisp — https://learn.microsoft.com/en-gb/cpp/build/reference/vd-disable-construction-displacements?view=msvc-170 Why a vtable slot may point at sub ecx, N; jmp and why this inside a method is not the object base.
Raymond Chen, Inside STL: The string — https://devblogs.microsoft.com/oldnewthing/20230803-00/?p=108532 ; Yurichev RE4B ch. on STL — https://github.com/x7dbg/reverse-engineering-for-beginners/blob/master/Chapter-33/Chapter-33.md Dinkumware _Bx SSO union / _Mysize / _Myres; list/map node shapes. Offsets are version-specific — verify.
Rich header — https://www.ntcore.com/files/richsign.htm ; tool https://github.com/dishather/richprint @comp.id records give exact compiler/linker build numbers and object counts; the fingerprint step.
Ghidra Function ID docs — https://github.com/NationalSecurityAgency/ghidra/blob/master/Ghidra/Features/FunctionID/src/main/doc/fid.xml ; extra fidbs https://github.com/threatrack/ghidra-fidb-repo ; FLIRT-in-Ghidra https://github.com/NWMonster/ApplySig Ghidra ships per-VS-version x86 FID databases; use them to knock CRT/STL/D3DX static code out of the function count.
MSVC EH funclets — https://llvm.org/docs/ExceptionHandling.html (Windows section) ; Ghidra SEH issue https://github.com/NationalSecurityAgency/ghidra/issues/2477 Catch bodies and cleanups are funclets called by __CxxFrameHandler3 — decompilers show them as orphan code; Ghidra does not model SEH scope tables.
Ghidra calling-convention pitfalls — https://github.com/NationalSecurityAgency/ghidra/issues/3404 , https://github.com/NationalSecurityAgency/ghidra/issues/5484 __thiscall/__cdecl mis-detection and vtable pointer parameter shifting; the class of bug that crashed our M0 hook.
CRT initialization — https://learn.microsoft.com/en-us/cpp/c-runtime-library/crt-initialization?view=msvc-170 .CRT$XCU initializer table walked by _initterm(__xc_a, __xc_z); every global-constructor stub lives there.
Visual C++ name mangling — https://en.wikiversity.org/wiki/Visual_C++_name_mangling ; https://github.com/cmu-sei/pharos-demangle Grammar for .?AV… type descriptors and ?func@Class@@QAE… symbols; undname.exe is the oracle.
GetPrivateProfileString — https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getprivateprofilestringa The INI idiom of the era; an import of it is a free map from config keys to code.

1.3 Dynamic-analysis toolchain

Source Why it matters
x64dbg conditional tracing — https://help.x64dbg.com/en/latest/introduction/ConditionalTracing.html ; plugins https://github.com/x64dbg/x64dbg/wiki/Plugins Trace-into-until-condition with {p:cip} {i:cip} log formats; ScyllaHide, xAnalyzer, ret-sync (Ghidra↔debugger sync).
Frida Stalker — https://frida.re/docs/stalker/ Whole-thread instruction tracing; documented "severe issues in 32-bit" — use x64dbg tracing or TTD for x86.
Microsoft Detours — https://github.com/microsoft/Detours ; MinHook https://github.com/TsudaKageyu/minhook ; hybrid https://github.com/m417z/minhook-detours Detours is MIT since 4.0.1 (the old "Express is x86-only/$10k" lore is obsolete); MinHook is the minimal BSD alternative we vendored.
Ultimate ASI Loader — https://github.com/ThirteenAG/Ultimate-ASI-Loader The de-facto list of proxyable system DLLs: d3d8/9/10/11, dxgi, ddraw, dinput(8), dsound, msacm32, msvfw32, version, wininet, winmm, winhttp, xlive, vorbisFile, binkw32, bink2w32, xinput*.
DLL best practices — https://learn.microsoft.com/en-us/windows/win32/dlls/dynamic-link-library-best-practices Loader-lock rules for doing work in DllMain (we hook there; it worked because the process is still single-threaded).
API Monitor — http://www.rohitab.com/apimonitor ; Process Monitor — https://learn.microsoft.com/en-us/sysinternals/downloads/procmon Import-level call logging and file/registry I/O without writing code.
apitrace — https://apitrace.github.io/ Traces/replays D3D7–11 on Windows; the D3D9 frame-debugger of record. RenderDoc does not capture D3D9 (https://github.com/openblack/openblack/wiki/Getting-frame-captures-of-Vanilla-Black-&-White).
DXVK — https://github.com/doitsujin/dxvk ; dgVoodoo2 — http://dege.freeweb.hu/dgVoodoo2/ ; dxwrapper — https://github.com/elishacloud/dxwrapper ; WineD3D for Windows — https://fdossena.com/?p=wined3d/index.frag ; lavapipe — https://docs.mesa3d.org/drivers/llvmpipe.html ; overview https://emulation.gametechwiki.com/index.php/Wrappers Ways to run a D3D9 app today: DXVK (d3d9→Vulkan, works on CPU Vulkan), dgVoodoo2 (→D3D11), dxwrapper (D3D8→9, DDraw fixes), WineD3D (→OpenGL).
WinDbg Time Travel Debugging — https://learn.microsoft.com/en-us/windows-hardware/drivers/debuggercmds/time-travel-debugging-overview ; record https://learn.microsoft.com/en-us/windows-hardware/drivers/debuggercmds/time-travel-debugging-record Records x86 processes; replay backwards to "who wrote this field"; slow and multi-GB; known x86 quirk misreads adjacent 32-bit args as one 64-bit value.
ReClass.NET — https://github.com/ReClassNET/ReClass.NET ; Cheat Engine dissect — https://forum.cheatengine.org/viewtopic.php?t=530548 Live-memory struct layout viewers; complement static serializer-derived layouts.
Source Why it matters
OpenMW FAQ — https://openmw.org/faq/ "Engine recreation … only replaces the program"; no assets shipped; "clean reverse-engineering"; must own Morrowind.
ScummVM FAQ — https://www.scummvm.org/faq/ Explicitly mixes engines from released source and from RE; ships none of the assets.
DevilutionX installing docs — https://github.com/diasurgical/devilutionX/blob/master/docs/installing.md Sustainable Use License, shareware-data path, "provide your own legitimate copy".
OpenTTD / OpenGFX — https://wiki.openttd.org/en/Basesets/OpenGFX Two years to replace every sprite with free ones; the only way an engine recreation ever stops needing the original.
EFF Reverse Engineering FAQ — https://www.eff.org/issues/coders/reverse-engineering-faq ; Sega v. Accolade — https://en.wikipedia.org/wiki/Sega_v._Accolade ; EU Software Directive Art. 6 / Top System — https://vidstromlabs.com/blog/the-legal-boundaries-of-reverse-engineering-in-the-eu/ Intermediate copying for interoperability as fair use (US); decompilation for interoperability and, per CJEU 2021, for error correction (EU). Non-legal summary only.

1.5 AI-assisted RE (2026)

Source Why it matters
ReVa — https://github.com/cyberkaida/reverse-engineering-assistant Ghidra 12+ MCP server; small tool-per-task design; headless mode for pipelines. What we use.
GhidraMCP — https://github.com/LaurieWired/GhidraMCP ; ida-pro-mcp — https://github.com/mrexodia/ida-pro-mcp ; re-mcp — https://github.com/jtsylve/re-mcp The alternative bridges; ida-pro-mcp's README warns LLMs are "prone to hallucinations" and that int/byte conversions are "especially problematic".
aiDAPal — https://github.com/atredispartners/aidapal ; GhidrAssist — https://github.com/symgraph/GhidrAssist ; Sidekick — https://sidekick.binary.ninja/ Local fine-tuned (aiDAPal) and OpenAI-compatible (GhidrAssist) in-tool assistants.
ReSym (CCS 2024) — https://github.com/lt-asset/resym Variable/struct symbol recovery; uses a Prolog aggregation over multiple LLM queries to "suppress uncertainty and hallucinations" — the cross-checking pattern to copy.
LLM4Decompile — https://github.com/albertan017/LLM4Decompile ; Decompile-Bench — https://arxiv.org/abs/2505.12668 ; REFORGE function-naming benchmark — https://arxiv.org/pdf/2607.07738 State of neural decompilation/naming: re-executability ~45 % on toy benchmarks, far lower on real optimized code.
Kévin Drapel, Settlers IV with Ghidra + ReVa + AI — https://medium.com/@kevin.drapel/reverse-engineering-an-old-game-with-ghidra-reva-mcp-and-ai-846911bf740a Field report on a 2001 Win32/C++ RTS: AI great at subsystem discovery and plausible names, but left renames half-done and "does not replace … cross-checking with runtime behaviour".
Talos, Using LLMs as a RE sidekick — https://blog.talosintelligence.com/using-llm-as-a-reverse-engineering-sidekick/ Practitioner framing: assistant, not oracle.

2. How-to: fingerprint → static map → dynamic → verification → reimplementation

Step 0 — Fingerprint the binary (half a day)

  1. objdump -p / pefile for machine, linker version, imports, LARGE_ADDRESS_AWARE, relocs. richprint for the Rich header: @comp.id build numbers pin the compiler and count object files (ntcore; richprint).
  2. Look for *.pdb next to the exe and for a PDB path string inside it (RSDS debug directory). Even a mismatched build's symbols are worth manual re-matching (dethrace; SpaceCadetPinball).
  3. Grep for .?AV — non-zero count means RTTI survived; count TypeDescriptors.
  4. Note the import mix: MSVCR100/MSVCP100 = VS2010 and dynamic CRT (fewer static-lib functions to strip); d3d9 + d3dx9_NN gives the renderer anchor and the exact D3DX month; binkw32, dsound, wsock32 each become a proxy-DLL candidate.

Our experience (findings/01-fingerprint.md): MajorLinkerVersion 10 + MSVCR100 settled a VS2003/2005/2008 ambiguity in minutes; LAA was already set, which killed a planned "fix" before it started. 1,924 type descriptors and 41,411 functions in 7.5 MB.

Step 1 — Static map (weeks, breadth-first)

1a. RTTI harvest. Run Ghidra auto-analysis with the Windows x86 PE RTTI Analyzer and Decompiler Parameter ID on; then run RecoverClassesFromRTTIScript (or IDA ClassInformer). The analyzer only labels vftables and RTTI data; the script is what creates class namespaces, ClassDataTypes structs and this types. It NPEs on some PEs (issues #7927, #8896) — if so, read the type descriptors and COLs yourself.

Our experience (findings/objects/00-inventory.md): the headless RTTI analyzer produced 0 SymbolType.CLASS namespaces — consistent with the analyzer/script split above, not with broken RTTI. Reading .?AV strings directly gave the full Mars/Game inventory the same day.

1b. Vtables and this-adjust. For every class, record each vftable's COL offset (sub-object offset). Methods reached through a secondary vftable get this = object + offset, and slots may be adjustor thunks (sub ecx, N; jmp) (Chen; Skochinsky II). Absolute member offset = decompiled this+off + COL offset.

Our experience (findings/objects/struct-recovery.md §0): SOTS's IStreamable sub-vftable sits at +8 (most classes) or +0x3a0 (ServerPlayer); slots point straight at the functions with no thunks, so every serializer's this is pre-adjusted. We created shifted "serializer view" structs (ServerSystem_ser8, ServerPlayer_ser928) so the decompiler shows field names inside Read/Write (turn-spine.md §1.1).

1c. Library triage. Apply Ghidra's VS2010 x86 FID databases (plus threatrack's) or FLIRT via ApplySig to fence off CRT/STL/D3DX code; anything left is game code. STL template instantiations compiled from headers won't match a library signature — recognise them by shape instead: MSVC-2010 std::vector = {begin, end, cap}, std::list = {head*, size}, red-black node = {left, parent, right, key…, color/isnil tail}, std::string = 0x1c bytes on x86 with the SSO union and _Mysize/_Myres.

Our experience: our own notes disagree on whether _Bx starts at +0 or +4 (allocator stub) in this build (struct-recovery.md §0 vs turn-spine.md §1.1). The reliable lever is the _Myres >= 16 ? _Ptr : _Buf branch in any string consumer (Stream::WriteString @ 0x008b9d70), which pins both offsets. Verify per binary; do not trust a table.

1d. Strings are the map. Config keys, file names, log format strings and — decisively — serialization tag names. If the save format is tagged, each Read/Write references dozens of a struct's tag strings in order; rank functions by distinct tag xrefs, decompile, and read this+offset → tag → type straight off. This is the single highest-yield lever for the object model, and it is under-documented in the literature (see §4).

Our experience (struct-recovery.md): SerFind.java (string→xref→function ranking) + SerDump.java (decompile with DAT_ literal substitution) recovered 87-field ServerSystem, 110-field ServerPlayer, StarFleet, StarShip and 25 nested types in one round; community save-editor structs (Bardez, SOTSedit) were the Rosetta cross-check and caught two int64-vs-int32 errors in them.

1e. Static initializers. Walk _initterm(__xc_a, __xc_z) (MS CRT docs). Each .CRT$XCU stub is a global constructor; in engine code they register config vars, factories, message types.

Our experience (loader-prototypes.md M1): ~600 stubs at 0x009a4720…0x009c1400 each register one GlobalConst{storage,key,parser,file} — the whole flat-KV config schema (StrategyVars.txt, globals.txt, Species.txt) fell out of one script. Our first pass missed the fourth (file-name) argument; re-check arity from the RET n/stack purge, not from the first decompile.

1f. Exceptions. Expect __CxxFrameHandler3 prologues (push -1; push handler; mov eax, fs:[0]) and orphan funclets for catch bodies (LLVM EH doc; Ghidra #2477). Treat unwind/FuncInfo tables as evidence of which locals are objects with destructors (Skochinsky I). Don't chase "unreachable" funclet code as logic.

1g. Config idioms. Check imports for GetPrivateProfile* — if present, INI keys map 1:1 to code. If absent, the game has its own parser; find the writer (fprintf("%s %d")) as well as the reader.

Our experience (running-the-game.md): SOTS uses no INI API; the video config is display.cfg, parsed with _stricmp; we lost time on sots.cfg because the string existed for something else. Xrefs on the writer (0x8f0e40) settled it.

1h. Write it back. Names in class namespaces, prototypes with calling convention, plate comments citing the finding doc, and an exported address manifest (ghidra/addresses.json) with per-entry [verified]/[unverified] provenance. Ghidra resolves a __thiscall in namespace Game::X to struct /Game/X, so merging recovered fields into that struct makes every method decompile with names (turn-spine.md §1.1).

Step 2 — Dynamic: run it, hook it, trace it

2a. Run it on a modern box (or a GPU-less VM). Install the matching VC redist and the full DirectX End-User Runtime (legacy d3dx9_NN + shader-compiler DLLs for vs_1_1/ps_1_1 effects). Choose a D3D9 path: DXVK's d3d9.dll (works on Mesa lavapipe = CPU Vulkan), dgVoodoo2, dxwrapper, WineD3D-for-Windows, or Microsoft's WARP.

Our experience (running-the-game.md): app-local d3dx9_42.dll alone made every .fx effect fail (D3DX 42 falls back to d3dx9_31 for SM1 profiles); the full June-2010 redist fixed it. DXVK 3.1 + lavapipe rendered the game at 100–220 % CPU; plain d3d9.dll+WARP also works. Exclusive fullscreen minimises on any focus change — force windowed via the game's own config first. Drive UI from a scheduled task (schtasks /IT), not SSH.

2b. Proxy DLL + inline hooks. Pick a proxy the exe imports by name with a small export table (binkw32, dinput8, version, winmm; Ultimate ASI Loader's list). Forward every export to <name>_real.dll, relocate RVAs against the ASLR'd base, hook with MinHook or Detours (both free; Detours is MIT). Hooking in DllMain is fine while the process is single-threaded (DLL best practices).

Our experience (~/sots-engine/docs/M0.md): 66-export binkw32 proxy, MinGW-i686 cross-built, MinHook; --kill-at/--enable-stdcall-fixup must not be passed or the @N names the exe imports vanish. The __thiscall lesson: a C++ void __thiscall hook(void* self){ log; orig(self); } crashed Application::Initialize because the real function takes one stack arg and RET 4s. Until a prototype is verified from the prologue and stack purge, trace hooks are an asm stub (pushad; call logger; popad; jmp trampoline); only replace-hooks may be plain C++ (loader-prototypes.md "Why the M0 detour crashed").

2c. Trace and record. x64dbg trace-into-until-condition with a log format, ret-sync to keep Ghidra in step, ScyllaHide if the game checks for debuggers. For "who wrote this byte" questions use WinDbg TTD (x86 supported; slow, multi-GB, args display quirk) rather than Frida Stalker (32-bit is a known weak spot). API Monitor / ProcMon for import- and file-level views. ReClass.NET on a live object to confirm the statically recovered layout.

2d. Renderer. apitrace (apitrace trace -a d3d9 game.exe, qapitrace) captures the whole D3D9 call stream and replays it anywhere; RenderDoc cannot capture D3D9. Use the trace as the renderer's oracle later.

Step 3 — Verification: oracles and differential testing

  1. Find a deterministic oracle in the original. Saves, replays, logs, network packets — anything the game writes as a pure function of state. Check for timestamps/salts by hashing repeated runs across separate processes.

Our experience (determinism-oracle.md): load save → End Turn → autosave is byte-identical across five runs and two processes (gzip container included: MTIME 0). The only non-idempotent field is a per-player Status (4→0 on load) that moves an additive Checksum; both are canonicalised, nothing else. That single fact makes the whole turn pipeline testable without instrumenting the original.

  1. Instrument for per-call differential compare. Per hook: off | trace | compare | replace. In compare, run original and reimplementation on the same snapshot and diff typed outputs; record JSON Lines with C types so equality rules (exact ints, tolerant floats, hashed blobs, ignored pointers) are explicit (verify/harness/compare/TRACE_FORMAT.md).
  2. Replay regression in CI. OpenRCT2 records command streams and fails the build on state-checksum divergence; desync logs localise the first divergent tick. Note their finding that x86 and x64 builds checksum differently — x87 vs SSE float paths. Any 64-bit or differently-optimised reimplementation must budget for float-parity work (or pin -mfpmath//fp:strict and compare with tolerance).
  3. Matching where it counts. For RNG, hashing, checksum and fixed-point routines, a decomp.me-style exact match is cheaper than fuzzing semantics.

Step 4 — Reimplementation: shim → standalone

  • Follow OpenRCT2/OpenLoco/OpenJKDF2: original exe stays the host; the DLL owns a growing set of functions; unimplemented ones fall through to the original. Make "count of remaining Interop::call/trampoline sites" the public progress metric (OpenLoco).
  • Order by self-containment and oracle strength: parsers → VFS → serializers → RNG → turn pipeline → UI → renderer (our M0…M6 plan in 00-strategy.md).
  • Keep two repos: evidence (binary-derived, private) and engine (clean, public-capable). The only crossing is a generated address header. Ship engine code only; users bring assets ($SOTS_DATA_DIR), as OpenMW/ScummVM/DevilutionX/SoH do.
  • Expect the independence milestone to take a year-plus (OpenRCT2: Apr 2014 → Oct 2015); the standalone frontend comes only when enough engine exists to boot without the host.

3. What is different about SOTS1

  • RTTI is complete and namespaced (Mars:: engine, Game:: logic, 1,924 types), and the serialization framework is reflective (IStreamable + tagged Stream::Write(name, value)). Most literature assumes stripped C++ where class recovery is the hard part (OOAnalyzer's whole reason to exist); here the class list is free and the struct layouts fall out of the serializers. Effort shifts to control flow and semantics.
  • Dynamic CRT (MSVCR100/MSVCP100) — FID/FLIRT triage matters less than in statically linked targets; inlined Dinkumware STL is the noise instead.
  • No thunks on the sub-vftables we care about but a large secondary-base offset (+0x3a0) — the this-adjust discipline is needed even without adjustor thunks.
  • Byte-deterministic saves with a documented canonicalisation — a stronger free oracle than most projects had (OpenRCT2 had to build replays; Devilution had none).
  • ~600 static-init config registrations make the data schema recoverable by script before any game-logic RE.
  • Runs on a GPU-less VM via DXVK+lavapipe, so the whole dynamic side is automatable in CI-like conditions.
  • Not a still-sold flagship IP (contrast re3), and we already hold the clean-room split.

4. Gaps in the literature

  • Serializer-driven struct recovery (tag string → xref → Read/Write → layout) is not written up anywhere we found; it is our most productive technique and deserves a stand-alone note.
  • The __thiscall trace-hook trap (calling the original from a C++ detour whose stack purge is unverified) is scattered across Ghidra issues and forum posts; no guide states "asm stub until the prototype is verified".
  • Differential/oracle-based verification for functional (non-matching) reimplementations is thin: OpenRCT2's replay wiki is the only concrete design; nothing covers per-call compare modes, typed trace formats, or float-parity budgeting across x87/SSE.
  • Ghidra's analyzer-vs-script split for RTTI classes confuses many users (issues #3213, #7927, #8896); the official help does not say the analyzer builds no classes.
  • D3D9-on-CPU test rigs (DXVK + lavapipe on a GPU-less VM, legacy D3DX shader-compiler dependencies) are undocumented outside scattered issues.
  • AI-assisted RE evaluation on optimized MSVC C++ is missing: benchmarks (Decompile-Bench, REFORGE) are GCC/Clang-heavy; field reports (Settlers IV) are anecdotal. Nobody has measured how often an LLM mislabels an adjustor thunk, a funclet, or an inlined STL routine — exactly our error surface.
  • Secondary-vftable COL offsets at large distances (hundreds of bytes) are not discussed in the MSVC RTTI write-ups, which show small multiple-inheritance examples.