Files
BT412/context/reconstruction-gotchas.md
T
arcattackandClaude Fable 5 3c34ae6de6 KB: full staleness audit + sweep -- 35 verified corrections across 16 topics, 15 new glossary terms
Adversarially-verified audit (18 agents) against the task #46-#56 landings:
MP/current-state understatements, superseded punch readings, retired env
gates, dead paths, line-cite drift; env-gate hub table completed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:56:51 -05:00

269 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 (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
(`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:1673` + `mech.cpp:1775` (replicant
state sync in ReadUpdateRecord — MP is live; these are being revived by the in-progress
Mech Write/ReadUpdateRecord reconstruction, task #1).
- 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]].)
---
## 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).
---
## 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).