Ported from RP412: RIOBase split out of the serial RIO (L4RIO.h), rioPointer is RIOBase* (L4CTRL.h), PAD token -> new PadRIO() speaking the RIO surface from an XInput pad + keyboard (L4PADRIO/L4PADBINDINGS, vRIO bindings.txt grammar, hot-plug), KeyLight RGB mirror TU (BT412KEYLIGHT, /std:c++17 per-file). BT-side fixes PadRIO forced into the open: - Both keyboard input bridges (mech4.cpp, mechmppr.cpp BT_KEY_BRIDGE) stand down when a RIO device exists -- they overwrote the engine controls push every frame. M/X conveniences stay live. - Mapper attribute chain OFF BY ONE (latent real-pod bug): the DOS chain below MechControlsMapper carried two base attributes, WinTesla carries one, and AttributeIndexSet::Find is positional -- the .CTL stick mapping wrote throttlePosition. Pad slot + binary-locked enum; gotcha ledgered (reconstruction-gotchas #11). Verified: PAD throttle lever ramps + sticks, stick turns with the authentic speed-vs-turn clamp (61.5 -> 22.0 u/s), mech drives; keyboard fallback intact (BT_FORCE_THROTTLE harness). New diags: BT_CTRLMAP_LOG, BT_STICK_LOG. (Phase 2 of docs/BT412-ROADMAP.md) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
374 lines
27 KiB
Markdown
374 lines
27 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 (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]
|
||
|
||
**The inverse trap — SHORT parent chain (BT412, 2026-07-14):** `Find(ID)` is positional
|
||
(`attributeIndex[ID-1]`), so when the WinTesla parent chain carries FEWER attributes than the
|
||
DOS binary's did, every child attribute resolves one slot EARLY relative to a 1995 stream's
|
||
numeric IDs — no crash, values silently land in the WRONG MEMBER. Proven live the first time
|
||
a real device drove `MechControlsMapper`: the binary's `.CTL` control-mapping stream uses
|
||
stick=3/throttle=4 (the DOS Subsystem chain had 2 base attrs), the WinTesla chain has 1
|
||
(`SimulationState`), so the stick mapping wrote `throttlePosition` and the throttle mapping
|
||
wrote `pedalsPosition`. Only the numeric-ID path (CreateStreamedMappings / `.CTL`) is
|
||
affected — gauge databinding resolves by NAME and never saw it. Fix: a named pad slot at the
|
||
front of `mechmppr.cpp AttributePointers[]` + the enum locked to the binary's values.
|
||
Diagnostic: `BT_CTRLMAP_LOG=1` dumps each streamed mapping's resolved pointer — compare against
|
||
`&mapper->member` (the mech4 `[mppr]` log prints `&stick`/`&thr`). [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 × (1−e²) × 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).
|