Files
BT411/docs/HARD_PROBLEMS.md
T
arcattackandClaude Opus 4.8 9d82be46a1 P7: subsystem-tree alarm unification -- the whole PoweredSubsystem weapon/power subtree is byte-exact
The reconstruction modeled the binary's 0x54 AlarmIndicator (FUN_0041b9ec) with undersized
stand-ins (AlarmIndicator==ReconAlarm==4B; HeatAlarm==8B) across the entire subsystem tree, so
every field above an alarm sat at the wrong compiled offset.  Retype every such stand-in to
GaugeAlarm54(0x54) and de-phantom each class against its ctor, so the whole PoweredSubsystem
subtree becomes byte-exact.  An 8-agent read-only decomp-mapping workflow decoded every ctor
first; then hands-on implementation.  static_assert-locked chain (verified vs the raw ctors):

  HeatSink 0x1D0
   -> PoweredSubsystem 0x31C   retype electricalStateAlarm@0x264 + modeAlarm@0x2B8   @004b0f74
   -> MechWeapon      0x3F0   retype weaponAlarm@0x350; delete 5 phantom tail fields  @004b99a8
   -> Emitter         0x478   delete outputVoltage/beamLengthRatio/firingArmed aliases
                              + beamHit*/beamColor/beamHitData/energyRampTime phantoms;
                              retype beamOrientation EulerAngles->Quaternion(16B)       @004bb120
   -> PPC             0x478   (no own fields)                                           @004bb888
  PoweredSubsystem -> Sensor  0x328   (no alarm; 3 own fields)                          @004b1d18
  PoweredSubsystem -> Myomers 0x358   (no alarm)                                        @004b8fec

New systemic bug-class instances fixed (added to the CLAUDE.md checklist):
  * alias field    - Emitter outputVoltage==inherited rechargeLevel@0x320; beamLengthRatio==
                     beamScale.z@0x434; firingArmed==inherited useConfiguredPip@0x3E0
  * phantom field  - MechWeapon segmentReference/pipSegment/hasTarget/targetPoint/muzzlePoint
                     (past 0x3F0); Emitter beamHitPoint/beamImpact/beamImpactScalar/beamColor/
                     beamHitData/energyRampTime (binary writes inherited damageData/voltageScale
                     or the value is a method local)

Non-layout fixes required in the same wave:
  * outputVoltage->rechargeLevel also in the compiled GAUSS.CPP:74/93 (external readers of the
    removed Emitter field -- must grep EVERY TU, not just the class's own .cpp)
  * MechWeapon::GetMuzzlePoint reimplemented faithfully (removed muzzlePoint collided with
    Emitter's own fields at 0x3F0) via a BTResolveWeaponMuzzle void* bridge in mech4.cpp,
    resolving the weapon's mount segment (inherited this+0xdc) through the owner segment table
  * DetachFromVoltageSource fixed to set electricalStateAlarm not modeAlarm (raw @004b0e30
    writes the 0x264 alarm)

The vehicleSubSystems aux-screen gauge raw reads (btl4gau2.cpp:868/952 at subsystem+0x2b8/+0x278)
and the Sensor RadarPercent path now read the CORRECT byte offsets (garbage under the short layout).

Verified: combat DESTROYED-in-8, 28 shots, 0 crashes, heat heatEnergy=1.34e7, every static_assert
lock passes, heapcheck-clean through construction (the phase the isolated PoweredSubsystem retype
had overflowed).  LESSON: a factory-bridge runtime Check(sizeof<=alloc) does NOT fail the build --
only a static_assert sizeof lock catches alloc overflow at compile time.

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

283 lines
25 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.
# BT Port — Hardest-Problems Front-Load Plan (scope-hardest-problems workflow + verification)
Goal: tackle the hardest, highest-LEVERAGE problems first so the rest of the port gets easier.
6 candidates were scoped by independent deep-dive agents, then the load-bearing claims were VERIFIED
against the code (several agent claims + several CLAUDE.md facts turned out wrong — see Verification).
## Ranked front-load sequence (post-verification)
| # | Problem | Verdict | Why here |
|---|---------|---------|----------|
| **1** | **P5 — Entity lifecycle / collision teardown** ★ | do-first | Hardest (4) + highest leverage (4) + hard prereq for multiplayer & multi-entity combat & real hit detection. Forces Entity/Mover base-region layout correctness that ALSO de-risks P3. |
| **2** | **P3 — Locomotion cutover** (SequenceController → world transform) | do-early (adjacent to P5) | The gait pipeline is ALREADY reconstructed in source (mech2/3/4.cpp: legAnimation@0x65c, bodyAnimation@0x6bc SequenceControllers) — this is a CUTOVER from the procedural-slide stand-in, not greenfield. Shares P5's Mech/Mover layout de-risk. Produces the speed/turn-demand consumer P2 & P6 need. |
| 3 | **P2 — Authentic input** (MechControlsMapper + RIO) | do-later | Downstream of P3 (mapper output is inert without the locomotion consumer). Software plumbing = days once P3 lands; physical RIO wiring defers to a Phase-8 session with Nick. |
| 4 | **P6 — Multiplayer** (integrate existing TCP stack) | do-later | Terminal integration. The hard part is DONE (L4NET = 3446-line WinSock TCP; master/replicant core complete). Gated on P3 + P5 + subsystem waves. Do a 2-instance smoke test early to de-risk; save fidelity for last. |
| 5 | **P1 — dpl2d reticle/PIP overlay** | opportunistic | Easy + isolated + additive (btl4vid.cpp already calls dpl2d_Circle for the PIP). Slot in whenever a visible aiming win is wanted; blocks nothing. |
| — | **P4 — Build /FORCE cleanup** | optional cosmetic | ⚠ NOT a front-load enabler. The agent's premise was REFUTED (see Verification): /FORCE hides ZERO runtime symbols. Pure cosmetic; do opportunistically, not first. |
**Prerequisite gates:** P5 gates P6 · P3 gates P2 & P6 · P5 and P3 share a hidden prereq = **Entity/Mover
base-region field-layout correctness** (do that audit once, up front, both benefit).
## Verification results (adversarial — several claims corrected)
-**P5 root cause corrected:** CLAUDE.md said "collision solids were never built." VERIFIED WRONG —
`Mover::Mover` (RP/MUNGA/MOVER.cpp:1756) allocates `collisionLists = new BoxedSolidCollisionList[2]`
UNCONDITIONALLY; `~Mover`:2050 deletes it. So the crash is a **clobber / dangling / teardown-order** bug.
-**P6 corrected:** CLAUDE.md §8 said "reimplement over UDP." VERIFIED WRONG — L4NET.CPP is 3446 lines of
WinSock **TCP** (`SOCK_STREAM`×4, `ReliableMode`, zero `SOCK_DGRAM`). Already reimplemented, not greenfield.
-**P4 claim REFUTED:** agent claimed `/FORCE` swallows ~10-15 genuine runtime unresolveds (e.g.
`Mech::WorldToLocal`). The linker emits EXACTLY 40 unresolveds = 20 `DefaultData` + 20
`CreateStreamedSubsystem` (dead offline factory), **zero runtime symbols**. `WorldToLocal` resolves via the
engine lib. CLAUDE.md's "dead offline-factory, cleanup TODO" characterization was correct. P4 is cosmetic.
-**P3 confirmed less-greenfield:** mech2.cpp carries the full SequenceController gait reconstruction → cutover.
-**P1 confirmed small/isolated:** dpl2d gap ≈ the reticle/PIP vector overlay only (btl4vid.cpp:563+ already
calls dpl2d_NewDisplayList/Begin/Circle for the PIP; weapon beams are a separate 3D-renderables module;
gauges/MFDs/radar are the separate L4GAUGE path — MUNGA_L4/L4GAUGE.cpp — already ported, OFF in BT).
## P5 — ⚠ AUDIT PIVOT: it is a TEARDOWN-SEQUENCE bug, NOT a base-region stomp (verified)
The base-region audit **disproved the base-region-stomp hypothesis** and redirected P5:
- **The enemy's engine base region is FULLY VALID at death** (`BT_ENABLE_TEARDOWN` dump): `collisionLists@0x2e4
=0D8C8C04`, `segmentTable@0x2f0=00872CE8` (segmentCount `51`), `jointSubsystem@0x30c=00872D68` all valid;
`lastCollisionList`/`collisionAssistant` NULL (fine). Nothing corrupts it during the enemy's life.
- **Every raw-offset base-region stomp is DEAD CODE.** `Mech::Simulate` (collision-cluster reads/writes +
telemetry overflow past sizeof) and `FeedHeat*Gauge` (writes through `+0x2ec`) are DEFINED but NEVER
CALLED (grep-confirmed; mech4.cpp:858 "our drivable override bypasses the unsafe Mech::Simulate"). So the
`0x2d4-0x2f0` stomps and the `0x7e0-0x828` over-sizeof writes do not run — they are not the cause.
- **The crash is in the teardown SEQUENCE** (`FryDeathRow → ~Mech → ~JointedMover → ~Mover`): cdb_dmg5 shows
`collisionLists`'s array already **freed** (`0xdd` cookie / `count=0xdddddddb`) by the time `~Mover`'s
`delete[]` runs — a **double-free / destruction-order** bug (crash site varies run-to-run: `~Mover`
collisionLists, or `~JointedMover` deleting a `0x0ccd1210` value). ~Mech (reconstructed) runs BEFORE the
engine base dtors, so the prime suspect is a Mech member dtor / the enemy's minimal-spawn collision setup
freeing (or aliasing) a base resource that the engine base dtor then frees again.
**Latent finding (real but not the crash cause):** compiled `sizeof(Mech)=0x638` < binary `0x854`, and
`Mech::Make` allocates the compiled size; the code that would write `0x7e0-0x828` past it is the dead
`Simulate`, so no live overflow today — but if that code is ever revived, the Mech must first be padded to 0x854.
**✅ DOUBLE-FREE PINNED (trace done):** NOT collisionLists — it's the **skeleton SEGMENT teardown**.
- `collisionLists` is LIVE at `~Mech` entry (`*cl` not `0xDD`) — ruling out the collision path.
- Real crash stack: `~JointedMover` (JMOVER.cpp:436 `SegmentTableIterator(segmentTable).DeletePlugs()`) →
`SocketIterator::DeletePlugs` (SOCKET.cpp:157-161 `delete plug`) → `EntitySegment` scalar-deleting-dtor →
`_free_base` → **`STATUS_HEAP_CORRUPTION` (0xC0000374)** with **`0xFEEE` freed-fill** present. So one of the
enemy's **51 EntitySegments is freed twice**.
- Mechanism: `DeletePlugs(defeat_release_node=1)` (SOCKET.cpp:151-154) NULLs the socket's release node and
force-`delete`s every plug, BYPASSING the ref-count release path. If the enemy's segments are ref-counted/
shared (owned by the skeleton resource, or shallow-aliased by the minimal `Mech::Make` spawn), the normal
release already freed them → `DeletePlugs` double-frees. (Earlier `~Mover` collisionLists `0xDD` crash was
the same heap corruption surfacing at a different free — the segment double-free is the root.)
**Fix-trace (deep, still open):** segment-deletion trace (cdb bp on `EntitySegment::~scalar-deleting-dtor`)
shows `DeletePlugs` deletes segment #1 OK (`0d3d3f30`), then crashes on segment #2 (`0d3d5200`) —
`STATUS_HEAP_CORRUPTION` freeing its block. Only 2 of 51 deleted; **segment #2's block is invalid** and its
scalar-dtor never fired earlier, so it was freed via ANOTHER path before `~JointedMover`. Ruled out:
- `EntitySegment::~EntitySegment` (SEGMENT.cpp:116) only DeletePlugs its OWN child-index/damage/video plugs
(integers) — it does NOT free sibling segments, so deleting #1 didn't free #2.
- The reconstructed `~Mech` body (mech.cpp:952-1008) does NOT explicitly free `subsystemArray` or segments.
- No live overflow past `sizeof(Mech)=0x638` (the over-sizeof writes are all in dead `Simulate`).
**Leading hypothesis — DUAL segment/joint DeletePlugs ownership:** the `JointSubsystem` dtor (JOINT.cpp:499-505)
`DeletePlugs()`es its **`jointTable`**, and `~JointedMover` (JMOVER.cpp:436) `DeletePlugs()`es **`segmentTable`**;
the JMOVER `#if 0` comment ("deleted by the entity subsystemArray[jointSubsystem]") flags the ownership overlap.
If the enemy's minimal `Mech::Make` spawn puts the SAME segments in both tables (or sets release-node/ref-counts
differently than a normal entity), both force-delete them → the second is the double-free.
**✅ DECISIVE TEST RUN (spawn-vs-rp-creation workflow + cdb):**
- **Dual-ownership hypothesis DISPROVEN.** Engine source confirms `segmentTable` (owns EntitySegments,
`(this,True)`) and `jointTable` (owns Joints, `(NULL,False)`) are DISJOINT — `DeletePlugs` on one never
touches the other (JMOVER.cpp:171/310, JOINT.cpp:501-504, SEGMENT.cpp:116-126). So there is no
segment/joint double-delete.
- **Double-destruction DISPROVEN.** cdb bp on `JointedMover::~JointedMover` shows it runs EXACTLY ONCE for
the enemy (`~JM this=0d04b998` == the teardown-log enemy this). Single teardown.
- **=> It is HEAP CORRUPTION of an individual segment block (Hypothesis B), during a SINGLE teardown.**
`segmentTable.DeletePlugs` frees segment #1 OK, then segment #2's block header is invalid
(`RtlValidateHeap ... Invalid address`). So a live write during the enemy's life corrupts one segment's
block. **The spawn-lifecycle fix (Registry::MakeEntity / BecomeInteresting) will NOT fix this** (the
workflow's own gate: "entered once => steps 1-5 do not fix it").
- **Source still unpinned, but these are RULED OUT:** the dead-`Simulate` raw-offset stomps (never called);
double-destruction; subsystem writes past `sizeof(Mech)=0x638`; the segmentTable POINTER (valid at death).
What remains: a live heap-corruptor of a segment block (candidate: the damage path mechdmg.cpp:628 which
indexes segments while the enemy takes its 8 hits) — and it may not even be enemy-specific.
**⭐ P5 PREMISE WAS WRONG — dead mechs are SUPPOSED to stay (verified).** In the real game a killed mech does
NOT vanish: RP's death path `VTV::DeathShutdown` (VTV.cpp:1681-1691) loops subsystems calling `DeathShutdown`
(the vehicle shuts down but is NEVER removed); `CondemnToDeathRow`/entity-removal in RP is used ONLY for
transient objects (RIVET.cpp projectiles, DEMOPACK.cpp cleanup), never for a combat kill. BT death is a
STATE/VISUAL transition — `SetGraphicState(DestroyedGraphicState)` (mechdmg.cpp:355) + a death animation
(`deathAnimationLatched@0x650`, mech.hpp:505) + death effect/splash. So the mech becomes a wrecked HULK and
stays. ⇒ **Our current wreck-stays behavior IS the faithful one; `DestroyEntityMessage`-on-death is NOT a
real behavior and should never be enabled.** The teardown crash is an artifact of forcing a removal the
original never does (behind `BT_ENABLE_TEARDOWN`). There is NOTHING to fix here for faithfulness. The real
future "death" work is the OPPOSITE of removal: reconstruct the DeathShutdown sequence + collapse animation
+ destroyed skin — none of which touch the crashing teardown path.
**RECOMMENDATION: CLOSE P5 (not just park).** The wreck-stays death (explosion + stop-targeting) ships and
combat is 100% unaffected; the crash only exists behind `BT_ENABLE_TEARDOWN`. Pinning the live segment-corruptor needs
either gflags **PageHeap** (elevation — catches the overflow AT the write) or a `ba w4` drill on a segment
block, i.e. another deep session for a cosmetic "wreck vanishes" win. Better to spend the effort on P3
(locomotion cutover) and revisit this opportunistically (e.g. run once under PageHeap when elevation is available).
**(superseded) earlier next step:** hardware write-breakpoint (`ba w4`) on segment #2's block to catch the FIRST free
(which table/dtor frees it) — capture its address at spawn, set the bp, run to the free. That names the exact
first-owner and the fix (make that table release-not-delete, or de-duplicate the segment registration).
**Pragmatic alternative:** the wreck-stays behavior (explosion + stop-targeting) already ships and combat is
unaffected; full `DestroyEntityMessage` removal can stay deferred behind `BT_ENABLE_TEARDOWN` until the
segment-ownership model is reconciled (a bounded but genuinely deep engine-archaeology task).
---
### (superseded) earlier hypothesis — base-region stomp
Reproduced under cdb behind `BT_ENABLE_TEARDOWN=1` (mech4.cpp, default OFF, parallel to the working stand-in).
**Findings (all verified, not inferred):**
- The documented cause "collision solids never built" is **WRONG**. `Mover::Mover` (MOVER.cpp:1756) allocates
`collisionLists = new BoxedSolidCollisionList[2]` unconditionally; the `[teardown]` log shows the enemy's
`collisionLists@0x2e4 = 0x0CCBCF8C` (a valid heap ptr) at death.
- `collisionLists` sits at **Mover+0x2e4** (from `dt btl4!Mover`), inside the engine collision cluster
`collisionVolumeCount@0x2d4 · collisionVolume@0x2d8 · collisionTemplate@0x2dc · containedByNode@0x2e0 ·
collisionLists@0x2e4 · lastCollisionList@0x2e8 · collisionAssistant@0x2ec`. `sizeof(Mover)=0x2f0`,
`JointedMover=0x318`, `Mech=0x638`.
- The reconstructed Mech's **declared** fields for these are safely relocated (`netOrientation@0x3ec`,
`arrivalTime@0x500`, `torsoAimTarget@0x3e0`) — BUT `Mech::Simulate`/terrain/heat code in mech4.cpp still
uses **stale RAW binary offsets** `this+0x2d4 / +0x2e0 / +0x2e8 / +0x2ec / +0x2f0` that land ON the engine
collision cluster. It READS them (garbage) and DEREF-WRITES through them (e.g. mech4.cpp:1020
`*(this+0x2ec)+0xc = heatCapacity` writes through the engine `collisionAssistant` ptr). `physicsBody/
groundRef/groundCell` aren't even declared members — they exist ONLY as these raw offsets.
- Result: the engine base region is corrupted during the enemy's life; teardown crashes at **varying**
base-member sites (cdb_dmg5: `~Mover`:2050 `delete[] collisionLists`, count cookie `0xdddddddb`; this run:
`~JointedMover`:444 deleting a `0x0ccd1210` uninit member). It's the **base-region layout divergence**
between the 1995 BT binary (raw offsets) and the 2007 RP411 engine base — the shared P3/P5 prerequisite.
**Remaining P5 fix (the real work):**
1. **Base-region audit of mech*.cpp raw offsets:** enumerate every `this+0xNN` for `0xNN < 0x318` (engine
base), map each against the `dt btl4!Mover`/`JointedMover` layout, and convert stomping accesses to the
correct engine field or the relocated declared member. The `0x2d4-0x2f0` set is the known-bad start;
audit the whole base range (there are likely more, given `Mech::Simulate` is raw-offset-dense).
2. **Complete the minimal spawn** so the enemy's engine base members are all initialized (no `0xCD`).
3. **Route death through `DestroyEntityMessage → FryDeathRow`** once teardown is clean (env flag → default).
Effort: the audit is mechanical but broad (Simulate/terrain/heat are raw-offset-dense) — days-to-weeks.
Do it behind `BT_ENABLE_TEARDOWN` until green, since the shipped combat loop works by bypassing teardown.
## ✅ CLOSED — the BGF-load heap corruption (`bld08.bgf` / `Builder::~Builder` AV)
**Symptom:** mid-mission `LoadBgfFile("bld08.bgf")` → `Builder::~Builder` → `vector<float>` teardown → AV
inside `operator delete` (ntdll `RtlpFreeHeap` dereferencing `0xDDDDDDDD`), position-dependent (one combat
run crashed; walk-only and differently-routed runs were clean).
**Root cause (reconstruction TYPE CONFUSION, found by a 4-agent workflow + forensics):**
`HeatSink`'s ctor resolved its linked sink via `owner->GetSegment(heatSinkIndex)` — the Nth skeleton
**EntitySegment** (288 bytes compiled) — cast to `Subsystem*`/`HeatSink*`. The binary (`@004adda0`,
part_012.c:16999) reads **`owner->subsystemArray[heatSinkIndex]`** (the subsystem ROSTER @0x128,
bounds-checked vs subsystemCount @0x124, null-guarded before `linkedSinks.Add`). Through the bogus pointer,
every per-frame `ConductHeat` wrote `other->pendingHeat` at compiled offset 388 = **100 bytes past the
288-byte EntitySegment heap block** (and `BalanceCoolant` wrote `coolantLevel` +20 past) — thousands of
4-byte OOB writes during sustained fire, smashing NT free-list metadata adjacent to the mech's segments.
The BGF loader's big vector alloc/free churn merely DETECTED it later. Sibling bug: `PoweredSubsystem`'s
`voltageSourceIndex` (res+0xFC; raw part_013.c:1198 = the same roster lookup) was also GetSegment-resolved,
so `AttachToVoltageSource` wrote `currentTapCount` 136 bytes past the segment block at every mech spawn.
**Exoneration sweep (all CONFIRMED):** the loader itself — an exact Python mirror over all 879 content BGFs,
zero anomalies; the crashed 0x768 block = exactly the 474-float MSVC growth capacity for bld08's 472 verts
(healthy vector); every hard-coded placement-new alloc ≥ compiled sizeof (probe-compiled); Mech spawns use
`sizeof(Mech)` (immune to growth); Explosion churn is pure engine code; `Mech::Simulate` over-sizeof writes
are dead code; LoadLocomotionClips postdates the crash (timeline via /tmp mtimes) and its
`keyframeData[keyframeCount]` read is binary-faithful.
**Fix (heat.cpp + powersub.cpp):** both resolutions replaced with the binary's roster lookup via the public
`owner->GetSubsystemCount()`/`GetSubsystem(i)` (pre-checked so the engine Verify never fires; roster is
pre-zeroed so forward refs read NULL = the binary's "missing" warn path). Powersub's else-gate also fixed to
OWNER flags per raw. Payoff: the heat link now reaches a REAL sink (`heatEnergy=1.34e+07`, was ~0).
**Verification:** (1) `BT_HEAPCHECK=1` (new runtime gate, btl4main.cpp: `_CRTDBG_CHECK_ALWAYS_DF` whole-heap
validation on every alloc/free) through 100+ shots — ZERO detections (pre-fix, the first ConductHeat write
would trip it). (2) The im2 scenario re-run fast: **3601 shots + 10.6 km walked** — no AV. (3) `BT_PROBE_BGF`
(new: direct-load models at boot; `=ALL` sweeps every GEO model) — bld08 clean 25×.
**Durable lesson (systemic checklist entry):** an owner offset `+0x128` in subsystem raw decomp is the
subsystem ROSTER (`subsystemArray`), NOT the segment table — audit every `GetSegment(int)` call in
reconstructed subsystem ctors (only these two existed; both fixed).
## P7 — Heat-leaf subsystem byte-exactness (the systemic layout gap) ⭐ NEW (root-caused this session)
The reconstruction's **heat-leaf branch** (`HeatableSubsystem:MechSubsystem` -> `HeatSink` -> `PoweredSubsystem`
-> the weapon/sensor leaves) is **NOT byte-exact to the binary** -- measured: `PoweredSubsystem::auxScreenNumber`
compiles to **0x194** but the binary has it at **0x1DC** (72 bytes short). Consequence: any code reading a
heat-leaf subsystem field at a RAW binary offset above the gap gets garbage (this is what forced the aux-screen
BRIDGE in vehicleSubSystems, and the Sensor RadarPercent gauge stub, and blocks authentic data on the
engineering-screen cluster panels' secondary lamps). Fixing it byte-exact makes ALL raw reads correct
everywhere -> a high-leverage foundational fix.
**Root cause (measured + decompiled this session, authoritative):**
- `MechSubsystem` base + `HeatableSubsystem` (currentTemperature@0x114 / degradationTemperature@0x118 /
failureTemperature@0x11C / heatLoad@0x120) are byte-exact.
- `HeatSink` accumulates a **+16 over-allocation** from two RECONSTRUCTION LAYOUT ERRORS:
1. HeatSink RE-DECLARES `degradationTemperature`/`failureTemperature` that already exist in HeatableSubsystem
(a shadow/duplicate, +8).
2. `heatState`/`heatModelFlag` are modeled as SEPARATE HeatSink fields (+8) but in the binary they are
**INSIDE the heatAlarm** -- `heatState` is read at `this[0x61]==0x184 == heatAlarm@0x170 + 0x14` (the
alarm's level field). So they are modeling errors: heatState reads should be `heatAlarm.GetLevel()`.
- Then two **type-size under-allocations**: `SubsystemConnection` is 4B but the binary's is **0xC** (built by
`FUN_004af9cf`); `HeatAlarm` (heat.hpp) is 8B but the binary's alarm at 0x170 is a **0x54 `AlarmIndicator`**
(ctor `FUN_0041b9ec` @part_002.c:4767 = base `FUN_004178cc` + three 0x14-byte `FUN_0041c42c` sub-objects at
+0x18/+0x2C/+0x40, ending 0x54). `HeatFilter` is ALREADY 0xC (byte-exact -- NO reimplement needed; earlier
fear of a behavior-risk filter change was WRONG).
- Net at `PoweredSubsystem::auxScreenNumber`: +8 (dup temps) +8 (spurious heatState/heatModelFlag) -8
(SubsystemConnection) -76 (HeatAlarm) -4 (a PoweredSubsystem delta) = **-72**.
**The fix = a byte-exact re-base of the heat-leaf chain:** remove the duplicate temps; remove the spurious
heatState/heatModelFlag and re-point their reads to `heatAlarm.GetLevel()`; grow `SubsystemConnection` 4->0xC
and `HeatAlarm` 8->0x54 (as shared 0x54/0xC types used everywhere the binary has them, WITHOUT breaking the
already-byte-exact Watcher branch which uses separate `WatcherGaugeAlarm`/`WatchedConnection`); fix the
PoweredSubsystem -4. It touches the core classes every heat/power/weapon subsystem derives from (large blast
radius) + includes CODE changes (heatState), so it needs a full at-risk-read audit (raw `this+0xNN` reads
calibrated to the CURRENT compiled layout would break) + exhaustive combat/heat verification. Safety revert
point tagged `pre-heatleaf-rebase`. The `heat-leaf-layout-audit` workflow derives the full plan.
MEASUREMENT TECHNIQUE (reusable): a `template<int N> struct HSOff;` + `friend struct XProbe;` +
`struct XProbe { HSOff<offsetof(Class, field)> a; ... };` -- the "uses undefined struct 'HSOff<NNN>'" build
error reports each field's COMPILED offset in decimal. Compare to the binary offset from the ctor decomp.
### P7 — STATUS: the CORE heat leaf is DONE + byte-exact; PoweredSubsystem + the weapon subtree are a larger follow-up
**✅ DONE this session (byte-exact, static_assert-LOCKED, combat+heat verified):** `HeatSink`,
`HeatableSubsystem`, `Condenser`, `Reservoir`, `Generator` (a HeatSink subclass, alloc 0x250) + `Myomers`
(phantom tail removed). The shared types are now correct: **`SubsystemConnection` = 0xC** (was 4; the binary
`FUN_004af9cf` link node, `FUN_00417ab4` two-level derefs +8) and **`GaugeAlarm54` = 0x54** (the real
`AlarmIndicator` from `FUN_0041b9ec`; its STATUS level is at **+0x14**, so `subsystem+0x184 == heatAlarm+0x14
== GetLevel()`; `WatcherGaugeAlarm` is now a typedef of it). Confirmed from the ctors: `HeatSink` heatEnergy@0x158,
linkedSinks@0x164, heatAlarm@0x170, resource@0x1C4, pendingHeat@0x1C8, sizeof **0x1D0** (ctor @004adda0);
`Condenser` valveState@0x1D0, condenserAlarm@0x1DC (ctor @004ae568); `Reservoir` reservoirAlarm@0x1D0,
sizeof **0x230** (raw @4aef78); `Generator` stateAlarm@0x1FC, sizeof **0x250** (ctor @004b225c). Heat conduction
now reads the REAL `heatEnergy=1.34e7` (not garbage); combat DESTROYED-in-8, 0 crashes, heapcheck-clean through
construction. `heatState`/`heatModelFlag`/`field_1d0` deleted (reads -> `heatAlarm.GetLevel()` / `coolantActive`);
several **alias-field bugs** fixed the same way (Condenser `refrigerationOutput`==inherited `massScale@0x160`;
Reservoir `coolantCapacity`==inherited `thermalCapacity@0x128`, `injectActive`==`reservoirAlarm.GetLevel()`) and
**phantom fields** removed (Generator `shortFlag@0x25C` -- really `*(owner+0x190)+0x25c`, the msg-manager, raw
@004b0efc; Myomers `moverConnection@0x110` -- a write-only base slot).
**✅✅ SUBSYSTEM-TREE ALARM UNIFICATION -- DONE (P7 CLOSED; the whole PoweredSubsystem weapon/power subtree is
byte-exact).** Making **`PoweredSubsystem`** byte-exact (its two 0x54 alarms `electricalStateAlarm@0x264`/
`modeAlarm@0x2B8`) grows it **+0x98**, cascading into EVERY subclass -- `MechWeapon`, `Emitter`, `PPC`, `Myomers`,
`Sensor` -- which ALL modeled the binary 0x54 `AlarmIndicator` with 4-byte `ReconAlarm`/8-byte `HeatAlarm`
stand-ins and were short + phantom-tailed. All byte-exacted TOGETHER in one build (an 8-agent read-only
decomp-mapping workflow decoded every ctor first; full map in scratchpad/alarm_unify_maps.txt), each
`static_assert`-locked against its ctor + factory alloc. The verified chain:
HeatSink 0x1D0 -> **PoweredSubsystem 0x31C** (retype 2 alarms; ctor @004b0f74) ->
**MechWeapon 0x3F0** (weaponAlarm@0x350 ReconAlarm->GaugeAlarm54; delete 5 phantom tail fields; ctor @004b99a8) ->
**Emitter 0x478** (delete outputVoltage/beamLengthRatio/firingArmed aliases + beamHit*/beamColor/beamHitData/
energyRampTime phantoms; retype beamOrientation EulerAngles->Quaternion 16B; ctor @004bb120) ->
**PPC 0x478** (no own fields; ctor @004bb888);
PoweredSubsystem -> **Sensor 0x328** (no alarm, 3 own fields; ctor @004b1d18);
PoweredSubsystem -> **Myomers 0x358** (no alarm; ctor @004b8fec).
**Non-layout fixes in the same wave:** (a) `outputVoltage`->`rechargeLevel` also in compiled `GAUSS.CPP:74/93`
(external readers of the removed Emitter field -- grep EVERY TU); (b) `MechWeapon::GetMuzzlePoint` reimplemented
(the removed `muzzlePoint` collided with Emitter's own fields) via a `BTResolveWeaponMuzzle` void* bridge in
mech4.cpp resolving the weapon's mount segment (`this+0xdc`) through the owner segment table; (c)
`DetachFromVoltageSource` fixed to set `electricalStateAlarm` not `modeAlarm` (raw @004b0e30 writes 0x264).
VERIFIED: combat DESTROYED-in-8, 28 shots, 0 crashes, heat `heatEnergy=1.34e7`, all locks pass, heapcheck-clean
through construction (the exact phase the isolated PoweredSubsystem retype had overflowed). **The vehicleSubSystems
aux-screen gauge raw reads (`btl4gau2.cpp:868/952` at `subsystem+0x2b8`/`+0x278`) + Sensor RadarPercent now read
the CORRECT byte offsets** (garbage under the short layout). ⚠ LESSON: a factory-bridge `runtime Check(sizeof<=alloc)`
does NOT fail the build -- use a `static_assert` sizeof lock to catch alloc overflow at COMPILE time.