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>
238 lines
16 KiB
Markdown
238 lines
16 KiB
Markdown
---
|
||
id: reconstruction-gotchas
|
||
title: "Reconstruction Gotchas — the systemic bug classes (check these FIRST)"
|
||
status: established
|
||
source_sections: "CLAUDE.md §5a, §10c; docs/HARD_PROBLEMS.md; docs/RESOURCE_AUDIT.md; gauge-wave notes"
|
||
related_topics: [reconstruction-method, decomp-reference, subsystems, combat-damage, gauges-hud]
|
||
key_terms: [shadow-field, databinding-trap, Wword-trap, FORCE-trap, dtor-epilogue, bridge, attribute-pointer]
|
||
open_questions:
|
||
- "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
|
||
(`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) = 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]
|
||
|
||
## 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 `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 `.obj`s 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
|
||
- Applies to: every topic that reconstructs a class ([[subsystems]], [[combat-damage]], [[gauges-hud]], [[locomotion]]).
|
||
- Uses: [[decomp-reference]] (offsets/ClassIDs), [[reconstruction-method]] (the loop).
|