Decompilation of LEGO Racers (1999) using MSVC 6.0 (Optimizing Compiler, cl.exe 12.00.8168). Modeled after the LEGO Island decompilation.
Two targets:
- LEGORACERS —
LEGORacers.exe(~3986 functions) - GOLDP —
GolDP.dll(~1071 functions)
<path-to-msvc6>\VC98\Bin\VCVARS32.BAT
mkdir build && cd build
cmake .. -G "NMake Makefiles" -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build .
cmake --build .
Portable MSVC 6.0: https://github.com/isledecomp/MSVC600-8168. Double cmake --build works around an NMake bug. Both binaries link against MSVCRT.dll (dynamic CRT, /MD).
pip install -r tools/requirements.txt
# Compare (run from build/ directory)
reccmp-reccmp --target LEGORACERS --print-rec-addr
reccmp-reccmp --target GOLDP --verbose 0x100070b0 --print-rec-addr
# Compare global variable data values
reccmp-datacmp --target LEGORACERS --verbose --print-rec-addr
reccmp-datacmp --target GOLDP --verbose --print-rec-addr
# Progress SVGs
reccmp-reccmp --target LEGORACERS --total 3986 --nolib -S LEGORACERSPROGRESS.SVG --svg-icon assets/legoracers.png
reccmp-reccmp --target GOLDP --total 1071 --nolib -S GOLDPPROGRESS.SVG --svg-icon assets/goldp.png
# Lint annotations (pass source dir to avoid scanning gitignored files)
reccmp-decomplint --module LEGORACERS --warnfail <path-to-LEGORacers>
reccmp-decomplint --module GOLDP --warnfail <path-to-GolDP>reccmp-user.yml (gitignored) points to original binaries for local comparison.
Functions in a compilation unit must be ordered by address (ascending).
// FUNCTION: LEGORACERS 0x00449d50 — complete, compared by reccmp
// FUNCTION: LEGORACERS 0x004164c0 FOLDED — identical code merged by linker (see below)
// STUB: GOLDP 0x10006ff0 — incomplete, skipped by reccmp
// LIBRARY: GOLDP 0x1004b356 — CRT/3rd-party (in library_msvc.h, inside #ifdef 0)
// SYNTHETIC: GOLDP 0x10007040 — compiler-generated (scalar deleting destructors)
// GLOBAL: LEGORACERS 0x004c47e4 — global variable
// VTABLE: GOLDP 0x10056440 — virtual function table
// SIZE 0xc8ac8 — struct/class size assertionThe colon is required for every annotation except // SIZE. reccmp silently ignores // VTABLE MODULE 0x... (no colon); vtable set sites inside ctors/dtors then show as <OFFSET2> instead of ClassName::vftable, costing 5–10% match.
A // GLOBAL: marks the address of the pointer variable itself. If the variable is a char* to a string literal, the data address belongs in reccmp/lego-racers-ascii.csv:
// GLOBAL: LEGORACERS 0x004be8d8
LegoChar* g_jamFile = "lego.jam";Run reccmp-datacmp after adding/modifying globals with non-zero initial values to verify they match.
FOLDED functions. MSVC 6.0's Identical COMDAT Folding merges functions with identical compiled code; multiple source functions end up at one address. Annotate each with // FUNCTION: MODULE 0xADDRESS FOLDED (same address for all folded siblings, same signature and body — e.g. all empty void methods fold together). FOLDED is exempt from address-ascending order and does not need the STUB() anti-folding macro.
// header:
// VTABLE: GOLDP 0x10056440
// SIZE 0xc8ac8
class VelvetThunder0xc8ac8 : public PixelDust0x4 {
public:
virtual ~VelvetThunder0xc8ac8(); // vtable+0x00
virtual void VTable0x04(); // vtable+0x04
// SYNTHETIC: GOLDP 0x10007040
// VelvetThunder0xc8ac8::`scalar deleting destructor'
private:
int m_unk0x04; // 0x04
float m_unk0x08; // 0x08
undefined m_unk0x0c[0x100]; // 0x0c
};
// source:
DECOMP_SIZE_ASSERT(VelvetThunder0xc8ac8, 0xc8ac8)Member offset comments (// 0xNN) and vtable offset comments (// vtable+0xNN) are required.
Gap members. Use a subtraction for gap arrays so the size is self-documenting:
undefined m_unk0x05[0x7dc - 0x05]; // 0x05
undefined m_unk0x92c[0x944 - 0x92c]; // 0x92cOverrides. Use override instead of virtual for derived methods (void VTable0x04() override;). override is defined as empty for MSVC 6.0 in compat.h.
- Bit tests:
if (flags & c_flagCached)/if (!(flags & c_flagCached))— no!= 0/== 0. - Address padding: 8 hex digits, lowercase:
0x00449d50. - Annotation ordering: when a function has both GOLDP and LEGORACERS annotations, GOLDP comes first.
- No redundant
this->. Writem_member,Method(),BaseClass::VirtualMethod()directly. EvenBase::Foo()to suppress virtual dispatch doesn't need the prefix. - Win32 API: prefer un-suffixed names. Use
CreateWindowEx,DEVMODE,MSG,WIN32_FIND_DATA, etc. — NOTCreateWindowExA/DEVMODEA. The un-suffixed names are macros that resolve to theAform whenUNICODEis undefined (project default); the compiled binary still imports theAsymbols. IDA shows the resolved*Asymbol — translate back to the macro. - Pointer/bool constants: use
NULLfor null pointer assignments/returns,TRUE/FALSEforLegoBool/LegoBool32, and plain0only for scalar values and status codes. - Blank lines inside functions: write functions in readable paragraphs. Keep tight sequences of the same kind together (member assignments, descriptor field setup, argument pushes mirrored as calls), but separate declarations/setup, the main operation, and result-handling blocks with single blank lines. If a local result is introduced after setup, put a blank line before it. If an
if/loop block closes and the next statement is areturn, put a blank line before thereturn; returns inside a block after ordinary statements stay tight. Switch cases and tiny one-liner bodies stay tightly packed. - Enums for magic numbers: hoist fixed enumerations (flag bits, event tags, state codes) into a named
enumat class or namespace scope.c_camelCaseper NCC. - No leading
conston return-by-value (const RetType Get() const— meaningless, trips NCC). - clang-format the files you touch (CI-enforced; reflowing is codegen-neutral). NCC naming runs in CI only.
Uses LEGO Island NCC rules (tools/ncc/ncc.style), enforced in CI:
- Functions:
FUN_XXXXXXXX(8 hex digits, lowercase) - Globals:
g_unk0xXXXXXXXX - Members:
m_unk0xXX(by offset) - Parameters:
p_unk0xXX - Unknown classes:
RandomName0xSize(random PascalCase +0x+ hex size, e.g.NeonCactus0x1d6c,VelvetThunder0xc8ac8) - Virtual methods:
VTable0xXX(by vtable offset) - Enum constants:
c_prefix - The
p_/m_/g_prefixes apply to all parameters/members/globals, not just unknown placeholders (e.g. a named hInstance parameter becomesp_hInstance). - Names must match across prefixes when assigned:
m_hInstance = p_hInstance, neverm_hInstance = p_something - Unused parameters must be unnamed in the definition.
Use Lego types from util/types.h for game code:
LegoS8/LegoU8,LegoS16/LegoU16,LegoS32/LegoU32instead ofchar/short/intvariantsLegoFloat,LegoChar(character data),LegoBool
Keep original types at API boundaries (Win32, DirectX, CRT); void* can remain.
Unproven types: use undefined/undefined2/undefined4/undefined4* from decomp.h. Do not guess int/float/void* until usage context or reccmp proves it.
- Find the decompilation and check the byte budget. Read the body; note called functions and globals. The gap to the next function's address bounds the implementation — a rich decompiled body that cannot fit means the function is a thin wrapper and the real logic lives in a callee (often a nearby unannotated address). Disassemble suspicious gaps before writing code.
- Check calling conventions. Per-call-site guesses can differ from the real definition — cross-reference.
__thiscall⇒ class member. - Identify classes from
__thiscallon a global. That global is a class instance; declare a class withundefined m_unk0x00[size]. - STUB every unknown callee. Required for build + reccmp. Stubs ordered by address ascending per file.
- Avoid fold collisions. Use the
STUB(0xADDRESS)macro (fromdecomp.h) in stub bodies to prevent COMDAT folding. Empty destructors are exempt. - Write clean C++, not IDA pseudocode. Translate
*(_DWORD*)(this + 4)into proper member access, method calls, named variables. No gotos, no raw float bit patterns. - Build with double
cmake --build, thenreccmp-reccmp --target LEGORACERS --verbose 0xADDRESS --print-rec-addr. Iterate to 100%. 7b. Verify global data withreccmp-datacmp. - Validate vtables.
reccmp-reccmp --verbose 0xVTABLE_ADDR. Every declared virtual needs a matching// STUB:with its real address from the original binary. - Check for regressions. Re-verify previously matched functions that touch modified classes (especially those making virtual calls).
- Lint.
reccmp-decomplintfrombuild/, passing the source directory as a path argument.
- Every type must be corroborated by matched code. A type is proven only when a
// FUNCTION:using it reaches 100%. Until then,undefined/undefined4. - No raw pointer arithmetic as a substitute for types. Casts + subtractions mean the types are wrong; find the real class so the cast is legitimate C++ (including multi-inheritance cross-casts — define the inheritance, don't fake the adjustment).
- Split mixed compilation units until unexplained address gaps are gone. A large non-FOLDED address jump usually means a function is assigned to the wrong class, or multiple classes are mashed into one source unit. Move whole classes (or cohesive groups) into address-local implementation units; never split one class's methods across
.cppfiles just to tidy address order. Interleaved ranges prove nested-class TUs: when a nested class's methods occupy a range separated from the enclosing class's functions by a different file's range, give the nested class its own.cpp(the type stays nested in the header). - One root type per header. A header should define at most one top-level class or struct; forward declarations do not count. If a second concrete root type is needed, move it to its own header and include that header.
- Nest one-owner helper types. Loader params, callback shims, small related records, and similar one-owner types belong inside the primary class that owns or consumes them instead of becoming top-level classes/structs.
- Ground polymorphic classes. When a concrete polymorphic class is identified, add its
VTABLE, ctor, dtor, and scalar-deleting-destructor annotations instead of leaving it as an unannotated interface shape. - Read the original binary directly (Python/struct/capstone) for vtable entries, call targets, and function addresses when IDA/Ghidra mislabels them. Disassembler dumps stop at the first
ret— a function can continue past it. - Every annotation has a real address — no placeholders.
// FUNCTION:means 100% match. Any diff ⇒ the code is wrong; investigate the root cause (layout, types, missing base).- Re-verify inherited matches. When reviewing a branch or integrating generated code, re-measure every
// FUNCTION:claim with reccmp — claimed matches are not verified matches; demote sub-100% claims or fix them. - Validate vtables explicitly (verbose compare on the vtable address) in addition to function compares — reveals missing/wrong virtual declarations.
- Inline base constructors. MSVC 6.0 inlines small base ctors; their code appears between the outer base ctor call and the derived vtable set.
When starting a new class, match ctor + dtor + scalar-deleting-destructor first. Ctors reveal member init order, base chain (via vtable sets), and types; dtors reveal cleanup order and virtual calls (confirming vtable layout); SDDs verify class size and dtor linkage. Matching these three gives high confidence that size/inheritance/vtable/members are correct before tackling methods.
Register allocation is sensitive to many equivalent-looking source variants. When a function matches behaviorally but registers swap, try:
- Expression folding vs. temp local.
acc += p_str[i] << shift;vs.int v = p_str[i] << shift; acc += v;land the accumulator in different registers, cascading tothisand other hot locals. Keep byte values in byte-typed locals (LegoChar c = p_str[i]; acc += c << shift;). - Redundant member-pointer aliases. Prefer direct member access (
if (m_member) m_member->Call();) overT* tmp = m_member; if (tmp) tmp->Call();— usually identical codegen and closer to human source. Keep the local only for a real snapshot-before-mutation, an iterator, a return value, or an addressable call argument. - Clean argument locals can fix scratch-register diffs. Around COM/virtual calls, a named local for a member argument (
LPDIRECTDRAWSURFACE surface = p_depthBuffer->m_surface;) can route the value through the original scratch register without casts or volatility. - Mutate the parameter; save the original. When a function needs a masked form of
p_argas the hot value and the raw value once (cold), writeundefined4 saved = p_arg; p_arg &= MASK;— not a new local for the masked value. MSVC pins the parameter register to the incoming value; the mirror form inverts scratch registers around indexed accesses (~16pp). Diagnostic: stuck near 80–85% with the diff concentrated on register roles around an indexed access. - Mutate address-taken out-params for follow-up math. When the original stores derived values into the same stack slots as
GetPlacement(&x, &y, ...)out-args, the source reassigned them (x = p_x + i - x;). Address-taken locals are memory-homed, so reassignment emits the store; fresh locals stay in registers, dropping the stores and shortening the function. - Hoist computed values to locals before unrelated stores/copies. When the original interleaves an independent computation (FPU op,
lea, member load) with stores touching a different register file, hoist it:T tmp = compute(); obj->other = src->other; obj->target = tmp;. Diagnostic: an instruction-encoding diff (mov [base+disp]vsmov [reg]) on the first store after alea/expression. If hoisting one helps, hoist all the independent precomputes. - Paired independent stores emit in reverse source order. Two adjacent independent member assignments load both sources first, then store the second statement's target first. If your two stores are swapped vs. the original, swap the source statements.
- Local declaration position controls callee-saved push timing. A local initialized at function entry pushes its register up front; declaring it later defers the push past early returns. When the original uses a callee-saved accumulator (
xor edi,ediat entry;mov edi,1; mov eax,ediat returns), declare the accumulator before the early-return checks and write the special path as a directreturn CONST;. Can also regress — try both positions. - Constant-zero unification. If your build compares via a zeroed register (
xor ecx,ecx; cmp eax,ecx) where the original uses load +test eax,eaxand stores zero-inits as immediates, MSVC unified one zero across an init and a comparison. Reposition the= 0declaration or raise loop register pressure (e.g. a named index local) so the shared zero register stops paying off. - Statement position flips stuck two-register mirrors. Moving a statement across an adjacent block (e.g. an assignment before vs. after an
if) changes a live range's endpoint and can flip a coloring no declaration shuffle fixes. Likewise a mutation appearing before a test that doesn't consume it (lower += p_height;ahead of a skip check) is literal source order — keep it there. - Return type. If the original's epilogue lacks
mov eax, <value>and yours has one, declare the functionvoid(see "Return type inference").
- Start from the natural indexed source. Walking pointers, one-based induction variables, and separately incremented byte/element offsets are usually MSVC strength reduction, not source locals. Begin with
for (i = 0; i < N; i++)and direct indexed uses (items[i].field,i - 1,(i * 2) + 1); introduce per-iteration aliases (T& item = items[i]) or explicit walking pointers only when the indexed form demonstrably fails (real snapshot, stable pointer register, addressable argument). Reifying every observed register in source usually makes the match worse. - Named index local for repeated element access. When the same
m_entries[expr]element is touched several times — especially across a call —LegoS32 index = expr;materializes the strength-reduced byte offset (lea r,[i+i*2]; shl r,2) in a callee-saved register that survives the call, and demotes competing values (cached params, hoisted sums) sothiskeeps its original register. Diagnostic: your build recomputes the index per use, or cachesp_x/p_yin callee-saved regs where the original reloads them from arg slots. - Multiply operand order is visible.
member * exprloads the member into a register first (imul reg, reg);expr * memberemitsimul reg, [member]. The same binary mixes forms function-by-function — read each site. - Loop form.
while (ptr)/do-whileput the condition variable in the first callee-saved reg (ebx), pushingthislater;for (;;)+breakfrees ebx forthis. Loop-back shapetest reg,reg; jne top⇒while (ptr)/do-while, notfor(;;)+break (IDA's odd loop conditions notwithstanding). - Loop-counter scope. Two
for (LegoS32 i = ...)in the same scope are a redefinition under MSVC legacy scoping — declareLegoS32 i;once at function scope and reuse. Sibling branch arms may each declare their own counter; choose by the original's counter homes: arms sharing one stack slot ⇒ a single function-top declaration; distinct homes (slot+restore in one arm, register-only in the other) ⇒ per-armfor-init declarations. for (i = m_count++; i > p_index; i--)folds an increment into the loop init (load old value, compare on it, store old+1). A separatem_count++;statement emitsincplus an explicitnew - 1.sizeOfArray(m_member)for loop bounds — compiles to the same immediate as a hard-coded count, self-documenting.
Small inline-defined class methods are a matching tool of their own — expansions behave differently from equivalent inline expressions:
- Getter vs. direct member access. Promoting
ptr->m_footo an inlineptr->GetFoo()(or the reverse) shifts register choices even though the body is identical. Try both directions. - Control flow evaluated twice in one condition = inline helper. A condition evaluating the same clamp/select twice, branchy both times (
cmp/jle/xorper evaluation, operand masks CSE'd), cannot be a ternary: ternaries compile branch-free (setcc/sbb/shr) and CSE into one evaluation. Write an inline helper with theifinside (LegoS32 GetClampedLower() { LegoS32 v = m_second & 0x3f; if (v > (m_first & 0x3f)) v = 0; return v; }) and call it in each operand — inlined control flow is not CSE'd, and each expansion is branchy. - Branchy 1/0 store through one address = inline setter.
if/elsestatement stores reload the array base per arm; a ternary store compiles toshr. One address computation feeding branchymov byte [r+8], 1/0stores = an inline setter taking the flag byte (void SetOccupied(LegoU8 p_flags) { if (p_flags & 0x80) ... else ... }). - This family also resolves the "branch-free select" dead end: a bare ternary between a literal and zero resists all statement-level forcing (accept the partial match only if no helper fits;
charstorage still recovers amovsxhalf).
else iflayout. A redundantjnewhose flags come from a much earliercmpsignals anelse ifchain — usuallyif (x < N) { main } else if (x == N) { body }, with theelse ifbody physically last.- Cold-branch inversion. A branch body placed after the function's
ret(forward jump +jmpback) was treated as cold; reproduce withif (!X) { hot } else if (cond) { cold }— the naturalif (X) { cold } else { hot }stays inline. Forif (cond) clamp; else center;, write clamp as THEN. - Identical early returns merge only as one statement. MSVC emits a separate inline epilogue per
return CONST;statement; it does not cross-jump them. When the original's early check jumps forward into a later return's epilogue, restructure so both paths flow through a single return statement — e.g.if (height >= 0) { ...error-code returns... } return height;rather than an earlyif (height < 0) return height;. - Merged failure paths have expression-level sources. A retry whose double-failure shares another failure's
result = FALSE;block compiles from assignment-in-condition:if (x == -1 && (x = Retry()) == -1) { result = FALSE; } else { ... }. A check that jumps directly behind another path's cleanup call comes from a comma side-effect:if (count > 64 || (!Initialize(64) && (Clear(), TRUE))) { return FALSE; }. Use these only when the original's jump targets prove them.
mov eax, <literal>in the epilogue ⇒ the function returns that literal; declaring itvoidwill mismatch. OftenLegoS32returning a success sentinel.- Conversely, no
mov eax, ...in the epilogue and IDA still reportsint: the tail-call's return value lingers in eax; the original wasvoid. Flip and the match often jumps from <95% to 100%. - Pointer returned as boolean. Failure epilogues
xor eax,eaxwhile the success path leaves a live pointer in eax ⇒ the function returns the pointer (callers justtest eax,eax). Declare the pointer return type withT* result = thing; ... result = NULL; ... return result;, notLegoBool32. - Float literal assignments may compile to integer
mov.float_member = 1.0funder /O2 compiles tomov dword ptr [offset], 0x3F800000, notfld/fstp.LegoFloatmembers with float literals produce the correct integer stores.
- Vtable set position proves class hierarchy. MSVC 6.0 sets the vtable at the start of the ctor body, after all base ctors. A vtable set that appears after some member init means that init belongs to an inlined base ctor.
- Double vtable set in a ctor = inheritance.
*this = off_BASE; <sub_ctor_calls>; *this = off_DERIVED; <body>— first is the inlined base ctor, second is the derived vtable. Twomov [ecx], &vtablewrites identify the inheritance chain. - Vtable-set-at-dtor-start is orthogonal to virtual-dtor. Any polymorphic class writes its own vftable at dtor entry (so virtual calls during destruction resolve locally), whether the dtor itself is virtual or not. Check the vtable for an SDD slot to determine virtuality.
- Virtual dtor placement. MSVC places the SDD at slot N (the first new slot past inherited ones). Other virtuals declared before
~Derived()in the class body take earlier slots and push the SDD later — declaration order matters. - SDD body pattern.
push esi; mov esi, ecx; call <dtor>; test byte ptr [esp+8], 1; je .skip; push esi; call operator delete; add esp, 4; .skip: mov eax, esi; pop esi; ret 4. When a vtable slot points to this shape, declare a virtual dtor and add// SYNTHETIC: MODULE 0xADDRin the class. A larger body with unrelated sub-calls is a regular virtual (VTable0xNN), not a dtor. delete objvs.obj->SlotNMethod(1)compile to identical bytes (mov edx, [ecx]; push 1; call [edx+SDD_OFFSET]) when slot N is the SDD's slot. They differ only in whether eax is consumed. If the enclosing function uses the SDD's return, write the virtual-call form; otherwise usedelete.- Typed sub-object members auto-emit ctor/dtor calls. Prefer
SubClass m_member;over byte arrays. MSVC emits the sub-ctor in the outer ctor at the member's offset (in declaration order) and the sub-dtor in the outer dtor (reverse order). If the sub-class ctor/dtor has a correct STUB address, reccmp resolves the outer's calls and the outer can reach 100% — verifying the sub-object's offset without implementing its body. This is the primary tool for verifying class layout.
- Cross-TU visibility gates SEH. MSVC emits the SEH frame prologue (
push -1; push __ehhandler; mov fs:[0], esp; ...) in a ctor only if it cannot prove the sub-ctors are no-throw. Sub-classes defined in the SAME .cpp ⇒ compiler inspects bodies (e.g.STUBmacro is a trivial global write → no-throw) and elides SEH. Moving sub-classes to SEPARATE .cpp files hides the bodies and forces SEH emission. - Counter size reflects dtor-bearing member count. MSVC writes a small integer to a stack slot (usually
[esp+0x10]) as each dtor-bearing member is constructed — N+1 states for N members. If the original has 3 counter stores (-1,0,1) but yours has 2, you're missing a member dtor.
mov ecx, X; call Findicates__thiscall— even if F never readsthis. Declare F as a non-static member of the class whose pointer is in ecx; the body can ignorethis. Nomov ecxbefore the call ⇒ NOT__thiscall.- Direct call to a folded empty method = explicit base-scope call.
call <addr>(opcodee8) where<addr>is the landing pad for emptyvoid f()thiscalls (e.g.0x4164C0in LEGORACERS) isBaseClass::VTable0xNN();inside a derived member. The explicitBase::suppresses virtual dispatch. A virtual call emitsmov eax, [ecx]; call [eax+N]— very different bytes. - MSVC 6.0 does NOT tail-call
__cdecl. A trivialint Outer(a,b,c,d) { return Inner(a,b,c,d); }emits a ~30-byte re-push + call + epilogue, notjmp Inner. Only__thiscalltail-calls (~Class() { Release(); }→ 5-bytejmp). Plan for real wrapper + impl when the binary shows this double-dispatch between__cdeclfunctions. - Static member functions emit identical bytes to free
__cdecl. Moving a free function into a class as a static member needs no codegen change; update call sites only. - MSVC 6.0 rejects
__thiscallon function pointer typedefs (C4234). A member-function-pointer workaround emitsmov reg, [vtable+slot]; call reg, not the directcall [vtable+slot]of a named virtual call — when byte-exact match matters, declare a namedvirtualat the slot instead. - Overload by
intvs. typedef'd unsigned int.intandLegoBool32/LegoU32are distinct under overload resolution despite identical storage. Use to split a tiny__cdeclwrapper from a same-signature impl — the wrapper casts to force the overload (without the cast it picks itself and recurses). - IDA's FLIRT can mislabel local functions as STL/MSVCRT symbols. If the name isn't in the PE import table, it isn't external; disassemble the
e8 <rel32>to find the real target. STUB a typed sub-object member at that address and the outer ctor/dtor can match without any STL dependency. - Late
[esp+N]loads near the epilogue may still be the incoming stack arg. Recount pushes/calls before assuming the load is a nearby local struct field; in__thiscallmethods the original parameter can still be sitting above locals after several API calls. - Byte-identical call setup but different
call rel32target = thunk annotation gap. If the pushes and surrounding stores match exactly and only the direct call destination differs, the caller is probably correct; annotate the import thunk inlibrary_msvc.hinstead of distorting the source. __purecallin a derived vtable ⇒ re-pure-virtualize. A derived class can redeclare a concrete base virtual as pure (override = 0), putting__purecallin the derived slot while keeping the base body reachable via explicitBase::Method().- Derived-override tail calls to base: trace the spill. When a derived override ends in
Base::Method(...), check what the original pushes at that arg slot. A stack-spilled local in the original versus a literalFALSE/0in your source collapses the match. - Inferring signatures from push sequence. Arg count = number of
pushbefore the call; right-to-left push order = first-to-last arg. A caller push with no matching param means your signature is short. Mid-sequencemov ecx, [argslot]beforecallreveals a__thiscalltarget and which arg is itsthis. For callback interfaces tightly coupled to one outer class, prefer a nested class (Outer::Callback).
- Walking-pointer base reveals true struct layout. When iterating an array of structs, MSVC seeds the walking pointer with
lea reg, [this + K]where K centers on the hottest access region. A uniformΔ-byte shift between your[reg+disp]and the original's means your struct base is wrong byΔbytes — shift the array declaration and pad the struct head to preserve size. - Out-of-range member access reveals true struct size. A method accessing
[this+N]at or past the believedSIZEmeans the struct is larger — typically absorbing an adjacent "unknown" member of the enclosing class. Resize the nested struct and delete the parent's member; ctors/dtors that never touched the new field keep matching. - Stream cursors are members with helpers. Serialization spread across methods keeps the cursor as a member (
LegoU8* m_cursor) with small read/write methods (ReadU16/WriteU16) rather than passing a pointer around. Byte reads promote through two locals —LegoU8 b = *m_cursor++; LegoS32 x = b;— emitting byte-slot store + immediate dword reload +and 0xff. A singleLegoS32local instead compiles toxor reg,reg; mov bl,[..]; a singleLegoU8local defers the masks to the use sites. Diagnostic: one logical value with two stack homes = two locals. - Fused memset across adjacent members of different types. One contiguous zero-store spanning two members won't come from two
memsetcalls — use one call sizedsizeof(first) + sizeof(second)with a short comment, or nest the members in a struct. ZeroMemorycan beat aggregate init for Win32/DirectX structs.Type s; ZeroMemory(&s, sizeof(s)); s.dwSize = ...;can match stack-store order that{0}and field-by-field zeroing miss.- Small fixed-size
memcmpinlines into pointer loads + scalar compares; a hand-cast*(LegoU32*)a != *(LegoU32*)bcompiles to direct global memory operands instead and misses. Don't widen an existing global buffer for scratch space — its declared size perturbs register allocation in other users. - Preventing unwanted ICF with
#pragma code_seg(temporary hack). Symptom: reccmp "Failed to find function symbol" + a derived vtable slot pointing at a fold survivor. Wrap the function in a uniquely-named section (#pragma code_seg(".text$unique_suffix")...#pragma code_seg()) with a// TODO:note. Do NOT use for functions the original itself folds — those stayFOLDED.
A function in common/src/ compiled into both targets can need different fold groups per target — e.g. a trivial return 0; that folds with empty STUBs in one target but stays independent in the other. Wrap STUB() in #ifdef BUILDING_GOL and annotate both targets:
// FUNCTION: GOLDP 0xAAAAAAAA FOLDED
// FUNCTION: LEGORACERS 0xBBBBBBBB
LegoS32 Class::Method()
{
#ifdef BUILDING_GOL
STUB(0xAAAAAAAA);
#endif
return c_successCode;
}In GOLDP the STUB(...) write makes the body match other STUB(0xAAAAAAAA) and they fold; in LEGORACERS the macro is gone and the standalone xor eax, eax; ret matches at its own address.
common/src/ is compiled as separate object targets for GOLDP and LEGORACERS. This lets project-level compiler settings express target-specific codegen while keeping one idiomatic source implementation.
Current pattern:
common_goldpcompilesCOMMON_SOURCESwithBUILDING_GOLand/Ob2.common_legoracerscompiles the sameCOMMON_SOURCESwithBUILDING_LEGORACERSand the default inline behavior.goldpandlegoracerslink the corresponding common object target.GOLDP_INLINE_EXPANSION_SOURCESis a small evidence-driven list of GOLDP-only source units compiled with/Ob2. Treat it as a placeholder for a possible original static library or project grouping; this is not proven yet. Keep the list narrow, and do not broaden/Ob2to the fullgoldptarget — that changes unrelated GOLDP codegen.
Do not steer common-code matches by duplicating implementations, moving selected functions into headers, adding .inl.h files, or using #pragma inline_depth. If a common function differs between GOLDP and LEGORACERS because of inlining, first verify whether it belongs to this target-level common-source pattern.
A member name is proven when a // FUNCTION: match forces a specific semantic interpretation — e.g. a member that is both count++ inside a loop bounded at 20 AND stored into g_fileSourceCount is unambiguously a file-source count. Rename the m_unk0xNN placeholder once a match corroborates usage. Member types still follow the undefined/undefined4 rule.
Rename from VTable0xNN / FUN_XXXXXXXX to a semantic name when evidence is strong:
- Clear pair with a named counterpart.
VTable0x20callsm_input.Shutdown(); adjacentVTable0x1ccalls the input-init helper with hInstance/hWnd →VTable0x1cisInitializeInput. - Symmetric with a named method.
VTable0x10undoes every actionInitialize()took and is also~Class()body →Destroy. - Body leaves no interpretation. A one-line tail call to
InitInput, or a loop opening every line of a newline list and publishing them as globals.
Do not rename on weak evidence. If multiple plausible names exist (OpenFileSources vs. LoadFileSources vs. RegisterFileSources), keep FUN_XXXXXXXX. A misleading name is worse than a neutral placeholder. Renaming a virtual does not affect codegen — the vtable is slot-indexed — so it's safe if all call sites and overrides are updated together.
Lifecycle vocabulary. Match existing class method names so the whole codebase shares one vocabulary:
Initialize()— explicit init separate from the ctor (ctor zeroes fields,Initializeallocates/loads/registers).Run()— the main loop / per-instance driver (top-level app/game class).Shutdown()— release live resources but leave the object reusable (SoundManager,CobaltMist0x30,OpalVault0xf0,IndigoStar0x18,GolHashTable).Destroy()— full teardown: invokesShutdownplus everything else, leaving the object in its post-construction state.Reset()— return to pristine zero-state (shared between ctor/dtor bodies, e.g.CrimsonForge0x800::Reset).
When a class has a small subsystem-teardown method AND a larger full-destroy wrapper, the small one is Shutdown and the big one is Destroy — even if it means renaming a previously-named Shutdown to Destroy. Don't invent Cleanup / Teardown / StopServices.
LEGORacers/ # LEGORacers.exe source
include/ # Headers
src/ # Source files
GolDP/ # GolDP.dll source
include/ # Headers
src/ # Source files
GolDP.def # DLL exports (GolEntry, GolExit)
library_msvc.h # CRT library annotations
util/ # decomp.h, compat.h, types.h
cmake/ # reccmp CMake integration
data/ # Original binaries and decompilation data (gitignored)