Files
BT411/context/reconstruction-gotchas.md
arcattackandClaude Fable 5 bb795e2805 MP live-play wave: collision economy, missiles, radar transform, panel polarity, comm ticker
The interactive 2-node playtest wave -- every fix decomp-grounded and live-verified:

COLLISION ECONOMY (the ram one-shot): StaticBounce mutates worldLinearVelocity
per contact and ProcessCollisionList walks EVERY touched solid per frame; with
2007 terrain-as-solids the reflections compounded x4-x40 within one frame and a
walking bump one-shot a pristine mech for 112,375 pts (62-pt authentic economy).
Fix: frameEntryWorldVelocity restore per contact (damage always priced at the
real approach speed -- all the binary's physics ever saw); Mech::Reset zeroes
the mover motion (respawn = teleport); [collide-tx]/[mp-hdlr] telemetry.
Gotcha #16 (engine-facility drift class).

MISSILES: peer-visible salvos (the launcher record extension carries a salvo
counter + aim point; ForceUpdate actually enqueues it -- the dirty flag alone
never serialized), the authentic arc (authored MuzzleVelocity vector + the
Seeker's 200m/0.1/300 loft + gain-4 steering, decoded from @004beae4/@004bef78),
world-impact bursts (rounds detonate on cave geometry instead of phasing
through), contact-only damage (flight-cap expiry = fizzle, no more teleport
damage), live re-lead, and ballistic (unguided) shells for autocannons.
projweap's stale BTPushProjectile extern (the /FORCE signature trap, gotcha #6
corollary) crashed the Avatar's first AFC100 shot -- fixed + sweep rule recorded.

RADAR: two transcription bugs made the scope permanently empty -- FUN_0040b244
is the affine INVERSE (not a copy) and FUN_0040adec writes ONLY the 3x3 rotation
(never the translation); worldToView now Invert(view) built rotation-first.
CulturalIcons sorted out of the moving grid (the phantom red pips were map
props), visible-radius culls on all three draw passes, live pip verified at
|delta| x ppm px.  Gotcha #17 (verify the FUN_ body, not its call shape).

WEAPON PANELS (the frozen-dial hunt): the binary's *(subsystem+0x40) means
FAILED -- the recon's 'operating' name was backwards, inverting the destroyed-X
lamps, the panel look, the children enable and the ready-lamp gate (which had
NEVER executed).  Polarity chain corrected end-to-end (failedState, fed by real
damage saturation).  Root cause of the freezes: MFD page-mode gating -- the dev
composite shows ALL pages at once, so off-page dials legitimately stopped; under
BT_DEV_GAUGES the 15 page-plane bits stay active (the exclusive secondary trio
untouched).  The SEH gauge guard now names its kills; repaint-heal resets the
incremental arc after panel repaints; [panel]/[arc] probes added.

COMM/SCORE: MessageBoard LIVE (the engine already shipped the whole
Player__StatusMessage queue; wired the binary's one producer -- the kill branch,
victim's name, 6s -- plus the consumer bridge and a lazy source bind); MP DEATHS
counted via the observed-death tally (each node scores every pilot from locally
observed events, the same model as the KILLS credit) and the -2/-1 engine seed
clamped for display.

DEV UX: node-tagged window titles (-net port), gauge panel reworked (1320x480,
true 4:3 MFD cells, the portrait secondary UNROTATED upright, linear filtering,
BT_GAUGE_SCALE), fixed close spawns via BT_SPAWN_XZ, Boreas flies an Avatar
(first second-chassis live outing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 17:24:15 -05:00

361 lines
26 KiB
Markdown
Raw Permalink 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]
---
## 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.
---
## 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).