Follow-on tob70654d, which chained the base sim and stopped there. Decoding the remaining 0x17c bytes of @004b8b9c turned up four more blocks the port had dropped along with it. In binary order the registered Performance is: @004b8bab chain PoweredSubsystemSimulation (fixed inb70654d) @004b8bb9 un-powered self-repair of this myomer's own zone (dead -- see below) @004b8c1e republish outputVoltage@0x344 from the source @004b8c5a republish speedEffect@0x31C, the drive fed to the mover @004b8ceb run the inner integrator, ONLY when outputVoltage > 0 speedEffect is the interesting one. AvailableOutput scales by the Mech base speed and the wrapper divides by it again, so the two cancel and what lands in +0x31C is a 0..1 FRACTION of full drive carrying the gear ratio, the thermal degradation curve and the accumulated zone damage. Neither it nor outputVoltage was ever written before: outputVoltage sat wherever the ctor left it and speedEffect stayed pinned at its ctor 1.0f. Un-stubbed DamageStructureLevel() while in here. It was `return 0.0f`, which held AvailableOutput's (1 - damage) factor at 1, so a shot-up myomer drove exactly as well as a fresh one. Routed to the base's GetSubsystemDamageLevel() bridge, the same cell sensor.cpp:312 already reads. Measured dmg=0.6 -> speed=0.4. @004b8bb9 does not work, and did not work in 1995 either. It builds a Damage (Explosive, amount 0xbc343958 ~= -0.011f, impactPoint from owner+0x100, burst 1) and calls the ZONE's TakeDamage -- vtable +0x18, not the subsystem's +0x24 -- so the plain `damageLevel += amount * damageScale[type]`. Read on its own that is "a myomer you power down slowly heals". But a subsystem's private zone is built by the 2-arg `new DamageZone(this, 0)`, and DAMAGE.cpp:187-190 zeroes all five damageScale entries; Reset never touches them and the only other writer is Mech__DamageZone, the mech's streamed zones. The sum is always `+= amount * 0`. Seeded a zone to 0.6, held it at NoVoltage for ~1500 ticks: damageLevel never moved. Reconstructed and deliberately not "fixed" -- a working repair here would be behavior we invented. The crit path reaches subsystem damage by writing damageLevel directly, which is why subsystems still die. That generalises, so it is written up in context/combat-damage.md on its own: you cannot damage a subsystem's own zone through DamageZone::TakeDamage at all. Anything reconstructed later that means to hurt a subsystem has to go the way the crit path goes, or it will silently do nothing. Verified, self-damage runs across two death/respawn cycles: torso 96/96 samples elec=4, zero dips myomers 96/97 elec=4 (the odd one is the first tick, before the machine runs) healthy outV=10000 speed=1 un-powered outV=0 speed=0 BT_MYOMERS_LOG probes the four outputs; BT_MYOMERS_REPAIR_TEST=<level> seeds the zone and drops the subsystem into Manual so the repair branch can be watched without the AutoConnect hunt restoring power a frame later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
18 KiB
id, title, status, source_sections, related_topics, key_terms, open_questions
| id | title | status | source_sections | related_topics | key_terms | open_questions | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| subsystems | Subsystems — the factory + the reconstruction waves | established | PROGRESS_LOG.md §10d; docs/SUBSYS_PLAN.md; docs/VEHICLE_SUBSYSTEMS.md; docs/RESOURCE_AUDIT.md |
|
|
|
Subsystems
The mech's components (heat/power/weapons/sensor/gyro/torso/myomers/…), built by the Mech-ctor
factory. Full per-family plan: docs/SUBSYS_PLAN.md; the engineering-cluster panels:
docs/VEHICLE_SUBSYSTEMS.md; the layout audit: docs/RESOURCE_AUDIT.md. ClassID map:
decomp-reference §2.
The factory
The Mech ctor switches on the resource ClassID to build each subsystem into the subsystem-roster
(subsystemArray@0x128). The case <Name>ClassID LABELS are systematically MISLABELED — the
real class is the // FUN_004xxxxx ctor-address comment reconciled via CLASSMAP.md, not the case
name (e.g. 0xBBE case="Sensor" is actually AggregateHeatSink). Each real class is wired via a
Create<Class>Subsystem(Mech*,int,void*) bridge in the class's own .cpp (do NOT #include the
real subsystem headers into mech.cpp — the local stubs collide). Keep the alloc SIZE + special-cache.
[T2]
Class hierarchy (two branches, shared only at MechSubsystem)
- Heat leaf:
PoweredSubsystem : HeatSink : HeatableSubsystem : MechSubsystem. Emitter/PPC/ Sensor/Myomers/ProjectileWeapon/MissileLauncher/GaussRifle chain here. Uses 0xCSubsystemConnection- 0x54
GaugeAlarm54(alarm level at +0x14). [T1]
- 0x54
- Watcher branch:
Torso/Gyroscope/Searchlight/ThermalSight/HUD : PowerWatcher : HeatWatcher : MechSubsystem;AmmoBin : HeatWatcher. Uses a 0xCWatchedConnection+ 0x54WatcherGaugeAlarm. [T1] Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER in one build, eachstatic_assert-locked (the P7 alarm-unification: HeatSink 0x1D0 → PoweredSubsystem 0x31C → MechWeapon 0x3F0 → Emitter 0x478). [T2]
Reconstruction waves (state: ALL 20 factory cases wired to real classes — 0xBD3 = the real SubsystemMessageManager since task #7, 2026-07-11; the mapper lives in roster slot 0)
- WAVE 1 — HeatableSubsystem re-based onto MechSubsystem (de-shadow cascade).
- WAVE 2 — heat family (Condenser/AggregateHeatSink/Reservoir) + HUD + MechTech (un-swap).
- WAVE 3 — power bus (Generator/PoweredSubsystem) + Emitter/PPC fire-path (end-to-end fire; heat conducts to the central sink via the linked-sink roster).
- WAVE 4 — standalone readouts: Sensor/Searchlight/ThermalSight/AmmoBin (de-shim, gate fixes).
- ✅ Searchlight + ThermalSight buttons WIRED 2026-07-25 (#61) — both classes' handler sets were
default-constructed blackholes (systemic cause #1,
docs/INPUT_PATH_AUDIT.md). Each now chainsPowerWatcher::GetMessageHandlers()with its own id-3ToggleLamp. Verified live: pad0x14→requestedOn 0→1→lightState 0→1; pad0x12→requestedOn 0→1→thermalActive 0→1. - ❌ RETRACTED: the "ORIGINAL 1995 latent bug" (old task #63) NEVER EXISTED. It was an artifact of
a swapped table attribution: the claim compared Searchlight's Performance (@004b841c, reads
requestedOn@0x1E0) against ThermalSight'sToggleLamp(@004b860c, toggles0x1DC) and concluded "no 0x1DC→0x1E0 bridge, so the lamp can never light". @004b860c is not Searchlight's. Searchlight's own handler is @004b838c (table @0x51117C) and — like every sibling — toggles the field its OWN Performance reads,requestedOn@0x1E0. Each class is internally self-consistent. [T1 throughout. Attribution: un-pooled"ToggleLamp"strings adjacent to their own class names,section_dump.txt:69661-69711. Body: @004b838c is a Ghidra EXPORT GAP (#60) but was recovered by raw disasm ofcontent/BTL4OPT.EXE(scratchpad/dis838c.py) — it toggles 0x1E0, raisesupdateModel(+0x18) unconditionally, and has NO novice gate (ThermalSight-only).] Consequence: the searchlight→fog swap was NOT inert in the arcade, and making it work in the port is FAITHFUL, not a designer-intent deviation. See open-questions + rendering fog, both corrected. - ⚠ Still open on Searchlight:
commandedOn@0x1DC (ctor-seeded from subsystem resource +0x28, @004b84dc) has no identified role — it is neither the toggle target nor read by @004b841c, and it measured 21 live (not a boolean), which hints our SubsystemResource +0x28 ≠ the binary's. - ⚠ ThermalSight's published attributes are mis-named: we publish
"LightState"→thermalActive@0x1D8, but the binary's table @0x511268 has"LightOn"(id 3)→0x1D8 and"LightState"(id 4)→0x1E0 (the alarm). A gauge bindingThermalSight/LightOncurrently misses. Not yet changed (gauge-binding change needs its own verification pass).
- ✅ Searchlight + ThermalSight buttons WIRED 2026-07-25 (#61) — both classes' handler sets were
default-constructed blackholes (systemic cause #1,
- WAVE 5 — Torso (aim twist/elevation; joint-I/O reconstructed; the BLH record disables torso → faithfully no visible twist).
- WAVE 6 — Myomers (mover-coupled; structural un-stub is INERT — no live mover/heat coupling).
⚠ The registered Performance is the WRAPPER @004b8b9c, not the inner integrator @004b8d18
[T1, fixed 2026-07-28]. The ctor @004b8fec stores
[0x511620]into activePerformance, and that pointer resolves to0x4b8b9c, whose first instruction iscall 0x4b0bd0=PoweredSubsystem::PoweredSubsystemSimulation; @004b8d18 is the drive-heat integrator it then runs. The port had registered the INNER function, so Myomers never advanced its own electrical state machine — and since that machine is the only thing that both entersNoVoltage(source == 0) and walks it back (NoVoltage → Starting → Ready), a Myomers that lost power in the death/reset window stayed there forever. The Torso is a PowerWatcher that MIRRORS its watched subsystem, sotorso.cpp:570heldeffectiveTwistRateat 0 = Gitea #70, "torso twist stops working after a death/respawn." Measured: 2 dead windows per respawn before, 0 after; pristine missions never showed it. Order is load-bearing — the base call precedes the heat-model gate, and gating first also denied the electrical machine to every non-expert pilot (OwnerAdvancedDamage()is the +0x260 flag, off below veteran). General rule this establishes: when a subsystem's Performance address in aPTR_LAB_*slot does not match the function you reconstructed, check whether the real target is a wrapper that chains the base sim — the whole family does (Generator/PoweredSubsystemlead withHeatSink::HeatSinkSimulation; the Torso's own perf @004b5cf0 leads withUpdateWatch). The full wrapper is now reconstructed [T1/T2, 2026-07-28] — five blocks in binary order:@004b8babchainPoweredSubsystemSimulation;@004b8bb9the un-powered self-repair (see below);@004b8c1erepublishoutputVoltage@0x344from the resolved source when the alarm is Ready;@004b8c5arepublishspeedEffect@0x31C=AvailableOutput(clamped V) / OwnerBaseSpeed()— the Mech base speed CANCELS, sospeedEffectis a 0..1 fraction of full drive carrying gear ratio, thermal curve and zone damage;@004b8cebrun the inner integrator only whenoutputVoltage > 0. Verified live: healthyoutV=10000 speed=1, un-poweredoutV=0 speed=0, and 96/96 torso samples atelec=4across two death/respawn cycles. ⚠@004b8bb9is DEAD CODE — in the original too [T0]. It builds aDamage(type=Explosive, amount=0xbc343958≈-0.011f, impactPoint=owner+0x100, burst=1) and calls the zone'sTakeDamage(zone vtable+0x18, not the subsystem's+0x24). Reading it as "an un-powered myomer heals" is the evident intent, but it can never fire: a subsystem's private zone comes from the 2-argnew DamageZone(this,0), andengine/MUNGA/DAMAGE.cpp:187-190zeroes all fivedamageScale[]entries;Reset()never touches them and the only other writer isMech__DamageZone(the mech's streamed zones —mechdmg.cpp:246-253). SinceTakeDamageisdamageLevel += amount * damageScale[type], the sum is always+= amount * 0. Confirmed by experiment: seeded to 0.6 and held at NoVoltage ~1500 ticks,damageLevelnever moved. Reconstructed and deliberately NOT "fixed" — inventing a working repair would be made-up behavior. General rule: you cannot damage a subsystem's own zone throughDamageZone::TakeDamage; the crit path writesdamageLeveldirectly (DistributeCriticalHitpins*(this[0x38]+0x158) = 1.0f). Also un-stubbed here:Myomers::DamageStructureLevel()returned a hardcoded0.0f, which pinnedAvailableOutput's(1 - damage)factor at 1 — a shot-up myomer drove exactly as well as a fresh one. Now routed to the base bridgeGetSubsystemDamageLevel(); measureddmg=0.6 → speed=0.4. - WAVE 7 — projectile/missile weapons (byte-exact; flying projectiles are a PORT reconstruction — the 2007 Entity is 0x1BC vs the binary's 0x300, so raw base-offset reads fail).
- TASK #56 (2026-07-11) — Gyroscope LIVE byte-exact (ctor @004b3778, sizeof 0x3D0 locked;
integrators +
FUN_004b2980damage fan-out; joint dispatch from the Mech performance tail @0x4aaf74/83;BT_GYRO_LOG/BT_GYRO_TRACE) — see cockpit-view. - ✅ DONE (task #7,
afefaee) — SubsystemMessageManager 0xBD3 (a damage/explosion consolidation hub cached toMech[0x10d]=0x434; the factory builds the REAL class andmech.hppnames the slotmessageManager— the oldcontrolsMappermislabel is swept, the real mapper is roster slot 0. NOT the valve/heat-model gate — that's the owning BTPlayer @mech+0x190 carrying the EXPERIENCE-level flags, see experience-levels). [T2]
The watcher electrical chain (task #57, 2026-07-13 — the torso power gate)
The Watcher branch is POWERED indirectly: a watcher WATCHES another roster subsystem and mirrors its electrical state. The full chain, byte-verified [T1]:
- Data: the model entry
WatchedSubsystem=<name>→ segment index +2 at resource+0xE4 (HeatWatcher::CreateStreamedSubsystem@004aec54); the ctor @004aeb40 stores it at watcher+0x128 (watchedSubsystem). - Bind (factory post-roster loop 1): vtable slot 14 (+0x38) —
@004aee2c(HeatWatcher) /@004b1a40(PowerWatcher/Torso override, byte-identical); Ghidra missed both function starts (recovered from raw bytes; the vtables.tsv rows have GAPS where the exporter skipped slots — dump the exe bytes at vtable+slot*4 when a slot looks missing). Master-gated ((owner->simulationFlags & 0xC)==0 && (flags & 0x100)); bindswatchedLink(+0x114).Add( owner->roster[+0x128][watchedSubsystem]). Port:BTWatcherWatchedIndex/BTWatcherBindTargetbridges (heatfamily_reslice.cpp) called from the mech.cpp factory loop. - Tick:
@004b181c= the REALPowerWatcherPerformance (PTR @0050f5fc → 004b181c) =UpdateWatch(): heat mirror (FUN_004aeac4) +watchdogAlarm.SetLevel(watched->electrical level @+0x278)+ brownout downgrade to 1 whengen->outputVoltage(+0x1DC) <= minVoltage(+0x180) × gen->ratedVoltage(+0x1D8). The Torso sims (@004b5cf0/@004b65f8) call it first-line. (@004b1804is slot-10 ResetToInitialState, NOT the Simulation — old recon mislabel, fixed.) - MinVoltageScale = 0.01 — a 10-byte x87 literal at 0x4b1924 (
0a d7 a3 70 3d 0a d7 a3 f8 3f); the port had 1.0f, making minVoltage 100× too big → the brownout latched every watchdog at 1. - PowerWatcher::GetClassDerivations chains HeatWatcher (real base) — the old HeatableSubsystem
stand-in broke
IsDerivedFrom(HeatWatcher)for Torso/Searchlight/ThermalSight and silently skipped them in the connect pass. - Effect:
Torso::ElectricalStateLevel()==Ready(4)un-gateseffectiveTwistRate— the MadCat torso twists at its authored 50°/s (±140° limits, roster 17 watching 15 → generator @10000V); the BLH is authentically fixed (horizontalEnabled=0, limits ±0.01°). [T2 live-verified] - STILL DEAD: factory loops 2-4 (heatable/weapon/damageable capability rosters) go through the
SubProxystub whoseIsDerivedFromreturns 0 — they add NOTHING. See open-questions.
The coolant FLUSH (Gitea #7, 2026-07-19) — Reservoir InjectCoolant end-to-end [T2 live-verified]
The manual-p24 coolant button (coolant MFD top-right; punch or HOLD): message id 4 "InjectCoolant"
on the Reservoir (handler table @0x50e680, one entry → @4aee70 — same per-receiver id space as the
Condenser's MoveValve @0x50E52C). Handler @4aee70: novice-lockout gate (FUN_004ac9c8), press
(data≥1) → reservoirAlarm.SetLevel(1) gated on coolantLevel@0x12C > 0 (+300 decimal = 0x12C =
coolantLevel — the old "currentTemperature" reading was a misdecode), release → level 0; ForceUpdate.
While level==1 the CoolantSimulation Performance (@4aef78) runs InjectCoolant @4aefa4 each frame:
work list = condenser chain @+0x7cc + weapon chain @+0x7bc + heatable chain @+0x7ac + linkedSinks
master (port walks the roster with the same membership tests — chains are SubProxy-dead; condensers/
weapons/bank get the binary's duplicate-visit weighting; watcher-branch members are filtered to
HeatSink [T2 guarded]); per sink with flowScale@0x15C≠0: move squirtMass@0x224 × flowScale × dt
clamped to [-resLevel, 0] (reservoir drains → the coolant vertBar C Reservoir/CoolantMass drops),
sink level clamped [0, capacity], and pendingHeat@0x1C8 += -(sinkE×|Δ|/sinkLvl − resE×|Δ|/resLvl)
capped at sinkMass@0x154 × res->startingTemperature@0x13C, clamped ≤0 (only cools) — the heat
relief rides the existing heat model. Two ctor corrections surfaced: (1) the master gate @4af408
(and @4aeb40 HeatWatcher) reads *(param_2+0x28) = the owner MECH's simulationFlags, not the
resource's subsystemFlags (the resource read left the Reservoir Performance unregistered → no drain);
(2) (float10)*(int*)(link+0x1d0) is a FILD of the bank's integer HeatSinkCount — capacity =
0.05 (_DAT_004af518) × heatSinkCount × streamed CoolantCapacity (BLH: 0.05×6×20 = 6), not a float
reinterpret (which gave ~1e-44 → empty tank). Visual: the binary's mode-1 coolant-effect renderable
(FUN_00456a68, built for classID 0xBC0 on the "ReservoirState" attr, part_014.c:5439; tick
@part_007.c:8780) spawns psfx 19 = FLUSH.PFX ("Coolant flush", BTDPL.INI:1018, bluish smoke) on
the state's 0→1 edge — port: BTSpawnFlushCloud (mech4.cpp) on the same alarm edge, attached emitter
at torso height. Audio (CoolantDump layers) rides the ReservoirState watchers. Desktop input: 'H'
HELD = Flush (CONTROLS.MAP action; press+release dispatch). Diags: BT_FLUSH_LOG ([flush-tx]
press/release, [flush] level drain, cloud spawn), BT_FLUSH_TEST=<frame> scripted ~2 s hold.
Verified live (FOGDAY): level 6→0 over ~0.6 s held (≈3-4 short punches to empty — matches the
manual), bluish cloud screenshot-confirmed (scratchpad/flush_cloud.png). Replicant note: the state
receive @4aeef8 sets the alarm from the state stream — the port's reservoir state replication is not
wired, so remote clouds are deferred (solo/master path complete).
Valve boost A/B/C (2026-07-23, field "boosted loop still cooked" audit) [T2 measured]
Blackhawk, sustained BT_AUTOFIRE, 150 s soaks, [heat-t] 5 s cadence. Valve shares are
zero-sum (valveState/Σ); detent cycle per press = 1 → 5 → 50 → 0 (CLOSED) → 1 (@4ae464,
one past max turns the loop OFF). SRM6s (+ GeneratorB + Avionics) are on Condenser2
([heat-link] dump; the MFD loop readout matches the heatSinkIndex truth). Results, SRM6_1:
- boost 2 (share 0.91): plateau ~531, then COOLS under nonstop fire — never jams/fails.
- balanced (0.167): ~1162 @ 70 s and climbing (the jam band; field jams logged 1043-1469).
- starved (0.018): ~1440 @ 70 s, slope → the 2000 FailureHeat line.
The valve mechanic is REAL and powerful; MoveValve reruns the redistribute on every press
(not a placebo), veteran role passes the guard. The field double-failure with "loop 2
boosted" is therefore a DETENT question — most likely one press past max (detent 0 = closed,
share 0) — now diagnosable: valve presses log always-on (
[valve] ... -> detent N, with a CLOSED warning). Solo clock shim verified the same day (60 s egg self-ends;[mission] solo game clock expired).
The four systemic checks (every subsystem)
See reconstruction-gotchas: (1) shadowing (re-declared engine-base fields), (2) the Wword trap,
(3) message-handler chaining, (4) entity validity. Plus resource-struct layout (must mirror the class
hierarchy, byte-exact — the RESOURCE_AUDIT found 8 bugs) and object-layout (alias/phantom/interior
fields). A +0x128-style owner offset is the ROSTER, not the segment table (the heat-link type
confusion that caused a heap-corruption via OOB ConductHeat writes). [T2]
Engineering cluster panels (vehicleSubSystems)
Not a widget — a per-subsystem FACTORY (FUN_004cbaf0) building a status CLUSTER onto one of 12 aux
MFD positions, dispatching on classID → HeatSinkCluster/MyomerCluster/EnergyWeaponCluster/
BallisticWeaponCluster. Reads PoweredSubsystem::auxScreenNumber/Placement/Label (res +0x104/8/C) via
the BTGetSubsystemAuxScreen bridge. See docs/VEHICLE_SUBSYSTEMS.md + gauges-hud. [T2]
Key Relationships
- Data: decomp-reference (ClassIDs/hierarchy). Bugs: reconstruction-gotchas.
- Feeds: combat-damage (weapons/damage), gauges-hud (attribute state).
- Plan:
docs/SUBSYS_PLAN.md.