Files
BT412/docs/SUBSYS_PLAN.md
arcattackandClaude Opus 4.8 8b36440d05 subsystems: un-stub Wave 4 readouts + Wave 6 Myomers (10->15 factory cases)
WAVE 4 (standalone readouts) -- Sensor(0xBC3)/Searchlight(0xBD8)/
ThermalSight(0xBDE)/AmmoBin(0xBCB) un-stubbed via Create<Class>Subsystem
bridges + Torso-style de-shim (drop cross-family shadow fields, redirect
accessors to the real inherited base, static_assert layout locks).
- FIX: Searchlight/ThermalSight ctors gated their Performance on the shadow
  segmentFlags(=0) so it NEVER installed; switched to owner->simulationFlags.
- AmmoBin: retype ammoAlarm HeatAlarm->WatcherGaugeAlarm(0x54) + drop the
  statusState@0x40 shadow -> exact 0x22C layout.
- Guard HeatWatcher::WatchSimulation against the unresolved watchedLink
  (null-deref exposed once these sims run; faithful fix = resolve the link).
- Heat-leaf branch (Sensor) is not byte-exact -> overflow-lock only.

WAVE 6 (Myomers 0xBC6, mover-coupled) -- structural un-stub, gated
BT_MYOMERS (default on; =0 -> Actuator stub). Wired INERT: MyomersSimulation
early-returns (advanced-damage gate stubbed) + no-op mover feed, so the live
JointedMover is untouched and the gait cannot regress. De-shim drops the
owner*/segmentFlags shims to fit the exact-0x358 alloc. Authentic mover/heat
coupling deferred (needs messmgr 0xBD3 + reconciling the mover feed with the
gait cutover).

Verified: BLH tick 20->27, Mad Cat 24, combat DESTROYED un-regressed,
locomotion un-regressed, 0 crashes, 0 heap detections under BT_HEAPCHECK.

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

218 lines
32 KiB
Markdown

I have verified the factory switch (mech.cpp 556-741), the stub block (145-181), the corrected ClassID map (CLASSMAP.md), and the heat base re-declarations (heat.hpp 168-242 vs mechsub.hpp 120-255). The findings are corroborated by the source. Here is the unified spec.
---
# UN-STUB THE BATTLETECH SUBSYSTEM ROSTER — SYNTHESIS PLAN
Ground truth used throughout: **the ctor address in each factory case's `// FUN_004xxxxx` comment, reconciled via `CLASSMAP.md`** — NOT the `case <Name>ClassID` enum label, which is systematically mislabeled. The numeric classID is correct; the label and stub-struct name are wrong.
## Master case → real-class table (authoritative; from mech.cpp 567-689 + CLASSMAP)
| factory case (line) | classID | ctor | alloc | current stub | **REAL class** | file (status) | special action |
|---|---|---|---|---|---|---|---|
| CockpitClassID (569) | 0xBBD | 4ae568 | 0x230 | `Cockpit` | **Condenser** | heatfamily_reslice (DONE) | — |
| SensorClassID (574) | 0xBBE | 4ae8d0 | 0x1e4 | `Sensor` | **HeatSink aggregate/bank** | heatfamily_reslice (`#if 0` skeleton) | `sensorSubsystem=arr[id]` (0x1f7) = the heat bank, NOT a sensor |
| CondenserClassID (580) | 0xBC0 | 4af408 | 0x230 | `Condenser` | **Reservoir** | heatfamily_reslice (DONE) | — |
| GeneratorClassID (585) | 0xBC1 | 4b225c | 0x250 | `Generator` | **Generator** | powersub.cpp (DONE) | — |
| PoweredSubsystemClassID (590) | 0xBC2 | 4b0f74 | 0x31c | `PoweredSubsystem` | **PoweredSubsystem** | powersub.cpp (DONE) | — |
| MyomersClassID (595) | 0xBC3 | 4b1d18 | 0x328 | `Myomers` | **Sensor** | sensor.cpp (DONE) | — |
| GyroClassID (600) | 0xBC4 | 4b3778 | 0x3d0 | `Gyro` | **Gyroscope** | gyro.cpp (DONE) | `gyroSubsystem=arr[id]` (0x14a) |
| SinkSourceClassID (606) | 0xBC5 | 4b6b0c | 0x280 | `HeatSinkSource` | **Torso** | torso.cpp (DONE) | `sinkSourceSubsystem=arr[id]` (0x10e) |
| ActuatorClassID (612) | 0xBC6 | 4b8fec | 0x358 | `Actuator` | **Myomers** | myomers.cpp (DONE) | — |
| WeaponEmitterClassID (617) | 0xBC8 | 4bb120 | 0x478 | `EmitterWeapon` | **Emitter** | emitter.cpp (DONE) | `++weaponCount` |
| JumpJetClassID (623) | 0xBCB | 4bd5c4 | 0x22c | `JumpJet` | **AmmoBin** | ammobin.cpp (DONE) | — |
| MechWeaponClassID (628) | 0xBCD | 4bc3fc | 0x448 | `MechWeapon` | **ProjectileWeapon** | projweap.cpp (DONE) | `++weaponCount` |
| MissileWeaponClassID (634) | 0xBCE | **4bdcb4** | 0x484 | `MissileLauncher` | **UNRESOLVED** (no CLASSMAP entry) | — | `++weaponCount`**BLOCKED** |
| BallisticWeaponClassID (640) | 0xBD0 | 4bcff0 | 0x44c | `BallisticWeapon` | **MissileLauncher** | mislanch.cpp (DONE) | `++weaponCount` |
| MechControlsMapperID (646) | 0xBD3 | 49bca4 | 0x130 | `MechControlsMapper` | **SubsystemMessageManager** | messmgr.cpp (DONE) | `controlsMapper=arr[id]` (0x10d) — verify, see Risk 4 |
| GaussWeaponClassID (652) | 0xBD4 | 4bb888 | 0x478 | `GaussRifle` | **PPC** (Emitter subclass) | emitter.cpp bridge | `++weaponCount` |
| MechTechClassID (658) | 0xBD6 | 4b7f94 | 0x2a4 | **MechTech (real, MIS-WIRED)** | **HUD** | hud.cpp (DONE) | `mechTechSubsystem=arr[id]` (0x16d) — see Risk 4 |
| LegSubsystemClassID (664) | 0xBD8 | 4b84dc | 0x238 | `LegSubsystem` | **Searchlight** | searchlight.cpp (DONE) | — |
| HeatableClassID (669) | 0xBDC | 4ad228 | 0x140 | `HeatableSubsystem` (**ptr discarded — bug**) | **MechTech** | mechtech.cpp (DONE) | must `arr[id]=…` + cache mechTech |
| DisplayClassID (679) | 0xBDE | 4b8718 | 0x234 | `MechDisplay` | **ThermalSight** | thermalsight.cpp (DONE) | — |
There is **NO real Cockpit / Sensor(@0xBBE) / Actuator / JumpJet / BallisticWeapon / LegSubsystem / MechDisplay class** — every one of those stub names is a mislabel for a different, already-reconstructed class.
Two confirmed wiring bugs visible in the source: (a) **case 0xBDC builds `HeatableSubsystem` and never stores the pointer** (line 676 has no `subsystemArray[id] =`); (b) **the real `MechTech` class is instantiated at case 0xBD6** (line 660, alloc 0x2a4 = HUD's size) — MechTech and HUD are swapped.
---
## 1. THE STUB → REAL SWAP RECIPE (general)
**Architecture decision (applies to every family): use a per-family factory-bridge function, do NOT `#include` the real subsystem headers into mech.cpp.** mech.cpp defines local bring-up stubs whose names collide with the real classes (`Condenser`, `Generator`, `PoweredSubsystem`, `Myomers`, `MechWeapon`, `MissileLauncher`, …). Including the real headers there is a redefinition storm. Instead:
**Per real class, in its own `.cpp` (which already includes its real header):**
```cpp
Subsystem *
Create<Class>Subsystem(Mech *owner, int id, void *seg)
{
Check(sizeof(<Class>) <= SIZE); // SIZE = the factory alloc literal (binary's true size)
return (Subsystem *) new (Memory::Allocate(SIZE))
<Class>(owner, id, (<Class>::SubsystemResource *)seg
/*, <Class>::DefaultData [, extra real ctor params] */);
}
```
- `SIZE` = the exact `Memory::Allocate(0x…)` literal already in the factory case (these are the binary's real allocation sizes — keep them).
- The `Check(sizeof <= SIZE)` guard catches a placement-new overrun, which is the source of the intermittent ~1-in-6 startup heap-corruption crash (base ctor writes `vitalSubsystemIndex@0x110` → object must be ≥ 0x114).
- The trailing `<Class>::DefaultData` matches the real ctor signature `(Mech*, int, SubsystemResource*, SharedData& = DefaultData [, …])`. Where the factory already passes `…::DefaultData`/`,0,0`, preserve those real tail params.
**In mech.cpp:**
```cpp
extern Subsystem *Create<Class>Subsystem(Mech *, int, void *); // near the forward-decls
...
case <Name>ClassID: // keep the numeric classID; the LABEL stays as-is
subsystemArray[id] = Create<Class>Subsystem(this, id, seg);
/* preserve ++weaponCount and/or the special cache (sensorSubsystem=…/gyroSubsystem=…) */
break;
```
Then delete the now-dead `RECON_SUBSYS(<StubName>);` line for that class. Leave a `RECON_SUBSYS` only where no reconstruction exists yet (the 0xBCE @4bdcb4 case).
### The 4 systemic checks — apply to EVERY real class before/while swapping
1. **Shadowing (re-declared base fields).** Grep the class + its bases for fields that already exist in the engine `Subsystem`/`Simulation` chain or in the reconstructed BT base (`MechSubsystem`/`HeatableSubsystem`/`HeatSink`/`PoweredSubsystem`): `owner`/`hostEntity` (→ `Subsystem::owningEntity`, use a `GetEntity()` cast), `damageZone` (→ engine `Subsystem::damageZone`), `simulationState`/`destroyed` (→ `MechSubsystem::simulationState`), `simulationFlags`/`flags`/`statusFlags`, `subsystemID`/`subsystemId2`, `subsystemName`, `lastPerformance`/`lastUpdate`/`updateModel`, `sharedData`, `classID`. **Delete the re-declaration; use the inherited member/accessor.** A shadow both (a) leaves the engine's value unread (the recon copy is `0xCDCDCDCD`) and (b) lands at the wrong compiled offset. **NOT a shadow (leave as is):** each class's own `typedef … Performance;` + `SetPerformance(Performance)` (deliberately writes the inherited `activePerformance`), its own `typedef … SubsystemResource;`, and `static …DefaultData`. After de-shadowing, confirm `sizeof(Class) ≤ alloc SIZE`.
2. **`Wword(N)` trap.** `mechrecon.hpp:192` `Wword(i)` is a global scratch bank (`bank[i&0x3ff]`), NOT `this+i*4`. Grep the class body for `Wword(` used as object/pointer access → replace with the named inherited member or an engine accessor (e.g. owner via `(Mech*)GetOwningSimulation()`). The heat/gyro/sensor/torso/myomers/powersub bodies are already clean here — keep them that way; `Wword` use is a mech.cpp-only concern.
3. **MessageHandler chaining.** Each class's handler set must chain to its parent: `Receiver::MessageHandlerSet X(Parent::GetMessageHandlers());`. An empty/parentless set makes `Receiver::Receive` find no handler → inherited messages (TakeDamage, InjectCoolant, ToggleLamp, Jammed, …) silently dropped. Gyroscope/Sensor/Myomers and all heat classes currently define `GetClassDerivations` but an empty/missing `GetMessageHandlers` — fix on swap.
4. **Performance install + validity.** Confirm the real ctor calls `SetPerformance(&Class::XxxSimulation)` **under the live-primary gate** (`(owner->simulationFlags & 0xC)==0`, some also require `& 0x100`). Without it, `activePerformance` stays `&Simulation::DoNothingOnce`, which on its first tick calls `NeverExecute()` → sets `DontExecuteFlag` → the subsystem is skipped forever (this is exactly why a stub "ticks once then goes silent"). Entity-validity for message delivery rides the mech's validity (already handled in bring-up).
**Tick is already wired** — no new plumbing. `mech4.cpp Mech::PerformAndWatch` (~825-871) walks `subsystemArray[0..subsystemCount-1]`, and for each `s->IsNonReplicantExecutable()` calls `s->PerformAndWatch``Simulation::PerformAndWatch` (SIMULATE.cpp:448) → `Perform(slice)``(this->*activePerformance)(slice)`. Installing `activePerformance` (check #4) is the entire difference between a stub and a running subsystem.
**Build/verify each swap:** `cmake --build C:/git/nick-games/btbuild/build --config Debug`; single file `C:/git/nick-games/btbuild/compile1.cmd <file>.cpp`. Watch the `[tick] first frame: dispatched to N … of M present` log — N should climb toward M (minus the bypassed `controlsMapper`) as stubs become real.
---
## 2. RECOMMENDED ORDER (rationale + dependencies + blocked)
The whole roster sits on the BT base chain `engine Subsystem → MechSubsystem → HeatableSubsystem → HeatSink → {PoweredSubsystem, Generator, Condenser, Reservoir, Bank}` and `PoweredSubsystem → {PowerWatcher → Gyro/Torso/HUD/Searchlight/ThermalSight, MechWeapon → Emitter/PPC, ProjectileWeapon → MissileLauncher}`. So the **heat base re-base is the universal prerequisite** — it must be correct before any leaf is trustworthy. This aligns with the user mandate (heat first).
- **WAVE 1 — Heat base re-base + de-shadow (PREREQUISITE, no factory change).** Reconcile `mechsub.hpp``heat.hpp`; make `HeatableSubsystem : MechSubsystem`; delete shadow fields; fix the thermal offsets. Compile `heat.cpp`, `heatfamily_reslice.cpp`, `mechsub.cpp` green. **Unblocks every family.**
- **WAVE 2 — HEAT family + the MechTech/HUD wiring fix (user: "heat first").** Un-stub Condenser (0xBBD), Reservoir (0xBC0), aggregate sink (0xBBE). Fold in the MechTech↔HUD swap (0xBDC→MechTech with store + cache; 0xBD6→HUD) because the 0xBDC case currently instantiates the heat base and discards the pointer — it has to change here anyway, and it validates the one already-real class in its correct slot. Verify heat builds/dissipates.
- **WAVE 3 — Energy weapons (user: "emitter/PPC second").**
- **3a (dependency):** power bus — Generator (0xBC1) + PoweredSubsystem (0xBC2). Low-risk, foundational; energy weapons need it to charge (`GetVoltageState()==4`).
- **3b:** Emitter (0xBC8) + PPC (0xBD4). Construct+tick+fire underneath; **keep the explosion stand-in for the visual** (beam renderable deferred — see §4).
- **WAVE 4 — Standalone readouts: ✅ DONE + WIRED (live, verified).** Sensor (0xBC3), Searchlight (0xBD8), ThermalSight (0xBDE), AmmoBin (0xBCB) all un-stubbed via `Create<Class>Subsystem` bridges + de-shim + `<Class>LayoutCheck` static_asserts. BLH tick 20→26, Mad Cat 24, combat DESTROYED un-regressed, 0 crashes. KEY finds: (a) Searchlight/ThermalSight ctor gate read the shadow `segmentFlags`(=0) → Performance NEVER installed → fixed to `owner->simulationFlags`; (b) AmmoBin `ammoAlarm` retyped `HeatAlarm``WatcherGaugeAlarm`(0x54) + `statusState`@0x40 shadow deleted; (c) the heat-leaf branch (Sensor's PoweredSubsystem:HeatSink) is NOT layout-exact → overflow-lock only (offset re-base deferred to Phase-4 HUD); (d) shared `HeatWatcher::WatchSimulation` null-deref on the unresolved `watchedLink` GUARDED (faithful follow-up = resolve the watch link). See CLAUDE.md §10d "WAVE 4".
- **WAVE 5 — Torso (0xBC5): ✅ DONE (live, binary-exact layout, zero regression).** The joint I/O
(`Mech::ResolveJoint`/`Torso::PushTwist` + corrected `TorsoSimulation` + per-frame `UpdateJoints()`) AND the
base-chain re-base are complete. Hierarchy is `Torso : PowerWatcher : HeatWatcher : MechSubsystem` (a SEPARATE
branch from the HeatSink leaves — shares only MechSubsystem, so the +156 fix stays in the Watcher branch). Fix:
Watcher-local `WatchedConnection`(0xC) + `WatcherGaugeAlarm`(0x54) in heatfamily_reslice.hpp (never resize the
shared `SubsystemConnection`/`HeatAlarm`); grew HeatWatcher (+84) + PowerWatcher (+72, shadow watchedLink deleted);
Torso shim fields deleted + 0x270 slot added → `currentTwist`@0x1D8, `sizeof==0x280`, compile-time locked
(`TorsoLayoutCheck`/`HUDLayoutCheck` + Watcher-base asserts). Real Torso un-stubbed at 0xBC5; verified no crash,
heat/combat un-regressed (TARGET DESTROYED after 8 hits). Full detail: CLAUDE.md §10d "WAVE-4 Torso base-chain".
NOTE: the torso doesn't visibly twist for the Blackhawk because its 0xBC5 record has `TorsoHorizontalEnabled=0`
(a FAITHFUL result — the binary ctor @004b6b0c gates on it identically); visible twist needs a twist-enabled mech.
**Gyroscope (0xBC4) — layout+joint-I/O DONE, un-stub DEFERRED (reverted to stub).** Shim-delete + accessor-redirect +
node-I/O (backed by the real engine Joint API, incl. FUN_0041d11c=SetTranslation) + ResolveJoint reuse + WriteEye/
MechJoint wiring are done and locked (`GyroLayoutCheck`: exageration@0x1D8, sizeof<=0x3D0). Verified live: gyro
constructs, resolves real joints (jointlocal/jointeye), WriteMechJoint fires — but with GARBAGE (0xCDCDCDCD/NaN)
because the ctor field-init + integrator field-MAPPING are incomplete/incorrect (binary @004b3778: springConstant@0x1E8,
dampingConstant@0x1F4, ...; recon mislabels + leaves accumulators uninit). Reverted to the stub (no NaN to the root
joint). Remaining = a full ctor+integrator reconstruction from @004b3778 (bigger than the torso). Full detail: CLAUDE.md
§10d "WAVE-5 GYROSCOPE". Also: the mech.cpp gyro↔torso cross-link write is broken (SubProxy::linkTarget→gyro+4) — commented out.
- **WAVE 6 — Mover-coupled: ◐ STRUCTURAL un-stub DONE + WIRED (gated `BT_MYOMERS` default ON; verified INERT, no locomotion/combat regression).** Myomers (0xBC6) constructs + ticks its real Performance via `CreateMyomersSubsystem`, but is INERT: `MyomersSimulation` early-returns on `OwnerAdvancedDamage()==False` (real gate = messmgr 0xBD3), and the mover feed (`MoverAttach`) + `SetOwnerMaxSpeed` are no-op stubs so the live JointedMover is never touched. De-shim dropped the `owner*`/`segmentFlags` shim fields (accessors return neutral defaults) to fit the exact-0x358 alloc (`sizeof` lock). ⚠ AUTHENTIC COUPLING DEFERRED: routing `SpeedEffect@0x31C` into the mover + real owner-motion accessors + the advanced-damage heat gate — this DRIVES THE LIVE MOVER, so reconcile with the gait cutover FIRST. BLH tick 26→27; `BT_MYOMERS=0` → Actuator stub. See CLAUDE.md §10d "WAVE 6".
- **WAVE 7 — Projectile/missile weapons:** ProjectileWeapon (0xBCD), MissileLauncher (0xBD0). **PARTIALLY BLOCKED:** case 0xBCE calls `@4bdcb4`, which has no CLASSMAP entry — decompile it first and resolve the 0xBCD/0xBCE/0xBD0 label tangle (Risk 1) before swapping these.
- **WAVE 8 — Message hub:** SubsystemMessageManager (0xBD3). Real, but **verify the cache-slot/tick-bypass interaction** (Risk 4) before enabling its tick.
**DEFER / DO NOT ENABLE:**
- **MechControlsMapper tick** — the real mapper is installed at roster slot 0 by `btl4app::MakeViewpointEntity` (not by this switch); ticking it calls `InterpretControls→FillPilotArray`, reading undocumented WinTesla Application offsets (`application+0x6c→+0x190→pilot+0x1e0`) → **access violation** (mech4.cpp:854-866 deliberately bypasses it). Keep bypassed; this is a Phase-8 input-remap task.
- **0xBCE @4bdcb4** — leave its `RECON_SUBSYS(MissileLauncher)` stub until decompiled.
---
## 3. HEAT FAMILY — detailed ordered edit list
### Step 1 — heat.hpp re-base + de-shadow (PREREQUISITE; do this before any factory edit)
All in `C:/git/nick-games/decomp/reconstructed/heat.hpp` (+ matching touch-ups in `heat.cpp` / `heatfamily_reslice.cpp`). The collisions are confirmed: `mechsub.hpp` already owns `Mech *owner` (line 134), `int simulationState;//@0x40` (251), `ReconDamageZone *damageZone;//@0xE0` (252), `Mech *hostEntity;//@0xE8` (254), `int subsystemId2;//@0xEC` (255), plus `GetStatusFlags@4ac144 / HandleMessage@4ac0bc / ResetToInitialState@4ac1d4 / PrintState@4ac8c0` virtuals — while `heat.hpp` re-declares all of these on top of `: public Subsystem`.
1. **Base change.** heat.hpp:33 — ensure `#include <mechsub.hpp>`. heat.hpp:168-169 — `class HeatableSubsystem: public Subsystem`**`: public MechSubsystem`**.
2. **Delete the shadow fields** (heat.hpp:234-238):
- `Mech *owner` → inherited `MechSubsystem::owner` (or a `Mech* GetEntity(){return (Mech*)Subsystem::GetEntity();}` accessor, matching surviving MECHTECH.HPP).
- `LWord flags` / `LWord statusFlags` (both @0x28) → engine `Simulation::simulationFlags`. Retarget `flags |= 8/2` (Condenser/Reservoir) to `simulationFlags`.
- `LWord statusBits` (@0x60) → engine dirty word; Reservoir `statusBits |= 1``ForceUpdate()`.
- `int destroyed` (@0x40) → `MechSubsystem::simulationState` (DestroyedState); `IsDamaged()` reads it.
3. **Thermal offsets** (heat.hpp:240-241 + HeatSink). CLASSMAP:472 — the `0x114-0x184` block belongs to HeatableSubsystem: keep `currentTemperature@0x114`, **move `degradationTemperature@0x118` / `failureTemperature@0x11C` UP from HeatSink into HeatableSubsystem** between `currentTemperature` and `heatLoad`, so `heatLoad` lands at `@0x120` (today it compiles to 0x118).
4. **Virtual surface** (heat.hpp:192-201) now **overrides** MechSubsystem's real slots — do not add parallel slots. Match the binary signature (watch `MechSubsystem::ResetToInitialState(Logical powered)` vs heat's `ResetToInitialState()`).
5. **HeatSink overlap words** (~heat.hpp:382-412): `valveState@0x1D0`/`field_1d0@0x1D0` and `refrigerationOutput@0x160`/`massScale@0x160` are the SAME binary word reused by subclasses — declare each once in the base it belongs to so subclasses extend rather than re-append.
6. **Handler chains** (systemic #3): define `GetMessageHandlers()` for each heat class to chain to its parent (`Receiver::MessageHandlerSet(HeatableSubsystem::GetMessageHandlers())`, etc.). Today only `GetClassDerivations` is defined → `HeatSink::HandleMessage(1)` and `Reservoir` InjectCoolant are silently dropped.
Verify: `compile1.cmd heat.cpp` and `compile1.cmd heatfamily_reslice.cpp` green; `sizeof(Condenser) ≤ 0x230`, `sizeof(Reservoir) ≤ 0x230`.
### Step 2 — factory bridges (in heatfamily_reslice.cpp)
Add `CreateCondenserSubsystem` (SIZE 0x230), `CreateReservoirSubsystem` (SIZE 0x230), `CreateHeatSinkBankSubsystem` (SIZE 0x1e4) per the §1 template (Condenser/Reservoir take `…::DefaultData`).
### Step 3 — mech.cpp factory edits
- `extern` the three bridges near the forward-decls.
- **Case 0xBBD** (CockpitClassID, 569-572): `subsystemArray[id] = CreateCondenserSubsystem(this, id, seg);`
- **Case 0xBC0** (CondenserClassID, 580-583): `subsystemArray[id] = CreateReservoirSubsystem(this, id, seg);`
- **Case 0xBBE** (SensorClassID, 574-578): `subsystemArray[id] = CreateHeatSinkBankSubsystem(this, id, seg); sensorSubsystem = subsystemArray[id];`**keep the cache** (slot 0x1f7 is the heat bank, per CLASSMAP:455-458; the name "sensor" is a mech.cpp mis-guess).
- **0xBBE interim (still real, not a stand-in):** the aggregate is a `#if 0` skeleton (Performance @4ae73c, 351 bytes, unfilled). Until reconstructed, have `CreateHeatSinkBankSubsystem` instantiate the real **HeatSink** base (alloc 0x1e4) so the master gate + `HeatSinkSimulation` run. This sink is the central heat engine that relaxes stored heat toward the ~300K ambient — without it the mech has no heat dissipation (Risk 8).
- Delete `RECON_SUBSYS(Cockpit)` (161), `RECON_SUBSYS(Sensor)` (162), `RECON_SUBSYS(Condenser)` (163). Leave the others for later waves.
### Step 4 — MechTech ↔ HUD un-swap (folded into this wave)
- Add `CreateHUDSubsystem` (hud.cpp, SIZE 0x2a4) and `CreateMechTechSubsystem` (mechtech.cpp, SIZE 0x140 — ≥ real 0x104 and ≥ the 0x114 base-ctor write).
- **Case 0xBD6** (MechTechClassID, 658-662): build **HUD**`subsystemArray[id] = CreateHUDSubsystem(this, id, seg);`.
- **Case 0xBDC** (HeatableClassID, 669-677): build **MechTech**`subsystemArray[id] = CreateMechTechSubsystem(this, id, seg); mechTechSubsystem = subsystemArray[id];` (this fixes the discarded-pointer bug).
- The `mechTechSubsystem` cache (slot 0x16d) — see Risk 4 for whether it caches MechTech or HUD; default to caching the real MechTech at 0xBDC.
### Step 5 — internal heat linkage (depends on the bank existing)
The HeatSink ctor resolves `heatSinkIndex` and `linkedSinks.Add(master)`; Reservoir ctor calls `link->Attach(this)` and scales by `link->field_1d0`. These need the 0xBBE master sink (Step 3) present so `linkedSinks.Resolve()` is non-null; the null-master guards (heat.cpp:595/646/693) keep it safe-but-inert otherwise. The mech.cpp:700-704 gyro↔`+0x1d8` link is **Torso, not heat** — leave it for Wave 5.
### Verify heat build/dissipate
1. In each heat ctor, after the master-gate block, log when the gate arms (`name << " heat gate armed"`). No "armed" line for the 0xBBE sink ⇒ the gate/`owner->simulationFlags` path is wrong.
2. In `HeatSink::HeatSinkSimulation` (heat.cpp:516), gated to the viewpoint mech, log per tick: `currentTemperature, heatEnergy, pendingHeat, coolantLevel, heatLoad, coolantActive`.
3. With `BT_FORCE_FIRE=1 BT_SPAWN_ENEMY=1`: weapons deposit into `pendingHeat``currentTemperature`/`heatEnergy` climb, `heatLoad` (15-sample average) ramps, `coolantActive`→1 and `coolantLevel` drains; on cease-fire the aggregate relaxes `currentTemperature` toward ~300K and `coolantLevel` recovers via `Reservoir::DrawCoolant`. Alarm transitions log via `HeatSink::PrintState` (Normal/Degradation/Failure).
4. **Offset proof:** the cockpit Heat gauge (`btl4gaug` Heat connection @4c3720 reads `currentTemperature@0x114`, CLASSMAP:260) must track the logged temperature. Garbage/`0xCDCDCDCD` ⇒ the MechSubsystem re-base/offsets are still off.
---
## 4. EMITTER / PPC WEAPON — detailed ordered edit list (make ONE real beam weapon fire)
Do **heat first** (E5 below depends on it). All offsets per CLASSMAP:476-484.
**E1 — Bridge (emitter.cpp).** Add `CreateEmitterSubsystem(Mech*, int, void* seg)``new (Memory::Allocate(0x478)) Emitter(owner, id, (Emitter::SubsystemResource*)seg, Emitter::DefaultData);`. Use it for **both** 0xBC8 and 0xBD4 — PPC is an Emitter subclass whose `FireWeapon@4bb878` just tail-calls `Emitter::FireWeapon@4bace8`, and the renderer keys off the *streamed* classID, so behavior is identical now. (A `struct PPC : Emitter {…}` for vtable/classID fidelity can be added later.)
**E2 — ctor null-deref + uninit (emitter.cpp ~653-680).** `FUN_00417ab4` (voltage-source resolver) returns 0 until the bus is real, then `src->ratedVoltage` and `TrackSeekVoltage`'s `src->voltage` deref null = construction-time crash. Add `energyCoefficient = 1.0f;` (@0x454) **before** the electrical branch (it is read by `FireWeapon` at line 160 but assigned only inside that branch). Resolve `src` once; gate the whole electrical block on `src != 0 && src->ratedVoltage > 0.0f`; in the else path keep `seekVoltage[i]` as the raw resource fractions, dividing by `v` only when `v > 0`.
**E3 — inert targeting (mechweap.cpp/.hpp).** `HasActiveTarget()` (mechweap.hpp:182) returns a never-set local `hasTarget` → fire gate permanently false. Read the owner instead (mech4.cpp already writes `mech+0x388` = MECH_TARGET_ENTITY):
```cpp
Logical MechWeapon::HasActiveTarget() const {
Mech *owner = (Mech *)GetOwningSimulation();
return (owner && *(Entity **)((char *)owner + 0x388)) ? True : False;
}
```
Have `GetTargetPosition` read owner `+0x37c`; drop the `hasTarget` member (systemic #1).
**E4 — Loaded→Firing gate reachable (emitter.cpp:259).** `firingArmed` (aliases `MechWeapon::recoil@0x3E8`) is uninitialized and written by nothing → the `if (firingArmed && HasActiveTarget())` gate reads garbage. Initialize it `True` in the ctor / `ResetToInitialState` for bring-up, pending its real semantic (Risk 3).
**E5 — heat self-load (emitter.hpp:283).** `heatAccumulator` is declared as an *Emitter* member → lands ~0x3f0+, NOT the inherited HeatableSubsystem accumulator `@0x1c8`, so `FireWeapon`'s `heatAccumulator += heatPortion` never reaches the heat sim. Delete the Emitter-local copy; write the inherited `@0x1c8` accumulator. **Depends on the heat module (Wave 1/2) — this is why heat is first.**
**E6 — mech.cpp factory.** `extern Subsystem *CreateEmitterSubsystem(Mech*,int,void*);`. Replace **case 0xBC8** (617-621) and **case 0xBD4** (652-656): `subsystemArray[id] = CreateEmitterSubsystem(this, id, seg); ++weaponCount; break;`. Delete `RECON_SUBSYS(EmitterWeapon)` (170) and `RECON_SUBSYS(GaussRifle)` (174). The Emitter ctor already installs `activePerformance = EmitterSimulation` (emitter.cpp:627), so the roster loop ticks it every frame for the authoritative mech — no extra plumbing.
**E7 — power bus (Wave 3a, required for the real fire path).** While Generator/PoweredSubsystem are stubs, `GetVoltageState()` never returns 4 → `currentLevel` never charges → the weapon never reaches `Loaded`. Faithful path: un-stub **Generator (0xBC1)** and **PoweredSubsystem (0xBC2)** so `FUN_00417ab4` resolves a real `VoltageSource`. Bring-up shortcut (still drives the real state machine): force-charge — `currentLevel = seekVoltage[idx]; weaponAlarm → Loaded`.
**E8 — trigger wiring (mech4.cpp ~707-736).** The mapper is bypassed, so nothing writes `fireImpulse@0x31c` and `CheckFireEdge()` never sees a rising edge. Where `gBTDrive.fire` is handled, locate the emitter in `subsystemArray` (classID 0xBC8/0xBD4) and set its `fireImpulse > 0` for the frame (small setter/friend on the weapon). This is the faithful replacement for the explosion stand-in's input read.
**What blocks a VISIBLE shot (and the honest interim).** `btl4vid.cpp::MakeMechRenderables` dispatches `0xBC8 Emitter beam` / `0xBD4 PPC beam` (CLASSMAP:334) but **defers the beam renderable to the unported `dpl2d_` 2D display-list layer** (btl4vid.cpp:244-245, 452-458). So a perfectly-firing Emitter draws nothing. **Honest interim: keep the mech4.cpp explosion stand-in (707-736) for the visual** while the real Emitter mechanics (charge → `Loaded``CheckFireEdge``Firing``FireWeapon` splits `damagePortion`/`heatPortion`, sets `dischargeTimer`/`beamFlag`/`targetEntity`) run underneath. The shot is real; only its rendering is borrowed until the `dpl2d_` beam lands. (Net: E1/E2/E6 = construct+tick safely; E3/E4/E5 + E7 = actually fire and self-heat; E8 = trigger; the `dpl2d_` beam = finally visible.)
Net dependency: **E5 needs heat (Waves 1-2); the real-fire path needs the power bus (E7 = Wave 3a); the visible beam needs `dpl2d_` (out of scope — keep the stand-in).**
---
## 5. RISKS / UNKNOWNS — and how to settle each at the binary
1. **0xBCE @4bdcb4 unresolved (blocks Wave 7).** No CLASSMAP/recon at that address; sits between AmmoBin (4bd5c4) and MissileLauncher (4bcff0). **Settle:** run `tools/ghidra_scripts/ExportBTSource.java` on `4bdcb4` (and its CSS/vtable); it is most likely a ballistic-autocannon variant of ProjectileWeapon. Reconcile the 0xBCD(ProjectileWeapon)/0xBCE(?)/0xBD0(MissileLauncher) label tangle before swapping any of the three. Leave `RECON_SUBSYS(MissileLauncher)` at 0xBCE until done.
2. **HeatableSubsystem base: MechSubsystem vs Subsystem.** `mechsub.hpp` and `heat.hpp` are overlapping reconstructions of the same cluster (vtable 0x50e210, classID 0xBBB, dtor 0x4ac868, model 0xE4). **Settle:** confirm `sizeof(MechSubsystem) == 0x114` and that `currentTemperature` begins exactly at 0x114 (HeatSink temp init @4adda0). Recommended resolution (Wave 1): `HeatableSubsystem : MechSubsystem`, one DamageZone built by the MechSubsystem resource ctor (@4ac644). Cross-check via the Heat gauge offset proof (§3 verify #4).
3. **`firingArmed` real semantic (@0x3E8, aliases MechWeapon::recoil).** **Settle:** decompile around the `Loaded→Firing` gate and the fire-group dispatch to find what writes `this+0x3e8` in the binary (a weapon-selected/enabled flag from the controls path). Until then init `True` for bring-up (E4).
4. **Special-cache slots — re-derive each member's TRUE meaning.** The Wword index is ground truth for *which Mech member* is written, but the names are mislabeled: `sensorSubsystem`(0x1f7)=heat bank; `mechTechSubsystem`(0x16d)=MechTech-or-HUD; `controlsMapper`(0x10d)=mapper-or-message-manager; `gyroSubsystem`(0x14a)=gyro; `sinkSourceSubsystem`(0x10e)=torso. **Settle:** read which Mech offset the binary writes at each slot and who consumes it. **Critical for SubsystemMessageManager (0xBD3, Wave 8):** verify slot 0x10d is NOT the same member `mech4.cpp` bypasses in the tick (`if (s == controlsMapper) continue;`) — if the message manager lands in the bypassed slot it will never tick. And confirm whether the HUD (0xBD6) needs its own cache distinct from `mechTechSubsystem`.
5. **MechControlsMapper tick AV (confirmed).** `InterpretControls→FillPilotArray` reads `application+0x6c→+0x190→pilot+0x1e0` (`DAT_004efc94`) → wild pilot pointer → AV (mechmppr.cpp:46). **Settle:** only as a Phase-8 input-remap task with the real WinTesla `Application` layout. Until then keep it bypassed; the factory's 0xBD3 case is the *message manager*, not this mapper.
6. **Gyro/Torso joint writes — TORSO SIDE RESOLVED.** ~~Stubbed no-ops needing a Skeleton/DCS API.~~ The
real engine already exposes it: `JointedMover::GetSegment(CString)``EntitySegment::GetJointIndex()`
`JointSubsystem::GetJoint(i)``Joint::SetRotation(Radian|EulerAngles)` (all PUBLIC, in `munga_engine.lib`).
Torso's `ResolveJoint`/`PushTwist` are reconstructed against this (DONE). Gyro's `WriteEyeJoint`/`WriteMechJoint`/
`ResolveJoint` should reuse the same hoisted `Mech::ResolveJoint` + the joint-write dispatch (follow-up). Not a
separate DCS task — the write path is engine-native. (Torso's live wiring is still gated on the WAVE-5 base
re-base, not on this.)
7. **Myomers mover coupling (Wave 6).** `ConnectToMover` routes `SpeedEffect@0x31C` into `**(Mech+0x128)`; `RegisterMaxOutput` writes `maxSpeed@0x7A0` and loops the myomer list `@0x1EB`. **Settle:** verify those Mech offsets exist/are valid in the reconstructed Mech before enabling, else no-op/wild writes. Also note `powersub.hpp` still declares a STALE `Myomers` (the old Sensor mislabel) — `#include <myomers.hpp>` first and watch for the name collision at link.
8. **Aggregate heat sink (0xBBE) unfinished.** Performance @4ae73c (351 bytes) is a `#if 0` skeleton; it's the load-bearing central heat engine. **Settle:** decompile `4ae8d0`/`4ae73c`, finish `AggregateHeatSink`, give it `GetClassDerivations`/`DefaultData`. Interim = real `HeatSink` base at 0xBBE (real, not a stand-in) so the master gate + `HeatSinkSimulation` run.
9. **Placement-new overrun (general).** After de-shadowing, `sizeof(Class)` can exceed the factory alloc SIZE → heap corruption (the 1-in-6 crash). **Settle:** the `Check(sizeof(<Class>) <= SIZE)` guard in every bridge (§1) makes this a hard, immediate assert instead of an intermittent corruption.
Key files (all under `C:/git/nick-games/decomp/reconstructed/`): `mech.cpp` (factory 556-741, stubs 145-181, enum mech.hpp:253-272), `mech4.cpp` (tick 825-871, target slots/explosion 707-736), `heat.hpp`/`heat.cpp`/`heatfamily_reslice.{hpp,cpp}`, `mechsub.hpp`, `mechtech.{cpp,hpp}`, `hud.cpp`, `emitter.{cpp,hpp}`, `mechweap.{cpp,hpp}`, `powersub.{cpp,hpp}`, `sensor/gyro/torso/myomers/searchlight/thermalsight/ammobin/projweap/mislanch/messmgr.cpp`, `CLASSMAP.md` (authoritative). Engine bases: `C:/git/nick-games/elsewhen_extracted/Elsewhen RP411 Source/trunk/MUNGA/{SUBSYSTM,SIMULATE,RECEIVER,VDATA}.h`, `SIMULATE.cpp` (PerformAndWatch @448), `ENTITY.cpp` (roster tick @698).