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>
16 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 (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
(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. 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, 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]
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]
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::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.)
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).