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>
25 KiB
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) allocatescollisionLists = 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, zeroSOCK_DGRAM). Already reimplemented, not greenfield. - ❌ P4 claim REFUTED: agent claimed
/FORCEswallows ~10-15 genuine runtime unresolveds (e.g.Mech::WorldToLocal). The linker emits EXACTLY 40 unresolveds = 20DefaultData+ 20CreateStreamedSubsystem(dead offline factory), zero runtime symbols.WorldToLocalresolves 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_TEARDOWNdump):collisionLists@0x2e4 =0D8C8C04,segmentTable@0x2f0=00872CE8(segmentCount51),jointSubsystem@0x30c=00872D68all valid;lastCollisionList/collisionAssistantNULL (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) andFeedHeat*Gauge(writes through+0x2ec) are DEFINED but NEVER CALLED (grep-confirmed; mech4.cpp:858 "our drivable override bypasses the unsafe Mech::Simulate"). So the0x2d4-0x2f0stomps and the0x7e0-0x828over-sizeof writes do not run — they are not the cause. - The crash is in the teardown SEQUENCE (
FryDeathRow → ~Mech → ~JointedMover → ~Mover): cdb_dmg5 showscollisionLists's array already freed (0xddcookie /count=0xdddddddb) by the time~Mover'sdelete[]runs — a double-free / destruction-order bug (crash site varies run-to-run:~MovercollisionLists, or~JointedMoverdeleting a0x0ccd1210value). ~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.
collisionListsis LIVE at~Mechentry (*clnot0xDD) — ruling out the collision path.- Real crash stack:
~JointedMover(JMOVER.cpp:436SegmentTableIterator(segmentTable).DeletePlugs()) →SocketIterator::DeletePlugs(SOCKET.cpp:157-161delete plug) →EntitySegmentscalar-deleting-dtor →_free_base→STATUS_HEAP_CORRUPTION(0xC0000374) with0xFEEEfreed-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-deletes 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 minimalMech::Makespawn), the normal release already freed them →DeletePlugsdouble-frees. (Earlier~MovercollisionLists0xDDcrash 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
~Mechbody (mech.cpp:952-1008) does NOT explicitly freesubsystemArrayor segments. - No live overflow past
sizeof(Mech)=0x638(the over-sizeof writes are all in deadSimulate).
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)) andjointTable(owns Joints,(NULL,False)) are DISJOINT —DeletePlugson 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::~JointedMovershows 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.DeletePlugsfrees 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-
Simulateraw-offset stomps (never called); double-destruction; subsystem writes pastsizeof(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) allocatescollisionLists = new BoxedSolidCollisionList[2]unconditionally; the[teardown]log shows the enemy'scollisionLists@0x2e4 = 0x0CCBCF8C(a valid heap ptr) at death. collisionListssits at Mover+0x2e4 (fromdt btl4!Mover), inside the engine collision clustercollisionVolumeCount@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) — BUTMech::Simulate/terrain/heat code in mech4.cpp still uses stale RAW binary offsetsthis+0x2d4 / +0x2e0 / +0x2e8 / +0x2ec / +0x2f0that land ON the engine collision cluster. It READS them (garbage) and DEREF-WRITES through them (e.g. mech4.cpp:1020*(this+0x2ec)+0xc = heatCapacitywrites through the enginecollisionAssistantptr).physicsBody/ groundRef/groundCellaren'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:2050delete[] collisionLists, count cookie0xdddddddb; this run:~JointedMover:444 deleting a0x0ccd1210uninit 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):
- Base-region audit of mech.cpp raw offsets:* enumerate every
this+0xNNfor0xNN < 0x318(engine base), map each against thedt btl4!Mover/JointedMoverlayout, and convert stomping accesses to the correct engine field or the relocated declared member. The0x2d4-0x2f0set is the known-bad start; audit the whole base range (there are likely more, givenMech::Simulateis raw-offset-dense). - Complete the minimal spawn so the enemy's engine base members are all initialized (no
0xCD). - Route death through
DestroyEntityMessage → FryDeathRowonce 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):
MechSubsystembase +HeatableSubsystem(currentTemperature@0x114 / degradationTemperature@0x118 / failureTemperature@0x11C / heatLoad@0x120) are byte-exact.HeatSinkaccumulates a +16 over-allocation from two RECONSTRUCTION LAYOUT ERRORS:- HeatSink RE-DECLARES
degradationTemperature/failureTemperaturethat already exist in HeatableSubsystem (a shadow/duplicate, +8). heatState/heatModelFlagare modeled as SEPARATE HeatSink fields (+8) but in the binary they are INSIDE the heatAlarm --heatStateis read atthis[0x61]==0x184 == heatAlarm@0x170 + 0x14(the alarm's level field). So they are modeling errors: heatState reads should beheatAlarm.GetLevel().
- HeatSink RE-DECLARES
- Then two type-size under-allocations:
SubsystemConnectionis 4B but the binary's is 0xC (built byFUN_004af9cf);HeatAlarm(heat.hpp) is 8B but the binary's alarm at 0x170 is a 0x54AlarmIndicator(ctorFUN_0041b9ec@part_002.c:4767 = baseFUN_004178cc+ three 0x14-byteFUN_0041c42csub-objects at +0x18/+0x2C/+0x40, ending 0x54).HeatFilteris 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'" 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.