Files
BT412/context/reconstruction-gotchas.md
T
arcattackandClaude Opus 4.8 63250aee41 Fix two Jul-16 regressions: BT_DEV_GAUGES crash + -net dead controls mapper
Both surfaced today (dormant ~1 day) the moment the pod launch flags
(BT_DEV_GAUGES=1 BT_START_INSIDE=1, tools/mp_launch.sh) were used to drive in
multiplayer -- which made the same-day paint change look guilty.  Confirmed on
the pre-paint build too; the trigger was the Jul-16 audio attribute work.

1) BT_DEV_GAUGES crash (cdb: CoolingLoopConnection::Update, ~15s in as the gauge
   builds lazily).  The dev-gauge cooling-loop lamp reached the cooling master by
   RAW attribute index -- GetAttributePointer(3) + *(master+0x1d4).  The audio
   commits (cc2b109 ReportLeak etc.) inserted rows into the chained attribute
   tables, so index 3 shifted onto a scalar, the resolve walked garbage, and the
   background pass AV'd.  Fix: route through a complete-type bridge reading NAMED
   members (heat.cpp BTCoolingLoopFrame: linkedSinks.Resolve() +
   Condenser::condenserNumber) -- the databinding rule; never a raw numeric
   attribute index.  Removed the now-orphaned GetAttributePointer-by-index
   helper.  Name-keyed samplers (InputVoltage) were unaffected.

2) -net dead controls mapper (mech wouldn't walk; turning/weapons still worked).
   The RIO mapper is built from a stack SubsystemResource in btl4app.cpp that set
   only name/classID/modelSize -- leaving subsystemFlags as stack GARBAGE, which
   Subsystem::Subsystem copies into simulationFlags.  A stray DontExecuteFlag
   (0x2) froze the mapper (speedDemand stuck at 0).  Config-dependent: clean in
   SP, dirty in -net; the audio commits shifted the stack and flipped the bit.
   Fix: memset each hand-built control-mapper resource to 0 (flags 0 =
   AlwaysExecute, the faithful value -- a mapper must tick every frame).

Verified with the pod launch flags: SP alive 32s no crash; MP mech walks
(speedDemand 61.5 at full throttle, ~430u traveled), both nodes alive, no crash.

KB: reconstruction-gotchas.md gains gotcha 18 (uninitialized stack-resource
flags + the raw-attribute-index-into-a-growing-table variant) and a
verify-under-the-user's-launch-flags note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:58:32 -05:00

396 lines
28 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. (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]
## 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]
**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.
## 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]
## 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 `.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]
- **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 MakeViewpointEntity``Subsystem::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]
**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]
---
## 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).