Files
BT411/context/reconstruction-gotchas.md
T
Joe DiPrimaandClaude Opus 5 c23d06cbb5 KB: correct the burstCount claim -- it is honoured by the CALLER, not cosmetic
The KB asserted as [T1] that "burstCount is cosmetic for zone damage".  Half of
that is byte-verified (DamageZone::TakeDamage @0041e4e0 really does ignore it);
the other half was an inference that inherited the [T1] tag and then justified
task #62's salvo-lead design -- which silently divided every missile salvo by its
missile count (gitea #95).

Corrected in both places it appeared (combat-damage.md:334 and :884): the CALLER,
Mech::TakeDamageMessageHandler @0x4a0423-0x4a04d8, applies TakeDamage burstCount
times and re-rolls the struck zone per burst.  burstCount = number of
applications; load-bearing for missile cluster count, splash falloff and the gyro
bounce.  Also records that the arcade Missile dispatches DIRECTLY at the victim
rather than through the message manager (whose consolidation drops burstCount).

New gotcha 24: a verified fact, over-generalised, becomes a wrong design premise.
Detection smell -- a "cosmetic/unused" claim about a field other code still
computes carefully.  Nobody spends instructions randomising a decorative value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 04:43:34 -05:00

57 KiB
Raw Blame History

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-method
decomp-reference
subsystems
combat-damage
gauges-hud
shadow-field
databinding-trap
Wword-trap
FORCE-trap
dtor-epilogue
bridge
attribute-pointer
Which reconstructed classes still carry un-audited raw-offset reads?

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_GOTO env 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 (statusFlagssimulationFlags, destroyedsimulationState, statusBitsForceUpdate()). [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) = x silently vanishes; the later read is always 0/null. Archetype: the STEP-6 cylinder table was "cached" at Wword(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 — check mech.hpp's offset map first (the slot may already exist under a best-effort mislabel; 0x111 was mislabeled ammoExpended).
  • if (Wword(a) != Wword(b)) and if (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; the Wword(0xf)/(0x10) comparisons were simulationState.oldState/currentState.)
  • Any Wword(N) used for OBJECT access reads a shared global, not this+i*4; e.g. (Mech*)Wword(3) for a zone's owner → garbage; use GetOwningSimulation().

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==inherited massScale@0x160; Emitter outputVoltage==rechargeLevel@0x320). → delete, use the inherited name.
  • Alarm-interior field: a value the binary reads at alarm+0x14 modeled as a separate member (HeatSink heatState@0x184 == heatAlarm.GetLevel()). → route to the accessor.
  • Phantom field: a member at an offset PAST the object (Generator shortFlag@0x25C is really *(owner+0x190)+0x25c the 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's FillPilotArray write the PEER's Player* over controlMode ("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 cdb ba w4 on the compiled member address (log &member from the ctor, offset delta gives the compiled position). [T2] GaugeAlarm54 = 0x54 (the real AlarmIndicator; STATUS level at +0x14, so subsystem+0x184 == heatAlarm+0x14 == GetLevel()); SubsystemConnection = 0xC. [T1]

/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. Stub-typedef corollary (mech3 tool path, 2026-07-30): a LOCAL stub TU that re-declares a sibling class's STATIC MEMBER with the wrong typedef mangles to a ghost symbol — mech3.cpp declared every <Subsystem>::DefaultData as Mech::SharedData (= Entity__SharedData) while the real definitions are the inherited Simulation__SharedData (in THIS engine Entity derives from Simulation; Entity__SharedData : public Simulation::SharedData, ENTITY3.h:9) → ~20 unresolved externals, all /FORCE-silenced, all cold (offline authoring dispatch has no callers). Fixed for the DefaultData statics; the CreateStreamedSubsystem stub SIGNATURES are still wrong (real ones take the class's NESTED SubsystemResource* + trailing ResourceFile*) and stay unresolved-by-design until bridged per-module (open-questions). Rule: when a "benign" LNK wall exists, tail on the build output hides the fleet — always grep the FULL log; and verify a stub's member TYPE against dumpbin /symbols of the defining obj, not against what looks right. Duplicate-GLOBAL corollary (glass per-display windows, 2026-07-20): a global DEFINED in two libs (the 1995 headers declare free globals without inline/extern, so application, ghWnd, … exist in BOTH munga_engine and bt410_l4) links under /FORCE:MULTIPLE with per-object binding that is non-deterministic across links — a given .obj can resolve application to the copy the game ASSIGNS (correct) or to the other copy (stays NULL). A NEWLY-ADDED engine TU is the classic victim: it read application == NULL forever while L4VB16.cpp in the same lib read the assigned pointer, and a relink flipped which one was right. Symptom: a feature that reads an engine global silently no-ops (here: the gauge renderer came back NULL → blank glass surfaces), no crash, no link error. Rule: from a fresh TU, never touch these duplicate globals directly — resolve through a tiny accessor DEFINED in the TU that OWNS the real pointer (game/btl4main.cpp holds btl4App/hWnd as its own file-scope pointers; BTResolveGaugeRenderer()/BTResolveMainWindow() there are always correct). Do NOT "fix" it by adding the accessor to another engine TU — that binding is just as random. [T2]

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 MessageHandlers set must be built chained to the parent's (Receiver::MessageHandlerSet(Entity::GetMessageHandlers())). An empty default-ctor set has no parent chain → Receiver::Receive finds 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::Dispatch delivers 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 — does if(!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 call SetValidFlag() itself — else EVERY message defers forever. Force-validate at Make (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, then Entity::Receive saw valid=0 and deferred it forever → 0 damage. Fix = Mech::Make sets ValidFlag for the master too (mech.cpp), not just replicants. [T2]

  • Never send a NON-Entity message through Entity::Dispatch. Entity::Dispatch (ENTITY.cpp:236) unconditionally stamps message->entityID/interestZoneID at the Entity::Message field offsets (after Receiver::Message's 12-byte header). A NetworkClient::Message (the console ConsolePlayer*Message family) has no such fields and is SMALLER — those stamps write PAST the object. On a stack-allocated console message that is an /RTC1 stack-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 to networkManager->Send with no entity stamping) — mirror the working VTV-damaged push in ScoreMessageHandler, don't call the player's Dispatch. (Entity::Dispatch's messageID < Receiver::NextMessageID early branch does NOT save you — it lacks a return, 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-based Find(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]

FIXED STRUCTURALLY 2026-07-20 — the MESSAGE-handler twin (Gitea #18, the glass dead-button crash). The identical hazard exists for Receiver::MessageHandlerSet (RECEIVER.cpp/.h): Find(id) returns messageHandlers[id-1].entryHandler for ANY id <= entryCount with no populated-check, and Receiver::Receive calls it when != NullHandler. A message id that no class in the receiver's chain registers but that sits BELOW entryCount lands on a gap slot; Build did new HandlerEntry[entryCount] and left gaps uninitialised, so Receive did (this->*garbage)(msg) → wild-jump AV with zero btl4 frames above Receiver::Receive. Trigger: the streamed Eng-page .CTL dispatches subsystem msg id 3 / 0xb to whatever subsystem is shown on that MFD page; a weapon (Emitter) registers only PoweredSubsystem 4-8 + MechWeapon 9-10, so id 3 (slot[2], < entryCount 10) is a gap. Clicking the glass Engineering panel button 0x21 on an Eng page reached it live (eip=cdcdcdcd debug / 0x01048748 release). The 1995 binary has the IDENTICAL non-zeroing new[] (part_002.c Build, FUN_004022b0 = operator new) and only survived on fresh-OS-heap-zero luck (a zero slot == NullHandler == ignored) — a weapon receiving id 3 was always meant to ignore it (id 3 = a Condenser/Reservoir action in a different subsystem branch; the Eng-page button template is uniform, so buttons for actions a shown subsystem doesn't implement are authentically inert). Fix: MessageHandlerSet:: Build now null-inits every slot up front (entryID=0; entryName=""; entryHandler=NullHandler) before copying inherited / placing supplied — a gap is deterministically NullHandler → Receive drops it (the authentic "Receiver ignores unhandled messages"), and the empty (never-NULL) name keeps the name-based Find() strcmp from dereferencing a gap. This is the correct dispatch contract, not a glass-path guard, so it protects the WHOLE aux-bank / dead-button class at once (one build; the reported button + every MFD bank now click-soak clean under cdb). Lesson (the "/FORCE-garbage-via-new-dispatch-path" the report expected): a NEW input surface (the per-display glass windows) that makes a previously-unreachable message id reachable can fire a latent heap-luck gap that the pod/legacy panel never exercised — the smoking gun was NOT a /FORCE unresolved external (the link log carried only the known-benign mech3 set) but an uninitialised dense-table slot; when a garbage-call AV shows zero symbols above Receiver:: Receive, suspect a gap slot, not a missing symbol. [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::BuildConfigurationFile runs 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's DebugStream is the ReconStream whose operator<< is { return *this; } — it DISCARDS everything. Use the engine DEBUG_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_assert not runtime Check: a runtime Check(sizeof<=alloc) in a factory bridge does NOT fail the build (it's a runtime assert → heap overflow at construction). Use a compile-time static_assert sizeof 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 (graphicAlarm etc.) are INDICATORS whose level later events legitimately REWRITE (a leg hit on a wreck rewrites 9→4/3). A predicate like IsMechDestroyed = alarm>=9 un-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/Verify are ACTIVE in MUNGA TUs: a NULL hitting an engine Check(ptr) is an ucrtbased abort() dialog ("Debug Error!"), not an AV — sxe av won't break there; the box blocks the event loop (a headless node just "stops logging"). cdb: run with a config that does g then kb 40 — the int3 lands ON the aborting thread. [T2]
  • Verify under the USER'S launch flags, not a bare run: a "30 s regression: stable" check that omits BT_DEV_GAUGES=1 / BT_START_INSIDE=1 (the tools/mp_launch.sh set) misses any bug on the gauge/cockpit paths those flags gate. The task-#7 gauge crash was DORMANT for a day because every post-commit check ran flagless; it surfaced the instant the pod launch config was used. Reproduce with the real launcher's env, and drive (-net keyboard) — not just spawn-and-look. [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::SetTextureScrolling set 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 (beams bexp, 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, over rate*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); our DPLEyeRenderable::Execute hand-built D3DXMatrixLookAtRH(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 the ProcessCollisionList call sites) and restore it at the top of every Mech::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::Reset must zero worldLinearVelocity + 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 × (1e²) × 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's operator*=(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.

18. Uninitialized stack-built resource → garbage simulationFlags (the "worked for weeks then broke" trap)

Symptom: a subsystem installed from a HAND-BUILT (stack) *__SubsystemResource silently stops ticking — IsNonReplicantExecutable() returns false because a stray DontExecuteFlag (0x2) bit is set. Config-dependent and nondeterministic: it "works for weeks" then an UNRELATED commit that shifts the stack layout before the install flips the garbage bit. Archetype (task #7, 2026-07-17): the RIO controls mapper built on the stack in btl4app.cpp MakeViewpointEntitySubsystem::Subsystem copies model->subsystemFlags into simulationFlags, but the stack struct only set subsystemName/classID/subsystemModelSize, leaving subsystemFlags+segmentIndex = stack garbage. When the garbage carried 0x2 the mapper froze → speedDemand stuck at 0 → no forward motion (MASKED in single-player when the garbage happened to be clean; surfaced in -net where the stack differed; the July-16 audio commits shifted the stack and flipped it). Cause: partial init of a struct the engine reads in full. Fix: memset(&res, 0, sizeof(res)) before filling ANY stack-built resource — flags 0 = AlwaysExecute, the faithful value for a mapper (it MUST tick every frame). Tell: a subsystem present in the roster but absent from the executed set; instrument the tick's !IsNonReplicantExecutable() branch to name the slot + dump simulationFlags (bit 0x2 = DontExecute). Rule: zero every hand-built resource struct; never rely on a partially-filled one. [T2]

19. Entity::Dispatch STAMPS the message — a bare Receiver message overflows its stack frame

Symptom: a wild-jump crash (eip=1-style) one call after dispatching a message AT AN ENTITY — the tester's '\' dev key instantly "crashed the game". Cause: Entity::Dispatch (ENTITY.cpp:236) WRITES entityID + interestZoneID into the incoming message at Entity::Message offsets before routing. A bare ReceiverDataMessageOf<T> (Receiver-sized) on the stack gets written PAST ITS END → the caller's frame is corrupted → wild jump. The 1995 binary performs the identical overwrite and survived on stack-layout luck. Subsystem/Receiver Dispatch does NOT stamp — only the Entity level. Rule: any message dispatched at an Entity must have Entity::Message-sized backing (derive from Entity::Message, or placement- new the small message into a padded buffer — the '\' fake-event fix, btl4mppr.cpp DispatchDeveloperFakeEvent). Sibling of the MakeMessage inline-string wire rule (multiplayer "wire-format bug class"). [T2]

Related — the '&' STOP-MISSION keystroke read as "the game crashes": '&' (Shift+7!) is the engine's dev-console stop keystroke (Application::KeyCommandMessageHandler) — one shifted-key slip while hunting unmapped panel keys (MAP zoom '+' is Shift+'=') instantly ended the session, indistinguishable from a crash to a tester. Now env-gated: default IGNORED (logged), BT_KEY_STOP=1 restores the authentic stop (APP.cpp; the arrow-release '&' alias was already swallowed for the same hazard, L4CTRL.cpp:1516). Full-keyboard fuzz (every printable char + F-keys posted as WM_CHAR/WM_KEYUP, [keych] delivery-proof under BT_KEY_LOG) now survives end-to-end. [T2]

Related — raw numeric attribute index into a GROWING table (same root as gotcha 11): the dev gauges' CoolingLoopConnection reached the cooling master with a RAW GetAttributePointer(3) + *(master+0x1d4) walk. The July-16 audio work inserted attribute rows (ReportLeak etc.), shifting the chained ids — index 3 landed on a scalar, the resolve walked garbage, and the gauge background pass AV'd (only with BT_DEV_GAUGES=1, the pod launch flag, ~15 s in when the gauge builds lazily — the cdb stack pinned it to CoolingLoopConnection::Update). Fix: route through a complete-type bridge reading NAMED members (heat.cpp BTCoolingLoopFrame: linkedSinks.Resolve() + Condenser::condenserNumber) — the databinding rule (gotcha 8), never a raw numeric attribute index. The name-keyed samplers (PowerSourceConnection/GeneratorVoltageConnection, which look up "InputVoltage" by NAME) were unaffected — name lookup survives table growth. [T2]


20. Dirty-bit mis-mapping: updateModel@0x18 (replication) vs simulationFlags@0x28 bit 0 (DelayWatchersFlag)

Symptom class (Gitea #12): subsystem watchers (the AUDIO watchers — ExecuteWatchers, SIMULATE.cpp:461) silently stop for a weapon after its FIRST shot; and any "flags == 1" state test misfires. Cause: the binary's "needs replication" mark is Simulation::updateModel@0x18 |= 1 — in the disasm it reads or word ptr [this+0x18], 1 (i.e. "this+6" in dword-speak) and is EXACTLY what the engine ForceUpdate() does. Several port reconstructions transcribed it onto simulationFlags@0x28 |= 0x1 (Emitter::SetDirty emitter.cpp:215, the MissileLauncher salvo mark mislanch.cpp:337) — but simulationFlags bit 0 is the engine DelayWatchersFlag [T0 SIMULATE.h:165]: once set (nothing clears it), PerformAndWatch skips ExecuteWatchers() for that subsystem FOREVER. Bit 1 is DontExecuteFlag; bits 2-3 the replicant-copy mask; bit 8 the master flag. A related misread: the @004bbd04 fault gate tests simulationState@0x40 == 1 (subsystem DESTROYED), which the port transcribed as simulationFlags == 1 — with the spurious |=1 above, that arms a permanent alarm-7 kill-switch for any launcher whose other flag bits are 0. Rule: a binary [this+0x18] |= 1 = ForceUpdate(); [this+0x28] = simulationFlags (engine-defined bits — never invent meanings); [this+0x40] = MechSubsystem::simulationState (1 = destroyed). [T1] FIXED 2026-07-19 (Gitea #12, all sites disasm-verified): Emitter::SetDirty retired — it conflated or word [this+0x18],1 (= ForceUpdate; FireWeapon tail @4bafaa, Reset tail @4ba55d) with or dword [this+0x28],2 (= ExecuteOnUpdate; ServiceDischarge @4ba99a/@4ba943) — each call site now uses the correct engine call; the emitter fault gate GetFlags()==1simulationState == 1 (@4baab9); mislanch.cpp salvo mark and projweap.cpp AC mark / ResetToInitialState (@4bbb47) likewise. ⚠ One un-audited sibling remains: btplayer.cpp:426 simulationFlags|=0x1 ("request a forced update" — likely the same mis-transcription; verify against the VehicleDead handler's disasm before changing). [T2]


21. Port fixed-array too small for the real data → heap overflow (the DEATH-CRASH, Gitea #12)

Symptom class (Gitea #12 death-crash): a SILENT hard crash a few seconds AFTER a mech DEATH (no WER, no crash markers) — solo on a killed dummy, on the local player's own MP death, and on a surviving peer when a player disconnects (PEER_DOWN). Combat runs fine for minutes; the crash is deterministic on the first kill. Under cdb the stack is always in the audio-entity teardown of the death Explosion: FryDeathRow → Explosion::~Explosion → Entity::~Entity → …NotifyOfEntityDestruction → RendererOrigin::RemoveUninterestingEntity → L4AudioRenderer::NotifyOfBecomingUninterestingEntity → DestroyEntityAudioObjects → SocketIterator::DeletePlugs → ~Dynamic3DPatchSource → operator delete = HEAP CORRUPTION DETECTED after Normal block (Debug); or a garbage vtable dispatch in AudioControlSequence:: RunSequence → AudioControlEvent::Send (call [eax+0x1c], vtable == 0x16) (Release). Both are the same corruption manifesting at the next free / next virtual call.

Cause: a WinTesla-port fixed array sized for an assumption the real gamedata violates. L4AUDHDW.h SourceSet { int count; ALuint sources[5]; } — the OpenAL source array was 5 wide (an "AWE 4-voice" assumption), but channelSet.count = GetAudioVoiceCount() = the streamed per-preset voiceCount, and the AllExplosion preset (bank2 p125 — the death boom) authors 25 layered zones (MAX_PRESET_SAMPLES = 25, L4AUDLVL.h, one OpenAL source per zone). On channel acquisition RequestAudioChannels runs for (i<count) alGenSources(1, sources+i) → writes sources[5..24] past the end of the 5-element array, clobbering the adjacent heap object's vtable / the debug heap guard bytes. Nothing faults until that neighbour is next used (the running sequence's virtual Send — Release) or freed (the death Explosion's audio teardown — Debug), seconds later. The Playing hit an error: 40961 (AL_INVALID_NAME) burst right after the wreck swap is the same corruption (the source-id array trashed). This is NOT the held #16 DPLEyeRenderable/boresight work (exonerated — never on the stack), and NOT the rev=-64 mppr value (a ControlsButton is "negative for release" by design, CONTROLS.h:26 — benign; the < 1 test at mechmppr.cpp:870 reads it correctly).

Fix (2026-07-20): size SourceSet.sources[] to AUDIO_SOURCESET_CAPACITY = 25 (== MAX_PRESET_SAMPLES; L4AUDLVL.h static_asserts the two in lockstep), so the largest legitimate preset fits. Plus defence-in-depth clamps (a malformed stream can't overflow): channelSet.count in the L4AudioSource ctor and requested in RequestAudioChannels. The teardown path is generic to ANY entity leaving the interest set, so the one fix covers all three #12 death flavors (solo kill, MP self-death, MP peer PEER_DOWN — all fire the same 25-voice explosion preset through the same teardown). Files: engine/MUNGA_L4/L4AUDHDW.h, L4AUDLVL.h, L4AUDIO.cpp, L4AUDRND.cpp. [T2 — solo stack-confirmed + fix survives 10+ kills under cdb; MP flavors explained-by-mechanism, live-MP repro pending]

Rule: a port fixed-size array fed from streamed gamedata must be sized to the DATA's real maximum (dump it — here the MAX_PRESET_SAMPLES comment already named the 25-zone worst case), not a guessed hardware limit; and index loops driven by a streamed count must clamp to capacity. Grep the port for sibling [N] arrays filled from a resource-derived count (channel/voice/zone/ segment tables) — the same class of latent overflow.


Diagnostic recipe (the standard loop)

  1. Read the RAW decomp reference/decomp/all/part_*.c for the FUN_xxxx.
  2. Map FUN_/DAT_/this+0xNN to engine symbols via BT headers + WinTesla MUNGA source + CLASSMAP.md + RP's parallel code.
  3. Write the REAL reconstruction; static_assert-lock the layout.
  4. Build; run env-gated; read content\<stem>_YYYYMMDD.log; cdb on any crash (0xCDCDCDCD=uninit, 0xFEEEFEEE=freed).
  5. For exhaustive multi-function analysis: a read-only Workflow (understand), then implement hands-on.

Key Relationships

Dropped mid-sequence call = first-use-works-by-accident (issue #42, 2026-07-24)

A transcription that drops ONE call from a decomp sequence can pass every first-look test: ConfigMapGauge's redraw dropped the binary's MoveToAbsolute(0,0) (@004c6f1c, vtbl+0x24 between SetColor and the blit) -- the FIRST draw landed correctly because the fresh view cursor happened to sit at the origin; only a FORCED REDRAW (the DISPLAY page round-trip) exposed the stale-cursor blit as an offset ghost. LESSON: when transcribing a draw/IO sequence, verify EVERY vtbl call in the decomp is present -- and test the REDRAW/second-use path, not just first render. (Live-pinned by the operator's exact repro; bisect showed it was never a regression -- it shipped with the feature.)

Load-time-only state must be re-installed on every RE-load (issue #38, 2026-07-24)

The bug class: a piece of state is installed around a resource load, mutates the load's output, and is then torn down — so any LATER re-load of that resource silently produces a different (wrong) result. Our port re-loads things the 1995 engine loaded once, which is exactly where this bites.

The archetype — mech paint. The per-pilot colour/badge/patch is applied by REWRITING MATERIAL NAMES while a BGF parses: SetupMaterialSubstitutionList(entity) installs the callback (dpl_ApplyMaterialNameCallback, engine/MUNGA_L4/bgfload.cpp:15-18), MakeMechRenderables parses, TearDownMaterialSubstitutionList() removes it (game/reconstructed/btl4vid.cpp MechClassID case). But BTL4VideoRenderer::ApplyViewSkeleton re-parses every shown segment BGF on each inside/outside view toggle AND on respawn — and did so OUTSIDE that bracket, so the reload resolved the raw %color% placeholders and the mech rendered grey. There is no geometry cache to hide it (d3d_OBJECT caches only TEXTURES, mTextureCache), so every call re-parses.

Why it hid for months: nothing logs, nothing crashes, and the FIRST build is correct — the mech is painted right until something re-loads it. Reported as "mech COLORS not preserved on respawn" (#38) and blamed on replication/teardown for weeks; it is neither.

The tell: the symptom appears after a view change or respawn with no new [paint] / MakeMechRenderables line in the log — i.e. the geometry changed appearance without a rebuild. If a visual property is installed at load time, grep every call site of the loader, not just the "build" path.

The extra trap when you fix it: SetupMaterialSubstitutionList ADVANCES the global %serno% counter (gSerno) per call, so naively re-bracketing stamps a DIFFERENT serial and still resolves the wrong material names. The fix must reuse the serial the mech was BUILT with (MechRenderTree::paintSerno, captured before Setup, restored around the re-install so the global sequence is untouched).

Rule: when reconstructing, ask of every load-time-scoped hook — what happens if this resource loads twice? If the answer differs from the first load, either re-install the scope at every load site or cache the loaded object. Both are legitimate; silently re-parsing is not.

19. Ghidra drops the x87 expression around __ftol (the "RandomDelay" that was a 10-second constant)

FUN_004dcd94 decompiles as a bare return (int)ROUND(in_ST0); — it is __ftol, the compiler's float→int helper, called with the value already in ST0. When Ghidra carves it as a normal function, the CALLER's decompile can silently LOSE the whole floating-point expression and show a bald iVar = FUN_004dcd94(); — the computation vanishes from the export.

Archetype (issue #46): the AmmoBin cook-off delay read as Now() + FUN_004dcd94() and was reconstructed as "a randomised delay" stubbed to 0. The raw bytes @004bd450 are fld 10.0 / fmul [ticksPerSecond] / fadd 0.5 / call __ftol — a fixed 10.0-second fuse. The stub meant an armed bay fire never detonated.

Rule: any decomp line of the shape iVar = FUN_004dcd94() (or another arg-less FUN whose body reads in_ST0) is a carve artifact — raw-disassemble the call site (scratchpad/disammo.py pattern) and recover the x87 sequence before reconstructing. Related export blind spots: mid-function iterator carves at odd addresses (FUN_004acfa9-style thunks), truncated vtables.tsv rows (dump exe bytes at vtable+slot*4), and whole-function gaps (#60 — raw-disasm recovered @004b838c and @004bb9b8).

22. A port accessor NAMED for one thing that reads another (HeatModelOff) — 2026-07-28

torso.hpp:176, gyro.hpp:176 and sensor.hpp:132 each define:

Logical HeatModelOff() const { return simulationState == 1; /* Destroyed */ }

It has nothing to do with the heat model. simulationState is the MechSubsystem state (Default=0 / Destroyed=1 / Exploding=2), so this is "am I destroyed?". The behaviour at the call sites is CORRECT — torso.cpp:569 zeroing effectiveTwistRate when the torso is destroyed is right — but the name reads as an experience-level heat gate, and the ACTUAL heat-model gate is a different thing entirely: OwnerAdvancedDamage()BTPlayerExperienceHeatModelOn() → the player's +0x260 flag (off below veteran). Two unrelated concepts, one misleading name.

Why it matters: while chasing #70 this reads as "novice pilots cannot twist their torso", which sends you hunting an experience-level bug that does not exist. Verify what an accessor READS before trusting what it is called — the reconstruction's names are reconstructions too.

Sweep note: the same misnomer sits in all three headers; renaming is safe (no binary meaning attaches to the port's accessor name) but touches three size-locked classes, so do it deliberately.

23. AlarmIndicator is a DIFFERENT TYPE per header family — Mech's layout diverges across TUs (2026-07-30)

Found chasing #78: mechdmg.cpp wrote mech->graphicAlarm.SetLevel(4) and read 4 back; mechmppr.cpp read 0 from the same object, same expression, same frame family. Root cause:

mech.hpp:67   typedef ReconAlarm  AlarmIndicator;   // 4 bytes  {unsigned level}
heat.hpp:52   typedef GaugeAlarm  AlarmIndicator;   // 0x54 bytes (the real binary alarm)

Mech::graphicAlarm is declared AlarmIndicator — so a TU's include ORDER decides which type that member is, and every Mech member after it shifts by 0x50 between the two families. A write through one family's layout is invisible to a read through the other. This is the shadow trap operating at the TYPEDEF level, where no compiler error can catch it (each TU only ever sees one definition).

Rule: any cross-TU read/write of a Mech member declared past graphicAlarm must go through a bridge compiled in a KNOWN TU (BTMechGimpLevel in mechdmg.cpp is the pattern) until the typedef split is audited and unified. The audit itself — which TUs resolve AlarmIndicator to which type, and which member traffic crosses families — is an open work item; the same split-brain also explains why MovementMode() (the engine simulationState) and the KB's "movementMode IS the graphicAlarm level" (task #1) never actually met in the port: they are two different cells, both alive, written by different subsystems.

24. Reviving DEAD reconstructed code replays every port-glue fix it predates (2026-07-30)

The gimp gait drivers (AdvanceBody/LegAnimationGimp, ex-"Airborne") sat reconstructed-but-dead for weeks while their gate read the wrong cell (gotcha #23's split-brain). The moment the gate was fixed and they ran for the first time, they reproduced — within seconds of engagement — TWO bugs the LIVE drivers had each been cured of long before:

  1. the raw *(this->controlSource) mapper read (controlSource@0x128 is never wired in the port; the live drivers were converted to MappingMapper() — the dead one crashed at AdvanceLegAnimationGimp+0x16, null deref, first live engagement);
  2. the missing alarm→member state re-sync (bodyAnimationState = bodyStateAlarm.GetLevel(); the binary has ONE cell, the recon has two — the ground drivers re-sync at :831/:1181; the dead driver didn't, so the state member froze at its pre-gimp value and the machine pinned in one run state for 8000+ frames). Rule: before wiring a long-dead reconstructed function into a live path, diff it against its LIVE sibling for the port-glue idioms (mapper access via MappingMapper(), alarm→member re-syncs, null guards, BTEnvOn gates). The binary-faithful parts transplant cleanly; it is the RECON-side glue that will be missing, because every glue fix landed only where code was running.

25. A SPLIT cell breaks REPLICATION silently — and per-frame writers erase the half you added (2026-07-29)

The gimp-level saga's second act (#82). When the port carries one binary cell as TWO members, the damage isn't only cross-TU reads (gotcha #23) — it is which half rides the wire:

  • Simulation::simulationState is replicated in EVERY update record header (Simulation::Write/ReadUpdateRecord). Any binary state living in mech+0x40 therefore replicated for free. The port's parallel member (graphicAlarm) replicates nowhere, so every behavior a peer derives from it silently becomes master-only — visible as "peers see something different" bugs (here: a limping mech that skated on every other pod).
  • Worse, the engine cell usually already has a per-frame writer with a narrower idea of what it means (Mech::PerformAndWatch: SetMovementMode(1) = "ground, non-death, non-airborne"). The moment you mirror extra semantics into it, that writer erases them 60×/second — and the symptom is not a stuck value but a 1→N→1 oscillation whose edges retrigger anything watching (the warning-voice sequence restarted on every damage tick and never reached its spoken note). Rules:
  1. Before mirroring into an engine cell, grep for its per-frame writers and teach them the new value (write gimped ? 3/4 : 1, not a blind 1).
  2. Read the authority, never the cell you feed — an alarm-only bridge (BTMechGimpAlarmLevel) keeps a respawn-cleared alarm from re-latching stale state out of the cell.
  3. Don't "fix" a stomp by patching incoming records: on a replicant the master's records ARE the authority, and pinning a local value against them makes state stick forever (that draft would have kept peers limping through a respawn). Find the writer instead. Tool: scope the trap. StateIndicator/Simulation state traps drown in subsystem churn — filter to one watched object (g_btGimpWatchMech) and print the caller module-relative (btl4+0x…) so tools/symcrash.py names it. That turned a two-hour guess into one line.

§21 — Export-gap blindness: "no callers in the decomp" is NOT "no callers in the binary"

(2026-07-31, the myomer seek retraction — the costliest wrong verdict to date.) The Ghidra export has GAPS (gitea #60): whole functions absent from reference/decomp/all/*.c — the master-perf region 0x4a9770-0x4ab188, the 0x4a03xx crit caller, @004b838c, and others. A text sweep of the decomp for callers/readers of a symbol cannot see a consumer that lives in a gap. The myomer speedEffect@0x31C demand feed was declared dead on exactly such a sweep ("two AvailableOutput callers, one attribute reference, no raw readers") and an already-correct port mechanism was deleted on that basis — while the real consumer sat un-exported at @0x4a9cf2 (speedDemand *= max myomer speedEffect + the dead-drive turn freeze). Field testimony (Oracle: pod seek-4 = 182 kph, the manual's printed figure) forced the re-audit. RULE: before declaring a data path dead, sweep the RAW IMAGE for the operand pattern — capstone over the CODE section for the addressing form (e.g. FPU reads of [reg+0x31C], SIB [reg+reg*4+0x330]). Minutes of scanning; it found in one pass what three decomp sweeps missed. Corollary: when the decomp and a PRIMARY SOURCE (the manual, a pod veteran) disagree, treat the disagreement as a hole in YOUR evidence first, not in theirs.

§22 — The SPLIT CELL: one binary offset, two port members (only one gets written)

(2026-07-31, gitea #86 "destroyed weapons keep firing".) The binary's weapon fire gates test subsystem+0x40. In the 1995 layout that offset is inside the embedded status alarm (statusAlarm@0x2C + the indicator's level at +0x14 = 0x40) — one cell, written by ForceCriticalFailure when a zone's crit-cascade kills the subsystem. The port models the same address as TWO independent members: AlarmIndicator statusAlarm and a plain int simulationState@0x40. Every destruction path writes the ALARM (so the MFD correctly draws its X, the paper doll correctly greys the mount) while the fire gates read the plain int — which nothing ever writes. Result: a weapon on a blown-off arm shows destroyed on every panel and keeps firing and scoring, on the shooter's screen and on peers' (all three night-7 testers). Detection smell: a diagnostic that prints the gate's own inputs and shows a state flag reading 0 while the UI bound to "the same" state shows destroyed. ([ammo] NoAmmo (gate1): destroyed=0 on a mech with an X'd-out launcher was the tell — it sat in the logs for weeks.) Rule: when a binary offset falls inside an embedded object in OUR layout, do not mirror it as a sibling scalar — read it through the object that owns it, or (if a duplicate member already exists) make every gate read BOTH and every writer write BOTH. Sibling of gotcha #1 (shadowed base field): same failure shape — two cells where the binary has one, and the readers pick the dead one.

§23 — Scaling D3DMATERIAL9 does NOTHING for BGF geometry (the vertex-colour diffuse source)

(2026-07-31, gitea #87 "mech armour panels don't darken".) The 1995 armour damage darkens a mech by scaling its MATERIALS' colour terms (dpl_SetMaterialAmbient/Emissive/Diffuse/Specular). The obvious port translation — scale the draw op's D3DMATERIAL9 before SetMaterial — renders byte-identically: measured 0 changed pixels at a full 0.1x scale. Why: D3D9 defaults D3DRS_DIFFUSEMATERIALSOURCE to D3DMCS_COLOR1 (and D3DRS_COLORVERTEX to TRUE), so when a vertex carries a diffuse colour the material's Diffuse is never consulted. Every BGF vertex carries a baked colour (that IS the 1995 shading model — no-normal geometry is unlit and coloured by vertex/ramp), so for essentially all world geometry SetMaterial is inert for colour. The material only matters for lit, normal-bearing meshes. Fix pattern: modulate the FINAL fragment instead — D3DRS_TEXTUREFACTOR + a MODULATE(CURRENT, TFACTOR) on texture stage 1 (unused in d3d_OBJECT::Draw), restored to D3DTOP_DISABLE after the op. That darkens the result whatever the diffuse source was, and equally covers the ramp-baked-texture path and pure-emissive batches. Detection smell: a colour change that logs perfectly at the source and produces zero visible difference. Diff two runs pixel-wise against a control region before believing a colour path works — "the log says 0.1x" is not evidence that anything reached the screen.

§24 — A verified fact, OVER-GENERALISED, becomes a wrong design premise

(2026-08-01, gitea #95 "missiles land 3 points instead of 50".) The KB carried this, tagged [T1]: "DamageZone::TakeDamage is damageLevel += amount*scale and IGNORES burstCount — so burstCount is cosmetic for zone damage." The first clause is true and byte-verified (@0041e4e0). The second is an inference that was written down beside it, inherited the [T1] tag, and then justified a design decision (task #62's salvo-lead model) that silently divided every missile salvo by its missile count for months. What was actually true: TakeDamage ignores burstCount; the CALLER (Mech::TakeDamageMessageHandler @0x4a0423-0x4a04d8) honours it by calling TakeDamage that many times, re-rolling the zone per burst. The consumer was one stack frame up — the same blind spot shape as §21 (export-gap blindness), but with the evidence present and simply not followed outward. Detection smell: a "cosmetic"/"unused"/"vestigial" claim about a field that other code still computes carefully. Here the binary randomises burstCount at impact (Random(n) + n/4) and the splash code derives it from a distance falloff — nobody spends instructions rolling a decorative value. If a field is called dead, ask who still writes it, and why. Rule: tag the VERIFIED clause, not the paragraph. An inference sitting next to a [T1] fact is still [T4] — split them, or the next reader (including you) will build on the guess.