Ported from RP412: RIOBase split out of the serial RIO (L4RIO.h), rioPointer is RIOBase* (L4CTRL.h), PAD token -> new PadRIO() speaking the RIO surface from an XInput pad + keyboard (L4PADRIO/L4PADBINDINGS, vRIO bindings.txt grammar, hot-plug), KeyLight RGB mirror TU (BT412KEYLIGHT, /std:c++17 per-file). BT-side fixes PadRIO forced into the open: - Both keyboard input bridges (mech4.cpp, mechmppr.cpp BT_KEY_BRIDGE) stand down when a RIO device exists -- they overwrote the engine controls push every frame. M/X conveniences stay live. - Mapper attribute chain OFF BY ONE (latent real-pod bug): the DOS chain below MechControlsMapper carried two base attributes, WinTesla carries one, and AttributeIndexSet::Find is positional -- the .CTL stick mapping wrote throttlePosition. Pad slot + binary-locked enum; gotcha ledgered (reconstruction-gotchas #11). Verified: PAD throttle lever ramps + sticks, stick turns with the authentic speed-vs-turn clamp (61.5 -> 22.0 u/s), mech drives; keyboard fallback intact (BT_FORCE_THROTTLE harness). New diags: BT_CTRLMAP_LOG, BT_STICK_LOG. (Phase 2 of docs/BT412-ROADMAP.md) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
27 KiB
id, title, status, source_sections, related_topics, key_terms, open_questions
| id | title | status | source_sections | related_topics | key_terms | open_questions | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| reconstruction-gotchas | Reconstruction Gotchas — the systemic bug classes (check these FIRST) | established | CLAUDE.md §5a, §10c; docs/HARD_PROBLEMS.md; docs/RESOURCE_AUDIT.md; gauge-wave notes |
|
|
|
Reconstruction Gotchas
The reconstruction is a layout + linkage problem as much as a logic problem. Our compiled
classes are NOT byte-identical to the 1995 binary, and the BT link uses /FORCE, so a whole
family of bugs is silent — garbage that happens to be non-fatal, or a runtime AV with no
link error. When a reconstructed class misbehaves, walk this checklist FIRST; the answer is
usually here, not in the logic.
The core rule (RULE: no stand-ins): the full game logic IS in the pseudocode — a "gap" is a reconstruction stub not yet filled, never a hole in the original. Never write placeholder logic for an apparent gap; read the decomp. (User: "there are no gaps, just work to be done.") Bring-up scaffolding (the
BT_AUTOFIRE/BT_AUTODRIVE/BT_GOTOenv harness; historically explosion-for-beam, since replaced by real per-weapon beams) is clearly marked and meant to be REPLACED, never to substitute for reading the decomp. [T2]
1. Shadow field — re-declaring an engine-base field (THE most common)
Symptom: a field reads 0xCDCDCDCD (fresh-heap fill) even though the ctor "sets" it; or an
object over-sizes past its factory alloc. Cause: the reconstruction re-declared a field the
engine base already owns, at the binary's offset. Two failures: (a) the copy shadows the
base — the engine ctor writes the base field, the reconstruction reads its own uninitialised
copy; (b) it lands at a different offset than the binary assumed.
Fix: delete the re-declaration; use the inherited member/accessor. Examples: Mech
damageZoneCount/damageZones (shadowed Entity's → zones never built); Mech__DamageZone
structureLevel→engine damageLevel; the whole HeatableSubsystem de-shadow
(statusFlags→simulationFlags, destroyed→simulationState, statusBits→ForceUpdate()).
[T2]
2. Wword(N) — an ABSORBER, not storage (state cached there VANISHES)
mechrecon.hpp:226 defines Wword(int i) as static BTVal bank[0x400]; return bank[i&0x3ff],
and BTVal is the recon absorber type: operator= stores NOTHING, every read converts to
T() (zero), and ALL comparisons (== and !=, vs BTVal or int) return false. Consequences: [T2]
- Any state CACHED via
Wword(N) = xsilently vanishes; the later read is always 0/null. Archetype: the STEP-6 cylinder table was "cached" atWword(0x111)→ the unaimed TakeDamage path was totally inert (every hit no-op'd, "can't kill the enemy") while the ctor log looked fine. Fix = a real named member (Mech::damageLookupTable). If a Wword slot must hold real state, PROMOTE it to a named member mapped to that binary offset — checkmech.hpp's offset map first (the slot may already exist under a best-effort mislabel; 0x111 was mislabeledammoExpended). if (Wword(a) != Wword(b))andif (Wword(a) == 2)are BOTH always-false → the guarded branch is dead code. (The archetype sites — the replicant state sync in Mech:: ReadUpdateRecord — were REVIVED by the task #1 update-record reconstruction, 2026-07-11: every Wword in that path is now a named engine/port member; theWword(0xf)/(0x10)comparisons weresimulationState.oldState/currentState.)- Any
Wword(N)used for OBJECT access reads a shared global, notthis+i*4; e.g.(Mech*)Wword(3)for a zone's owner → garbage; useGetOwningSimulation().
Sweep recipe: grep -nE "\((int|void|[A-Z]\w+) ?\*\)\s*Wword\(|if \(Wword\(|Wword\([^)]+\)\s*(!=|==)" —
every hit is either dead code or a vanished cache.
3. Databinding trap — raw offsets read garbage
Our compiled layout != the 1995 binary, so *(T*)(obj+0xNN) reads garbage for ANY object we
compile. This is WHY shadow fields fail and why raw subsystem reads (e.g. a gauge reading
owner+0x438) return junk. Fix: use compiled named members/accessors; for a cross-TU raw
op, use a bridge (§8). A +0x128-style owner offset in subsystem code is the
subsystem-roster (subsystemArray), NOT the segment table — check every GetSegment(int)
in a reconstructed subsystem ctor. [T2]
4. Resource-struct layout mismatch (sibling of the shadow bug)
Symptom: RESOURCE fields read garbage (silently — a heatSinkIndex reading 10.0f).
Cause: *__SubsystemResource structs overlay pre-built 256-byte records loaded VERBATIM at
fixed offsets, so OUR struct must match the binary byte-for-byte. Breaks two ways: (a) wrong
inheritance base (the resource must mirror the CLASS hierarchy — HeatableSubsystem's resource
inherits MechSubsystem__SubsystemResource 0xE4, not Subsystem::SubsystemResource 0x30); (b)
under-sized fields (a 12-byte record field typed as a 4-byte ResourceID). Diagnose: log
compiled offsets (char*)&res->field - (char*)res vs the binary's; dump raw record bytes as
int+float. Lock: static_assert(offsetof(...)==0xNN) + static_assert(sizeof(...)==0xNN).
The 33-agent RESOURCE_AUDIT fixed 8 such bugs (docs/RESOURCE_AUDIT.md). [T2]
5. Alias / phantom / interior fields (object layout)
- Alias field: a subclass member re-declaring an inherited slot the ctor reuses under a new
name (Condenser
refrigerationOutput==inheritedmassScale@0x160; EmitteroutputVoltage==rechargeLevel@0x320). → delete, use the inherited name. - Alarm-interior field: a value the binary reads at
alarm+0x14modeled as a separate member (HeatSinkheatState@0x184==heatAlarm.GetLevel()). → route to the accessor. - Phantom field: a member at an offset PAST the object (Generator
shortFlag@0x25Cis really*(owner+0x190)+0x25cthe msg-manager). → remove; read the real source. - Shrunk-span array: a binary FIXED-SPAN block declared as
member[1]("variable length" comment) — every write past slot 0 stomps the members declared after it. Archetype:MechControlsMapper::pilotArray— the binary reserves 0x15C..0x183 (10 slots); the[1]declaration let MP'sFillPilotArraywrite the PEER'sPlayer*overcontrolMode("can't turn in MP": turn shaping dispatched on pointer garbage →turnDemand=0), MASKED in solo because the overrun wrote 0 == BasicMode. → size the array to the binary's inter-member span ((next-member offset − array offset) / stride) + clamp the fill loops. Caught live with cdbba w4on the compiled member address (log&memberfrom the ctor, offset delta gives the compiled position). [T2]GaugeAlarm54= 0x54 (the realAlarmIndicator; STATUS level at +0x14, sosubsystem+0x184 == heatAlarm+0x14 == GetLevel());SubsystemConnection= 0xC. [T1]
6. /FORCE trap — unresolved symbol → runtime AV, not a link error
/FORCE turns an unresolved external (or a prose-only vtable slot) into a runtime AV near
__ImageBase, NOT a link error. When a /FORCE build crashes with a garbage call target
near the image base, grep the link log for "unresolved external" — the "successful" build is
lying. Corollary: a bridge fn / a .data fn-ptr callback MUST have a real (stub) definition.
A SetVideoPathPriority defined in an anonymous namespace → internal linkage → unresolved in
another TU → stubbed by /FORCE → AV in LoadMissionImplementation. [T2]
Signature-change corollary (user-hit crash 2026-07-12): changing a shared free-function
bridge's SIGNATURE changes its mangled name — every OTHER TU's local extern decl now
references a symbol that no longer exists, /FORCE tolerates it, and the crash lands on the
first call from the un-updated TU (the missile-arc wave updated BTPushProjectile +
mislanch.cpp's extern but not projweap.cpp's → first AUTOCANNON shot AV'd). Rule: after any
bridge-signature change, grep -rn "extern .*<name>" and update every declaration; then grep
the fresh link output for the symbol name — the pre-existing LNK2019 wall camouflages new
entries if you only eyeball it.
7. Dtor-epilogue rule — do not reconstruct compiler glue
In a decompiled DESTRUCTOR, the trailing member-dtor calls (FUN_xxx(this+N, 2)), the base-dtor
call (FUN_xxx(this, 0)), and the (flags&1) && operator delete(this) tail are COMPILER GLUE.
Reconstruct only the body ABOVE them; C++ re-emits member+base destruction at the closing brace.
An explicit base-dtor call runs the whole ~JointedMover → ~Mover → ~Entity chain TWICE =
the P5 double-free (re-delete[]s collisionLists, re-runs DeletePlugs over the freed segment
table). ONE bug = BOTH the death-row crash AND the app-exit crash. [T2]
8. Bridges — the databinding-safe escape hatch
When TU A needs a raw-offset-safe op but its local RECON stubs collide with the real class
headers, put the op in a bridge: a free function in a complete-type TU, extern-declared in
A. Examples: BTResolveWeaponMuzzle (mech4.cpp — a complete-Mech TU with the segment API),
BTRecomputeCondenserValves (heatfamily_reslice.cpp — sees Condenser), BTResolveMessageBoard
(btplayer.cpp — complete BTPlayer), BTGetSubsystemAuxScreen (powersub.cpp — casts through the
real PoweredSubsystem). Keep the alloc SIZE + special-cache when swapping a factory case. [T2]
9. Message-handler chaining + entity validity
-
A reconstructed class's
MessageHandlersset must be built chained to the parent's (Receiver::MessageHandlerSet(Entity::GetMessageHandlers())). An empty default-ctor set has no parent chain →Receiver::Receivefinds no handler → every inherited message (TakeDamage!) is silently dropped. [T2] -
Entity validity gates message delivery on BOTH paths, and an unvalidated entity drops everything.
Entity::Dispatchdelivers synchronously only for a VALID master (invalid →Post(EntityInvalidEventPriority), which does re-fire); but a message that arrives as an EVENT —Entity::Receive(Event*), ENTITY.cpp:165, e.g. any Posted or cross-pod-delivered message — doesif(!IsValid()) event->Defer(), and the deferred queue never re-fires until the entity becomes valid. A manually-spawned OR network-created entity (the port's MakeReady/CheckLoad handshake is a partial impl) must callSetValidFlag()itself — else EVERY message defers forever. Force-validate atMake(the reconstructed ctor builds the entity synchronously). Hit by: the spawned dummy, replicants, AND — task #47 — a peer's own MASTER mech: cross-pod TakeDamage reached B, resolved to B's real mech, thenEntity::Receivesawvalid=0and deferred it forever → 0 damage. Fix =Mech::MakesetsValidFlagfor the master too (mech.cpp), not just replicants. [T2] -
Never send a NON-Entity message through
Entity::Dispatch.Entity::Dispatch(ENTITY.cpp:236) unconditionally stampsmessage->entityID/interestZoneIDat the Entity::Message field offsets (after Receiver::Message's 12-byte header). ANetworkClient::Message(the consoleConsolePlayer*Messagefamily) has no such fields and is SMALLER — those stamps write PAST the object. On a stack-allocated console message that is an/RTC1stack-guard overflow →_RTC_StackFailure→ abort (caught on the respawned player's first score flush, task #52). Console/network messages go over the stream:application->SendMessage(host->GetHostID(), NetworkClient::ConsoleClientID, &msg)(which forwards tonetworkManager->Sendwith no entity stamping) — mirror the working VTV-damaged push inScoreMessageHandler, don't call the player'sDispatch. (Entity::Dispatch'smessageID < Receiver::NextMessageIDearly branch does NOT save you — it lacks areturn, and the console IDs aren't in that range anyway.) [T2] -
MESSAGE_ENTRY tables must be FUNCTION-LOCAL statics inside the GetMessageHandlers() accessor (task #12). A namespace-scope
HandlerEntry MessageHandlerEntries[]can be read by ANOTHER TU's static-init chain (DefaultData -> accessor -> Build) before its own TU's dynamic initializers run -- Build copies ZEROS, and every id in that table is silently dropped at dispatch (the set LOOKS built; ids added later in the chain still work, which hides it). Symptom: message transmitted, handler never runs, no error. The engine's own APP.cpp idiom (table + set both function-local in the accessor) is init-order-proof -- always use it. Related trap: the dense handler table (Build indexes slots by id-1) leaves GAP slots (skipped ids) as uninitialized heap -- the NAME-basedFind(const char*)strcmp-walks every slot and AVs on a gap's garbage entryName (the id-based Find is safe). The 1995 binary's own tables carry the same holes. [T2]
10. Container-Execute must override (gauges)
The 2007 engine Gauge::Execute base is Fail("not overridden") → abort() (GAUGE.cpp:598);
GuardedExecute's SEH cannot catch abort(). So a container/parent gauge MUST override
Execute (even as a no-op) AND override BecameActive with a non-inactivating body (the
default GaugeBase::BecameActive inactivates). A GraphicGaugeBackground-derived widget
(PrepEngrScreen/BackgroundBitmap) has NO Execute virtual → the hazard doesn't apply; there the
overridden slot is BecameActive. [T2]
11. Dense-table hazard (attribute publishing)
AttributeIndexSet::Build leaves gap slots uninitialized and Find strcmps EVERY slot → a
published attribute table MUST be a dense prefix from the parent's NextAttributeID; a gap
AVs. Fill gaps with a shared read-only pad member. Same for a class's <Name>AttributeID enum.
[T2]
PROVEN LIVE (task #16): a gap does NOT necessarily crash immediately — the garbage slot's
entryName may happen to point at readable heap, so a gapped table can "work" for weeks (the
MechWeapon table's 0x0D..0x12 gap shipped in task #5 and survived on heap luck). ANY change
that reshuffles allocations (the task-#16 renumber did) can then fire the latent AV — observed
as AttributeIndexSet::Find crashing in WeaponCluster::WeaponCluster("PercentDone"). A
"passes the run" verification does NOT clear a gapped table; grep every pinned
<Name>AttributeID = 0xNN and check the parent chain actually reaches 0xNN-1, else pad
(mechweap.cpp now static_asserts its pad base against PoweredSubsystem::NextAttributeID).
[T2]
The inverse trap — SHORT parent chain (BT412, 2026-07-14): Find(ID) is positional
(attributeIndex[ID-1]), so when the WinTesla parent chain carries FEWER attributes than the
DOS binary's did, every child attribute resolves one slot EARLY relative to a 1995 stream's
numeric IDs — no crash, values silently land in the WRONG MEMBER. Proven live the first time
a real device drove MechControlsMapper: the binary's .CTL control-mapping stream uses
stick=3/throttle=4 (the DOS Subsystem chain had 2 base attrs), the WinTesla chain has 1
(SimulationState), so the stick mapping wrote throttlePosition and the throttle mapping
wrote pedalsPosition. Only the numeric-ID path (CreateStreamedMappings / .CTL) is
affected — gauge databinding resolves by NAME and never saw it. Fix: a named pad slot at the
front of mechmppr.cpp AttributePointers[] + the enum locked to the binary's values.
Diagnostic: BT_CTRLMAP_LOG=1 dumps each streamed mapping's resolved pointer — compare against
&mapper->member (the mech4 [mppr] log prints &stick/&thr). [T2]
12. Frame-pacing trap — the binary assumes a LOCKED 60 fps (task #11)
The 1995 pod ran frame-locked; reconstructed per-frame logic can carry HIDDEN frame-rate
assumptions that variable dt violates. Archetype: the Emitter Loading tick — the charge
integrates toward the generator's 10000V and the Loading→Loaded transition only fires while
rechargeLevel crosses the ±0.01 snap window around seekV (~0.25s of travel ≈ 15 pod frames —
never missed at 60 fps). One port dt-spike (0.24s observed live) jumps the whole window; the
byte-verified >1.0 overshoot clamp (_DAT_004ba830 = 0.0) then zeroes readiness and the weapon
is PERMANENTLY bricked in Loading at level ~10000 (user-visible: "weapons cut out one by one").
Big steps also corrupt integrals evaluated at stale state (the I²R generator feed overheated).
Fix pattern: pod-frame sub-stepping — run the binary's own tick verbatim inside a
while (remaining) { slice = min(remaining, 1/60); … } loop (bounded; leftover time resumes
next frame). This reproduces the pod's exact trajectory instead of redesigning the logic.
Suspect ANY reconstructed per-frame code with narrow equality/window tests or x == 1.0f
state transitions: charge/seek loops, snap comparisons, timers compared with ==.
13. Verification gotchas (don't fool yourself)
- Lazy gauge build:
GaugeRenderer::BuildConfigurationFileruns LAZILY. A too-early process kill shows[gskip]=0/ "not built" even though the widget is fine — wait for the gauge window before concluding. (Cost a long detour this session.) [T2] - ReconStream is a no-op:
btl4gau3.cpp'sDebugStreamis theReconStreamwhoseoperator<<is{ return *this; }— it DISCARDS everything. Use the engineDEBUG_STREAM(what heat.cpp uses) for a log that reaches the BT_LOG file. [T2] - Head-on repro hides intermittent bugs: a straight ram gives 1 clean result; the bug shows on GLANCING/sliding/rough-terrain contact. Reproduce with an angled/terrain-crossing approach. [T2]
static_assertnot runtimeCheck: a runtimeCheck(sizeof<=alloc)in a factory bridge does NOT fail the build (it's a runtime assert → heap overflow at construction). Use a compile-timestatic_assertsizeof lock. [T2]- Engine-class new member: a NEW member on a 2007 engine class (d3d_OBJECT, DPLRenderer) MUST
be initialized in EVERY ctor init-list (debug heap fills 0xCDCDCDCD → an uninit flag reads
TRUE); and any device state a special draw path sets must be save/restored exactly. Deleting
stale
.objs fixes layout-mismatch corruption when a base class grows. [T2] - Status alarm is not a latch: gauge/status alarms (
graphicAlarmetc.) are INDICATORS whose level later events legitimately REWRITE (a leg hit on a wreck rewrites 9→4/3). A predicate likeIsMechDestroyed = alarm>=9un-latches → the wreck "resurrects" and the death transition re-runs (double score, abort in the respawn window). Latch on the state machine's own mode (movementMode 2||9); use the alarm only as the entry TRIGGER. (Task #52.) [T2] - Engine
Check/Verifyare ACTIVE in MUNGA TUs: a NULL hitting an engineCheck(ptr)is an ucrtbased abort() dialog ("Debug Error!"), not an AV —sxe avwon't break there; the box blocks the event loop (a headless node just "stops logging"). cdb: run with a config that doesgthenkb 40— the int3 lands ON the aborting thread. [T2]
13. Accumulated-time precision collapse (rate × absolute-time in a matrix)
Any matrix element (or coordinate) computed as rate × absolute_runtime grows without bound. Float
has ~7 significant digits, so once the value is large its FRACTIONAL precision is gone — and if that
value is then added to a per-pixel/per-vertex quantity, the result quantizes into coarse steps. The
visible signature is a smooth field shattering into grainy stair-steps or radial "spokes" that get
worse the longer the app runs (and are invisible right after launch).
- Archetype (the translocation-warp spokes):
L4D3D::SetTextureScrollingset a texture-matrix translate_31 = -scrollUDelta * targetRenderFrame(targetRenderFrame = absolute time). Within seconds the UV offset was large enough that adding it to the per-pixel UV collapsed precision → the scrolled cloud rendered as radial grain. It degraded EVERY scrolling texture (beamsbexp, exhaust), but the full-screen warp on black made it obvious. Fix: wrap into the periodic range —fmodf(rate*time, period)— identical under REPEAT tiling / rotation, but full precision. Prefer delta-time accumulators that you wrap each frame, overrate*absolute_time. [T2] - Tell from a symptom: if a smooth animated/scrolled surface looks progressively grainier or "steps" and a STILL/offline render of the same data is clean, suspect an unwrapped time accumulator, not the geometry, texture, or filter. (Cost most of task #52's visual effort — see translocation-warp.)
14. Hand-rolled LookAt / axis-convention guess (camera reconstruction)
When the original forms a view/camera matrix by inverting a composed world transform, any port reconstruction that instead extracts "forward/up" ROWS and feeds a LookAt has silently HARD-CODED an axis convention (+Z=forward/+Y=up) the engine never promises. It works for meshes/segments that happen to match and aims into geometry for the rest — a per-asset-random bug that looks like bad data, not bad code.
- Archetype (the cockpit eye, task #55): the binary's per-frame camera is
VIEW = affine_inverse(eyeWorld)(FUN_004c22c4 → FUN_0040b244); ourDPLEyeRenderable::Executehand-builtD3DXMatrixLookAtRH(pos, pos+row2, row1)— some mechs looked out, others into the canopy. Fix: compose the full eye world matrix and invert it; the axes fall out of the basis. [T1] - Tell: the same camera code behaving differently per mech/asset. Also check BOTH copies of duplicated ctor/Execute code — the dead one can mislead you about which path is live (our ctor had the CORRECT multiply order but its view write was commented out; the live Execute had it inverted).
15. Per-patch index namespaces (mesh connectivity analysis)
BGF face indices are LOCAL to their vertex chunk/patch. Any cross-patch analysis keyed on raw index tuples (edge counting, adjacency, dedup) silently MERGES unrelated edges from different patches and corrupts the metric.
- Archetype: BLX_COP boundary-edge ratio measured 7% ("closed shell") with a global edge Counter → the whole "closed vs open canopy" theory. Correct per-patch namespacing gives 59% — ALL 12 canopies are open lattices. Namespace edge keys by patch identity (and remember l/r patches are MIRRORED — winding handedness flips, so no global winding choice can be right; orient per-face).
16. Engine-facility drift: 2007 terrain-solids amplify 1995 per-contact physics (the MP ram one-shot)
Mover::StaticBounce [T0] MUTATES worldLinearVelocity (+= delta_v, a ×(1+e) reflection) on
every call, and ProcessCollisionList calls it once PER CONTACTED SOLID in the frame. In the 1995
binary the ground was a heightfield probe (FUN_0040e5f0 lineage) — never a collision-list entry —
so a mech's list held ~one solid and the mutation was harmless. The 2007 WinTesla engine models
TERRAIN AS COLLISION SOLIDS: a mech touching ground + rock + another mech reflects 2-4× in ONE
frame, compounding velocity ×4-×40, and the mech-vs-mech damage dispatch later in the list priced
ram damage off the amplified value — a 62-point bump economy produced 1,074- and 112,375-point
one-shots (mp_a.log:32651, 2026-07-12: a pristine mech killed by a walking bump). [T2]
- Fix pattern: snapshot the TRUE frame-entry motion (
frameEntryWorldVelocity, set beside theProcessCollisionListcall sites) and restore it at the top of everyMech::ProcessCollision— each contact prices damage at the mech's real approach speed, which is all the binary's StaticBounce ever saw. The post-list velocity is discarded anyway (frame-rejection response / next frame's position-delta derive). Also:Mech::Resetmust zeroworldLinearVelocity+localVelocity(respawn is a TELEPORT; stale death-frame motion must not survive it). - Tell: damage amounts orders of magnitude outside the weapon economy (weapons 3-12/hit, rams
~13-62), CONSTANT repeated values (a stable grind oscillation), or spikes scaling with how many
solids surround the contact. Damage =
0.0005 × (1−e²) × impact² × moverMass[T0 MOVER.cpp] — invert it to read the implied impact speed; >100 m/s means amplified/garbage velocity, not motion. - Class rule: when a 1995 per-event computation reads MUTABLE engine state, audit what ELSE the 2007 engine feeds that state within the same event batch. (Family of gotcha #12's frame-pacing trap: the binary's physics assumed its own engine's event granularity.)
17. Engine-helper identity: verify the FUN_ body, not its call shape (the empty-radar bug)
Two adjacent matrix helpers in the radar's DrawDisplay were transcribed by CALL SHAPE and both were wrong — producing a plausible-looking but broken world→view transform that drew every pip hundreds of pixels off-scope (the radar looked simply "empty"; nothing crashed, nothing warned):
FUN_0040b244(dst, src)read as a COPY — it is the full affine INVERSE (cofactor expansion + determinant divide, part_001.c:172). →worldToView.Invert(view).FUN_0040adec(matrix, quat)read as a COMPOSE (view *= yaw) — it writes ONLY the 3×3 rotation elements and NEVER touches [3]/[7]/[11] (the translation row). The engine'soperator*=(Quaternion)composes fully (rotates the translation too) — the pre-set center got corrupted BEFORE the invert. → rotation-only assignment first,SetFromAxis(W_Axis, center)LAST. [T1 both, verified live: blip at exactly |delta|·ppm px after the fix]- Tell: a transform chain whose output is self-inconsistent — check whether the matrix maps its own reference point where it must (here: the viewer's position → the scope origin; it mapped to (−54, −599)). One logged matrix dump falsifies the whole chain in one frame.
- Rule: for ANY engine-helper FUN_ in a reconstruction, read its BODY once (a 30-line decompile) before assigning it an engine method — a wrong-but-plausible identity survives every compile and every "it runs" test.
Diagnostic recipe (the standard loop)
- Read the RAW decomp
reference/decomp/all/part_*.cfor theFUN_xxxx. - Map
FUN_/DAT_/this+0xNNto engine symbols via BT headers + WinTesla MUNGA source +CLASSMAP.md+ RP's parallel code. - Write the REAL reconstruction;
static_assert-lock the layout. - Build; run env-gated; read
btl4.log; cdb on any crash (0xCDCDCDCD=uninit, 0xFEEEFEEE=freed). - For exhaustive multi-function analysis: a read-only Workflow (understand), then implement hands-on.
Key Relationships
- Applies to: every topic that reconstructs a class (subsystems, combat-damage, gauges-hud, locomotion).
- Uses: decomp-reference (offsets/ClassIDs), reconstruction-method (the loop).