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>
32 KiB
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):
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 exactMemory::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 writesvitalSubsystemIndex@0x110→ object must be ≥ 0x114). - The trailing
<Class>::DefaultDatamatches 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:
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
- Shadowing (re-declared base fields). Grep the class + its bases for fields that already exist in the engine
Subsystem/Simulationchain or in the reconstructed BT base (MechSubsystem/HeatableSubsystem/HeatSink/PoweredSubsystem):owner/hostEntity(→Subsystem::owningEntity, use aGetEntity()cast),damageZone(→ engineSubsystem::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 is0xCDCDCDCD) and (b) lands at the wrong compiled offset. NOT a shadow (leave as is): each class's owntypedef … Performance;+SetPerformance(Performance)(deliberately writes the inheritedactivePerformance), its owntypedef … SubsystemResource;, andstatic …DefaultData. After de-shadowing, confirmsizeof(Class) ≤ alloc SIZE. Wword(N)trap.mechrecon.hpp:192Wword(i)is a global scratch bank (bank[i&0x3ff]), NOTthis+i*4. Grep the class body forWword(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;Wworduse is a mech.cpp-only concern.- MessageHandler chaining. Each class's handler set must chain to its parent:
Receiver::MessageHandlerSet X(Parent::GetMessageHandlers());. An empty/parentless set makesReceiver::Receivefind no handler → inherited messages (TakeDamage, InjectCoolant, ToggleLamp, Jammed, …) silently dropped. Gyroscope/Sensor/Myomers and all heat classes currently defineGetClassDerivationsbut an empty/missingGetMessageHandlers— fix on swap. - 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,activePerformancestays&Simulation::DoNothingOnce, which on its first tick callsNeverExecute()→ setsDontExecuteFlag→ 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; makeHeatableSubsystem : MechSubsystem; delete shadow fields; fix the thermal offsets. Compileheat.cpp,heatfamily_reslice.cpp,mechsub.cppgreen. 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).
- 3a (dependency): power bus — Generator (0xBC1) + PoweredSubsystem (0xBC2). Low-risk, foundational; energy weapons need it to charge (
- WAVE 4 — Standalone readouts: ✅ DONE + WIRED (live, verified). Sensor (0xBC3), Searchlight (0xBD8), ThermalSight (0xBDE), AmmoBin (0xBCB) all un-stubbed via
Create<Class>Subsystembridges + de-shim +<Class>LayoutCheckstatic_asserts. BLH tick 20→26, Mad Cat 24, combat DESTROYED un-regressed, 0 crashes. KEY finds: (a) Searchlight/ThermalSight ctor gate read the shadowsegmentFlags(=0) → Performance NEVER installed → fixed toowner->simulationFlags; (b) AmmoBinammoAlarmretypedHeatAlarm→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) sharedHeatWatcher::WatchSimulationnull-deref on the unresolvedwatchedLinkGUARDED (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+ correctedTorsoSimulation+ per-frameUpdateJoints()) AND the base-chain re-base are complete. Hierarchy isTorso : PowerWatcher : HeatWatcher : MechSubsystem(a SEPARATE branch from the HeatSink leaves — shares only MechSubsystem, so the +156 fix stays in the Watcher branch). Fix: Watcher-localWatchedConnection(0xC) +WatcherGaugeAlarm(0x54) in heatfamily_reslice.hpp (never resize the sharedSubsystemConnection/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 hasTorsoHorizontalEnabled=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_MYOMERSdefault ON; verified INERT, no locomotion/combat regression). Myomers (0xBC6) constructs + ticks its real Performance viaCreateMyomersSubsystem, but is INERT:MyomersSimulationearly-returns onOwnerAdvancedDamage()==False(real gate = messmgr 0xBD3), and the mover feed (MoverAttach) +SetOwnerMaxSpeedare no-op stubs so the live JointedMover is never touched. De-shim dropped theowner*/segmentFlagsshim fields (accessors return neutral defaults) to fit the exact-0x358 alloc (sizeoflock). ⚠ AUTHENTIC COUPLING DEFERRED: routingSpeedEffect@0x31Cinto 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 callsInterpretControls→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.
- Base change. heat.hpp:33 — ensure
#include <mechsub.hpp>. heat.hpp:168-169 —class HeatableSubsystem: public Subsystem→: public MechSubsystem. - Delete the shadow fields (heat.hpp:234-238):
Mech *owner→ inheritedMechSubsystem::owner(or aMech* GetEntity(){return (Mech*)Subsystem::GetEntity();}accessor, matching surviving MECHTECH.HPP).LWord flags/LWord statusFlags(both @0x28) → engineSimulation::simulationFlags. Retargetflags |= 8/2(Condenser/Reservoir) tosimulationFlags.LWord statusBits(@0x60) → engine dirty word; ReservoirstatusBits |= 1→ForceUpdate().int destroyed(@0x40) →MechSubsystem::simulationState(DestroyedState);IsDamaged()reads it.
- Thermal offsets (heat.hpp:240-241 + HeatSink). CLASSMAP:472 — the
0x114-0x184block belongs to HeatableSubsystem: keepcurrentTemperature@0x114, movedegradationTemperature@0x118/failureTemperature@0x11CUP from HeatSink into HeatableSubsystem betweencurrentTemperatureandheatLoad, soheatLoadlands at@0x120(today it compiles to 0x118). - 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'sResetToInitialState()). - HeatSink overlap words (~heat.hpp:382-412):
valveState@0x1D0/field_1d0@0x1D0andrefrigerationOutput@0x160/massScale@0x160are the SAME binary word reused by subclasses — declare each once in the base it belongs to so subclasses extend rather than re-append. - Handler chains (systemic #3): define
GetMessageHandlers()for each heat class to chain to its parent (Receiver::MessageHandlerSet(HeatableSubsystem::GetMessageHandlers()), etc.). Today onlyGetClassDerivationsis defined →HeatSink::HandleMessage(1)andReservoirInjectCoolant 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
externthe 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 0skeleton (Performance @4ae73c, 351 bytes, unfilled). Until reconstructed, haveCreateHeatSinkBankSubsysteminstantiate the real HeatSink base (alloc 0x1e4) so the master gate +HeatSinkSimulationrun. 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).
- 0xBBE interim (still real, not a stand-in): the aggregate is a
- 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) andCreateMechTechSubsystem(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
mechTechSubsystemcache (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
- 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->simulationFlagspath is wrong. - In
HeatSink::HeatSinkSimulation(heat.cpp:516), gated to the viewpoint mech, log per tick:currentTemperature, heatEnergy, pendingHeat, coolantLevel, heatLoad, coolantActive. - With
BT_FORCE_FIRE=1 BT_SPAWN_ENEMY=1: weapons deposit intopendingHeat→currentTemperature/heatEnergyclimb,heatLoad(15-sample average) ramps,coolantActive→1 andcoolantLeveldrains; on cease-fire the aggregate relaxescurrentTemperaturetoward ~300K andcoolantLevelrecovers viaReservoir::DrawCoolant. Alarm transitions log viaHeatSink::PrintState(Normal/Degradation/Failure). - Offset proof: the cockpit Heat gauge (
btl4gaugHeat connection @4c3720 readscurrentTemperature@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):
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
- 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.javaon4bdcb4(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. LeaveRECON_SUBSYS(MissileLauncher)at 0xBCE until done. - HeatableSubsystem base: MechSubsystem vs Subsystem.
mechsub.hppandheat.hppare overlapping reconstructions of the same cluster (vtable 0x50e210, classID 0xBBB, dtor 0x4ac868, model 0xE4). Settle: confirmsizeof(MechSubsystem) == 0x114and thatcurrentTemperaturebegins 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). firingArmedreal semantic (@0x3E8, aliases MechWeapon::recoil). Settle: decompile around theLoaded→Firinggate and the fire-group dispatch to find what writesthis+0x3e8in the binary (a weapon-selected/enabled flag from the controls path). Until then initTruefor bring-up (E4).- 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 membermech4.cppbypasses 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 frommechTechSubsystem. - MechControlsMapper tick AV (confirmed).
InterpretControls→FillPilotArrayreadsapplication+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 WinTeslaApplicationlayout. Until then keep it bypassed; the factory's 0xBD3 case is the message manager, not this mapper. - 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, inmunga_engine.lib). Torso'sResolveJoint/PushTwistare reconstructed against this (DONE). Gyro'sWriteEyeJoint/WriteMechJoint/ResolveJointshould reuse the same hoistedMech::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.) - Myomers mover coupling (Wave 6).
ConnectToMoverroutesSpeedEffect@0x31Cinto**(Mech+0x128);RegisterMaxOutputwritesmaxSpeed@0x7A0and 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 notepowersub.hppstill declares a STALEMyomers(the old Sensor mislabel) —#include <myomers.hpp>first and watch for the name collision at link. - Aggregate heat sink (0xBBE) unfinished. Performance @4ae73c (351 bytes) is a
#if 0skeleton; it's the load-bearing central heat engine. Settle: decompile4ae8d0/4ae73c, finishAggregateHeatSink, give itGetClassDerivations/DefaultData. Interim = realHeatSinkbase at 0xBBE (real, not a stand-in) so the master gate +HeatSinkSimulationrun. - Placement-new overrun (general). After de-shadowing,
sizeof(Class)can exceed the factory alloc SIZE → heap corruption (the 1-in-6 crash). Settle: theCheck(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).