Files
BT412/context/reconstruction-gotchas.md
T
arcattackandClaude Fable 5 a3d67cc639 Combat visible + killable: Wword root-cause fix, .PFX effect layer, RemakeEntity swap
The 'can't kill the enemy / no visible damage' cluster, root-caused and fixed
faithfully:

- STEP-6 unaimed path was INERT: the cylinder table was 'cached' at Wword(0x111)
  -- the recon ABSORBER bank (stores nothing, reads 0) -- so every unaimed hit
  silently no-op'd.  Promoted to the named member Mech::damageLookupTable
  (binary this[0x111], was mislabeled ammoExpended).  New gotcha class recorded
  (reconstruction-gotchas §2) + sweep; 2 dead multiplayer branches logged.

- Fire path migrated off the stale vital-zone aim onto the completed STEP-6
  unaimed dispatch (zone=-1 + beam entry point -> cylinder resolves the
  exterior zone).  No more invisible 1-shot kills; death via the authentic
  cascade (~14 center-mass hits).  Wreck stays TARGETED on kill (beams stop on
  it); scoring latches off.

- SendSubsystemDamage AV fixed: unbound critical-subsystem plug guard (43
  unbound plugs/mech logged as an open question -- the binding itself is a gap).

- RemakeEntity (render damage swap): the 1996 render state machine's missing
  Remake state, reconstructed as an in-place SetDrawObj mesh swap keyed by each
  segment's damage-zone graphic state (tree dtor doesn't cascade -> never
  rebuild).  Destroyed arms/guns visibly wreck (the only variants the RES
  registers).

- BT .PFX particle layer (L4VIDEO.cpp): the 1995 explosion/damage effect layer,
  unported since 2007 (DPLIndependantEffect/ReadPSFX/ExplosionScripts all
  stubs).  Parses the authentic VIDEO/*.PFX definitions via the [pfx_day]
  psfxN mapping; premultiplied blending renders BOTH families from the same
  data (additive-style fire + occluding smoke -- DDAM2 is 30% grey, DDTHSMK
  ramps negative: impossible additively); depth-sorted billboards with a
  radial-masked grit sprite; impact-frame orientation (.PFX offsets are
  authored mech-local, -Z = out of the struck armor toward the shooter) for
  weapon hits AND damage bands (via lastInflictingID, now maintained -- was
  declared but never written).  Both effect-number encodings route (raw dpl
  <100 + WinTesla 1000+slot carried by the band resources).  Death fires the
  authentic dnboom (7) + ddthsmk smoke plume (1).

- Effects anchor at the impact point / damaged zone's segment, not the mech
  origin (no more fire at the feet).

- Dev force-input gates BT_AUTOFIRE / BT_AUTODRIVE for headless fire-chain
  verification; BT_PFX_ADD=1 flips the particle blend for A/B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 14:43:32 -05:00

182 lines
12 KiB
Markdown

---
id: reconstruction-gotchas
title: "Reconstruction Gotchas — the systemic bug classes (check these FIRST)"
status: established
source_sections: "CLAUDE.md §5a, §10c; docs/HARD_PROBLEMS.md; docs/RESOURCE_AUDIT.md; gauge-wave notes"
related_topics: [reconstruction-method, decomp-reference, subsystems, combat-damage, gauges-hud]
key_terms: [shadow-field, databinding-trap, Wword-trap, FORCE-trap, dtor-epilogue, bridge, attribute-pointer]
open_questions:
- "Which reconstructed classes still carry un-audited raw-offset reads?"
---
# Reconstruction Gotchas
The reconstruction is a **layout + linkage** problem as much as a logic problem. Our compiled
classes are NOT byte-identical to the 1995 binary, and the BT link uses `/FORCE`, so a whole
family of bugs is **silent** — garbage that happens to be non-fatal, or a runtime AV with no
link error. When a reconstructed class misbehaves, walk this checklist FIRST; the answer is
usually here, not in the logic.
> **The core rule (RULE: no stand-ins):** the full game logic IS in the pseudocode — a "gap"
> is a reconstruction stub not yet filled, never a hole in the original. Never write
> placeholder logic for an apparent gap; read the decomp. (User: "there are no gaps, just
> work to be done.") Bring-up scaffolding (env-var paths, explosion-for-beam) is clearly
> marked and meant to be REPLACED, never to substitute for reading the decomp. [T2]
---
## 1. Shadow field — re-declaring an engine-base field (THE most common)
**Symptom:** a field reads `0xCDCDCDCD` (fresh-heap fill) even though the ctor "sets" it; or an
object over-sizes past its factory alloc. **Cause:** the reconstruction re-declared a field the
engine base already owns, at the *binary's* offset. Two failures: (a) the copy **shadows** the
base — the engine ctor writes the base field, the reconstruction reads its own uninitialised
copy; (b) it lands at a **different offset** than the binary assumed.
**Fix:** delete the re-declaration; use the inherited member/accessor. Examples: `Mech`
`damageZoneCount`/`damageZones` (shadowed `Entity`'s → zones never built); `Mech__DamageZone`
`structureLevel`→engine `damageLevel`; the whole `HeatableSubsystem` de-shadow
(`statusFlags``simulationFlags`, `destroyed``simulationState`, `statusBits``ForceUpdate()`).
[T2]
## 2. Wword(N) — an ABSORBER, not storage (state cached there VANISHES)
`mechrecon.hpp:226` defines `Wword(int i)` as `static BTVal bank[0x400]; return bank[i&0x3ff]`,
and `BTVal` is the recon **absorber** type: `operator=` stores NOTHING, every read converts to
`T()` (zero), and ALL comparisons (`==` and `!=`, vs BTVal or int) return **false**. Consequences: [T2]
- Any state CACHED via `Wword(N) = x` silently vanishes; the later read is always 0/null.
**Archetype:** the STEP-6 cylinder table was "cached" at `Wword(0x111)` → the unaimed
TakeDamage path was totally inert (every hit no-op'd, "can't kill the enemy") while the
ctor log looked fine. Fix = a real named member (`Mech::damageLookupTable`). If a Wword
slot must hold real state, PROMOTE it to a named member mapped to that binary offset —
check `mech.hpp`'s offset map first (the slot may already exist under a best-effort
mislabel; 0x111 was mislabeled `ammoExpended`).
- `if (Wword(a) != Wword(b))` and `if (Wword(a) == 2)` are BOTH always-false → the guarded
branch is dead code. Known dead sites: `mech.cpp:1511` + `mech.cpp:1613` (replicant
leg-state / stability-alarm sync in ReadUpdateRecord — multiplayer-only, deferred).
- Any `Wword(N)` used for OBJECT access reads a shared global, not `this+i*4`; e.g.
`(Mech*)Wword(3)` for a zone's owner → garbage; use `GetOwningSimulation()`.
Sweep recipe: `grep -nE "\((int|void|[A-Z]\w+) ?\*\)\s*Wword\(|if \(Wword\(|Wword\([^)]+\)\s*(!=|==)"`
every hit is either dead code or a vanished cache.
## 3. Databinding trap — raw offsets read garbage
Our compiled layout != the 1995 binary, so `*(T*)(obj+0xNN)` reads garbage for ANY object we
compile. This is WHY shadow fields fail and why raw subsystem reads (e.g. a gauge reading
`owner+0x438`) return junk. **Fix:** use compiled named members/accessors; for a cross-TU raw
op, use a **bridge** (§8). A `+0x128`-style owner offset in subsystem code is the
[[subsystem-roster]] (`subsystemArray`), NOT the segment table — check every `GetSegment(int)`
in a reconstructed subsystem ctor. [T2]
## 4. Resource-struct layout mismatch (sibling of the shadow bug)
**Symptom:** RESOURCE fields read garbage (silently — a `heatSinkIndex` reading `10.0f`).
**Cause:** `*__SubsystemResource` structs overlay pre-built 256-byte records loaded VERBATIM at
fixed offsets, so OUR struct must match the binary byte-for-byte. Breaks two ways: (a) wrong
inheritance base (the resource must mirror the CLASS hierarchy — `HeatableSubsystem`'s resource
inherits `MechSubsystem__SubsystemResource` 0xE4, not `Subsystem::SubsystemResource` 0x30); (b)
under-sized fields (a 12-byte record field typed as a 4-byte `ResourceID`). **Diagnose:** log
compiled offsets `(char*)&res->field - (char*)res` vs the binary's; dump raw record bytes as
int+float. **Lock:** `static_assert(offsetof(...)==0xNN)` + `static_assert(sizeof(...)==0xNN)`.
The 33-agent RESOURCE_AUDIT fixed 8 such bugs (docs/RESOURCE_AUDIT.md). [T2]
## 5. Alias / phantom / interior fields (object layout)
- **Alias field:** a subclass member re-declaring an inherited slot the ctor reuses under a new
name (Condenser `refrigerationOutput`==inherited `massScale@0x160`; Emitter
`outputVoltage`==`rechargeLevel@0x320`). → delete, use the inherited name.
- **Alarm-interior field:** a value the binary reads at `alarm+0x14` modeled as a separate
member (HeatSink `heatState@0x184` == `heatAlarm.GetLevel()`). → route to the accessor.
- **Phantom field:** a member at an offset PAST the object (Generator `shortFlag@0x25C` is
really `*(owner+0x190)+0x25c` the msg-manager). → remove; read the real source.
`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::Dispatch` delivers synchronously only for a VALID master; an invalid entity `Post`s a
deferred event that never fires. A manually-spawned entity (no CheckLoad handshake) must call
`SetValidFlag()` itself — else EVERY message defers forever (the replicant / spawned-dummy /
MessageBoard-source class of bug). [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]
---
## 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).