Files
BT412/context/reconstruction-gotchas.md
T
arcattackandClaude Opus 4.8 0bfb3d4ab3 Warp: restore the translocation-vortex look + fix texture-scroll precision collapse (task #52)
The death/respawn "blue whirlwind" (tsphere) now matches the original cabinet
photo (capture.png): a smooth spinning lavender vortex with a bright core.

Root cause of the long-standing "radial spokes" artifact was NOT the warp code
but a general engine bug: L4D3D::SetTextureScrolling computed its texture-matrix
offset as scrollDelta * absolute_time, which grows unbounded and collapses UV
float precision -> a smooth scrolled cloud shatters into grainy radial steps.
Wrapped with fmodf(..., 1.0f) (identical under REPEAT tiling, full precision).
This also cleans the scrolling bexp beam grit and any other SCROLL material.

Visual reconstruction (verified against the real 45-vtx TSPHERE.BGF bicone,
offline-rasterized then ported):
  - view ON-AXIS (eye centred on the throat) + spin in place -> concentric rings
    (decomp FUN_00453dc4 does spin-about-local-Z + submit; the port had stubbed it)
  - bintA cloud through a WIDE lavender ramp at full contrast (drawn as SKY);
    no geometry "bands", no log-polar twist, no off-axis tornado (all discarded)
  - tessellate the 12-facet bicone smooth; isotropic + trilinear; ramp baked into
    the texture and drawn SELECTARG1(TEXTURE) to avoid double-tinting

Env knobs (BT_WARP_*) default to the verified values; BT_WARP_SELFTEST/SELFSHOT
are an off-by-default visual-verification harness (backbuffer frame dump).

Docs: new context/translocation-warp.md (geometry/material/visual/lifecycle/env);
reconstruction-gotchas.md gains the accumulated-time precision-collapse bug class;
rendering.md / multiplayer.md / decomp-reference.md / CLAUDE.md cross-linked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:53:50 -05:00

16 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 (env-var paths, explosion-for-beam) 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. Known dead sites: mech.cpp:1511 + mech.cpp:1613 (replicant leg-state / stability-alarm sync in ReadUpdateRecord — multiplayer-only, deferred).
  • 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]

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]

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]

12. 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]

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.)

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 btl4.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