Files
BT411/game/reconstructed/myomers.cpp
T
Joe DiPrimaandClaude Fable 5 819772f974 the rest of the Myomers wrapper: two live outputs recovered, and one branch that never fires
Follow-on to b70654d, 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 in b70654d)
  @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>
2026-07-28 16:06:56 -05:00

811 lines
39 KiB
C++

//===========================================================================//
// File: myomers.cpp //
// Project: BattleTech Brick: Entity Manager //
// Contents: Myomers subsystem implementation (mech artificial-muscle drive). //
//---------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. All Rights reserved //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
//
// RECONSTRUCTED from BTL4OPT.EXE. No source or header survived; this file is
// rebuilt from the decompiled cluster @0x4b8a48 .. @0x4b95b8 (Ghidra
// pseudo-C in recovered/all/part_013.c), the vtable @005117dc, the attribute
// IndexEntry table @00511588 and the string pool @00511660. Each method
// carries its @ADDR and the binary evidence used.
//
// --------------------------------------------------------------------------
// LOCATION / IDENTITY (all proven):
// Class name "Myomers" string @0051166d
// ClassID 0x0BC6 stamped by CSS @004b9140 (resource +0x20)
// model size 0x1B0 stamped by CSS @004b9140 (resource +0x24)
// object size 0x358 Mech factory case 0xBC6 (part_012.c:10069)
// vtable @005117dc set by ctor @004b8fec
// ctor @004b8fec dtor @004b9114
// CSS @004b9140 DefaultData @0051154c
// base PoweredSubsystem (ctor @004b0f74 called with &DAT_0051154c)
//
// CORRECTION vs powersub.cpp: powersub's "Myomers" (vtable 0050fb0c / ctor
// 4b1d18 / classID 0xBC3, members outputVoltage/powered/voltageAvailable)
// was a MISLABEL of **Sensor** (now sensor.cpp). Retire that naming. The
// real Myomers is here, classID 0xBC6. See CLASSMAP.md.
// --------------------------------------------------------------------------
//
// CONFIDENCE
// confident : class identity, base, ClassID/size, vtable slot mapping,
// member layout (every member is backed by the @00511588
// attribute table or by a ctor store), resource layout
// (every field is backed by a CSS parse @004b9140),
// ctor/dtor/CSS/HandleMessage/ResetToInitialState/
// AvailableOutput/RegisterMaxOutput/HasAdequateVoltage/
// ConnectToMover/DisconnectFromMover bodies.
// best-effort : MyomersSimulation (@004b8d18) is the per-tick Performance
// (only reachable via function pointer; SetPerformance call
// site not captured -- see note); [slot 15 @004b8f94 RESOLVED
// issue #11: SeekVoltageResponse -- the SeekVoltageGraph
// sampler, sqrt not fabs]; ToggleSeekVoltage (@004b8a48) not present
// in the decompiled shards; the heat-base field @0x150 and
// graphic-state block (this[7..9]) inherit-pattern stores.
// excluded : @0x4b7f94..@0x4b881c (gimbal / torso-horizon display, base
// 4b18a4, DAT_00511228) -- adjacent cluster, NOT Myomers.
// @0x4b95ec MechWeapon abstract-sim trap -- next class.
// All inherited PoweredSubsystem/HeatSink/Subsystem vtable
// slots (1-8, 11-14) are inherited, not redefined here.
//
#include "myomers.hpp" // FIRST: neutralizes powersub.hpp's stale 'Myomers'
#include <bt.hpp> // engine aggregate: NotationFile / NameList / Generator / ...
#pragma hdrstop
#if !defined(APP_HPP)
# include <app.hpp>
#endif
#if !defined(TESTBT_HPP)
# include <testbt.hpp>
#endif
#include <math.h> // fabs (speed-gauge magnitude)
//===========================================================================
// Shared Data Support (DefaultData @0051154c, ClassDerivations near it)
//
// Standard 4-arg Subsystem::SharedData boilerplate (cf. sensor.cpp):
// ClassDerivations / MessageHandlers / AttributeIndex are defined BEFORE
// DefaultData, which takes their address/reference.
//===========================================================================
Derivation
Myomers::ClassDerivations(
PoweredSubsystem::GetClassDerivations(), // returns Derivation* (no &)
"Myomers"
);
// task #14: the REAL shared data. The old empty static
// MessageHandlers/AttributeIndex meant (a) no inherited message handlers
// (the powered ids 4-8 among them), (b) an EMPTY UNCHAINED attribute index --
// even CurrentTemperature/InputVoltage resolved NULL on Myomers instances, so
// the Myomer engineering panel's cluster children were silently blank. Both
// now chain PoweredSubsystem's sets, and the SEVEN binary attributes
// (IndexEntry table @00511588) are published. Function-local statics per the
// static-init-order rule (reconstruction-gotchas #9).
Receiver::MessageHandlerSet&
Myomers::GetMessageHandlers()
{
// issue #19 (2026-07-21): register the binary's Myomers handler table
// @0x51158C {9, "ToggleSeekVoltage" -> @004b8a48} on top of the inherited
// powered ids -- the id was unregistered, so the ADV-mode seek-gear press
// was silently swallowed (Receiver::Receive NullHandler).
static const Receiver::HandlerEntry entries[]=
{
MESSAGE_ENTRY(Myomers, ToggleSeekVoltage) // id 9 @004b8a48
};
static Receiver::MessageHandlerSet messageHandlers(
ELEMENTS(entries), entries,
PoweredSubsystem::GetMessageHandlers()); // copy-inherit (ids 4-8)
return messageHandlers;
}
Myomers::AttributeIndexSet&
Myomers::GetAttributeIndex()
{
static const Myomers::IndexEntry entries[]=
{
ATTRIBUTE_ENTRY(Myomers, SpeedEffect, speedEffect), // 0x12 @0x31C
ATTRIBUTE_ENTRY(Myomers, CurrentSeekVoltageIndex, currentSeekVoltageIndex), // 0x13 @0x320
ATTRIBUTE_ENTRY(Myomers, RecommendedSeekVoltageIndex, recommendedSeekVoltageIndex), // 0x14 @0x324
ATTRIBUTE_ENTRY(Myomers, MinSeekVoltageIndex, minSeekVoltageIndex), // 0x15 @0x328
ATTRIBUTE_ENTRY(Myomers, MaxSeekVoltageIndex, maxSeekVoltageIndex), // 0x16 @0x32C
ATTRIBUTE_ENTRY(Myomers, SeekVoltage, seekVoltage), // 0x17 @0x330 (array base)
ATTRIBUTE_ENTRY(Myomers, OutputVoltage, outputVoltage) // 0x18 @0x344
};
static Myomers::AttributeIndexSet attributeIndex(
ELEMENTS(entries), entries,
PoweredSubsystem::GetAttributeIndex()
);
return attributeIndex;
}
Myomers::SharedData
Myomers::DefaultData(
&Myomers::ClassDerivations,
Myomers::GetMessageHandlers(),
Myomers::GetAttributeIndex(),
Myomers::StateCount
);
//===========================================================================
// Recovered float constants (section_dump.txt)
// _DAT_004b8b94 = 0.0f _DAT_004b8b98 = 1.0f
// _DAT_004b8ee4 = 0.5f _DAT_004b8ee8 = 0.0f _DAT_004b8eec = 1.0f
// _DAT_004b9110 = -1.0f (SeekVoltage list sentinel)
// _DAT_004b9470 = -1.0f (efficiency "missing" sentinel)
//===========================================================================
//***************************************************************************
// Myomers::Myomers @004b8fec
//***************************************************************************
//
// Chains to PoweredSubsystem::PoweredSubsystem (@004b0f74) with the Myomers
// DefaultData (&DAT_0051154c), then installs vtable @005117dc and builds the
// drive table. Field-store evidence (this[idx] -> byte offset):
//
// this[199]=0x3f800000 speedEffect@0x31C = 1.0f
// this[0xd2]=fail-degrade heatRange@0x348 = failureTemp(@0x11C) - degradationTemp(@0x118)
// this[0xd3]=range*range heatRangeSquared@0x34C = heatRange * heatRange
// this[0xd4]=res+0x190 velocityEfficiency@0x350
// this[0xd5]=res+0x194 accelerationEfficiency@0x354
// this[0x54]=0x3e19999a heat-base field @0x150 = 0.15f (best-effort; inherited HeatSink coeff)
// this[10]|=8 Subsystem flags @0x28 |= 8
// (cond) this[7..9] = graphic-state PTR_LAB_00511620 / DAT_00511624 / DAT_00511628
// when (flags@0x28 & 0xC)==0 && (flags & 0x100)!=0 (inherited render hook)
//
// seek-voltage table: source = FUN_00417ab4(this+0x1D0) resolves the
// VoltageSource connection to its Generator; the resource SeekVoltage[]
// fractions (res+0x198) are multiplied by the Generator's ratedVoltage
// (source+0x1D8) and stored into seekVoltage[]@0x330, stopping at the -1.0
// sentinel. maxSeekVoltageIndex@0x32C = (count-1).
// recommendedSeekVoltageIndex@0x324 = res+0x1AC; currentSeekVoltageIndex@0x320
// is seeded to the same; minSeekVoltageIndex@0x328 = 0.
//
Myomers::Myomers(
Mech *owner,
int subsystem_ID,
SubsystemResource *resource,
SharedData &shared_data)
: PoweredSubsystem(owner, subsystem_ID, resource, shared_data) // @004b0f74, &DAT_0051154c
{
// vtable @005117dc installed by the compiler here.
speedEffect = 1.0f; // @0x31C
heatRange = failureTemperature - degradationTemperature; // @0x348 (this[0x11C]-this[0x118])
heatRangeSquared = heatRange * heatRange; // @0x34C
velocityEfficiency = resource->velocityEfficiency; // @0x350 res+0x190
accelerationEfficiency = resource->accelerationEfficiency; // @0x354 res+0x194
// (heat-base default coefficient @0x150 = 0.15f; inherited HeatSink slot)
// (Subsystem flags @0x28 |= 8; optional graphic-state hook -- see header note)
// (WAVE 6 de-shim) the owner*/damageStructure shim backing fields are gone --
// their accessors return neutral defaults directly. The mover handle lives at
// base+0x110 (not a Myomers own field); the coupling is inert, so no init here.
// Register the per-tick Performance. NB [0x511620] resolves to @004b8b9c,
// the WRAPPER (which calls PoweredSubsystemSimulation @0x4b0bd0 first and
// then the drive-heat integrator @004b8d18) -- our MyomersSimulation plays
// the wrapper's role and chains the base itself; see the note on its body.
// The binary ctor @004b8fec stores that
// pointer-to-member into activePerformance (this[7..9] =
// PTR_LAB_00511620 / DAT_00511624 / DAT_00511628) under the live-master
// guard (owner segment flags & 0xC)==0 && (flags & 0x100) -- the exact same
// shape as the Gyroscope/Torso ctors (gyro this[7..9]=PTR_FUN_0050fe08,
// torso this[7..9]=PTR_FUN_00510c10). The earlier reconstruction mislabeled
// this store a "graphic-state hook" and omitted the registration, leaving
// activePerformance at the DoNothingOnce default (drops out after frame 1).
// (WAVE 6) the segmentFlags shim is removed; the gate reads OWNER
// simulationFlags (param_2+0x28) directly -- the oracle-verified source.
if ((owner->simulationFlags & SegmentCopyMask) == 0
&& (owner->simulationFlags & MasterHeatSinkFlag) != 0) // owner flags & 0x100 (binary @004b8fec)
{
SetPerformance(&Myomers::MyomersSimulation); // this[7..9] = &MyomersSimulation
}
// Resolve the powering Generator (PoweredSubsystem::ResolveVoltageSource,
// FUN_00417ab4(this+0x1D0)) and build the seek-voltage drive table by
// scaling each resource fraction by the Generator's rated voltage.
Generator *source = (Generator *)ResolveVoltageSource(); // this+0x1D0 -> Generator
Scalar srcRated = (source != 0) ? source->ratedVoltage : 0.0f; // source+0x1D8
maxSeekVoltageIndex = -1;
int count = 0;
while (count < 5) {
if (resource->seekVoltage[count] == -1.0f /*_DAT_004b9110*/) {
maxSeekVoltageIndex = count - 1; // @0x32C
break;
}
seekVoltage[count] = resource->seekVoltage[count]
* srcRated; // @0x330[count] (source+0x1D8)
++count;
}
if (maxSeekVoltageIndex < 0)
maxSeekVoltageIndex = count - 1; // list filled all 5 slots
recommendedSeekVoltageIndex = resource->seekVoltageRecommendedIndex; // @0x324 res+0x1AC
currentSeekVoltageIndex = recommendedSeekVoltageIndex; // @0x320
minSeekVoltageIndex = 0; // @0x328
}
//***************************************************************************
// Myomers::~Myomers @004b9114
//***************************************************************************
// vtable slot 0. Thin; chains to the PoweredSubsystem chain.
Myomers::~Myomers()
{
}
//***************************************************************************
// Myomers::CreateStreamedSubsystem @004b9140
//***************************************************************************
//
// Chains to PoweredSubsystem::CreateStreamedSubsystem (@004b13ac) for the
// shared electrical/thermal fields, then:
// * stamps resource+0x20 = 0x0BC6 (MyomersClassID),
// resource+0x24 = 0x1B0 (streamed model size).
// * on first pass (passes==1) initialises VelocityEfficiency / Acceleration-
// Efficiency (res+0x190/+0x194) to -1.0, the 5 SeekVoltage slots
// (res+0x198..) to -1.0, and SeekVoltageRecommendedIndex (res+0x1AC) to -1.
// * parses "VelocityEfficiency" -> res+0x190 (missing/-1.0 -> error)
// * parses "AccelerationEfficiency" -> res+0x194 (missing/-1.0 -> error)
// * parses the "SeekVoltage" notation list: each child either sets
// "SeekVoltageRecommendedIndex" (res+0x1AC) or appends a SeekVoltage
// fraction (res+0x198..); a missing list or missing recommended index is
// an error.
//
int Myomers::CreateStreamedSubsystem(
NotationFile *model_file,
const char *model_name,
const char *subsystem_name,
SubsystemResource *resource,
NotationFile *subsystem_file,
const ResourceDirectories *directories,
int passes)
{
if (!PoweredSubsystem::CreateStreamedSubsystem(model_file, model_name,
subsystem_name, resource, subsystem_file, directories, passes))
return False; // @004b13ac
resource->subsystemModelSize = sizeof(*resource); // resource +0x24 (0x1B0)
resource->classID = (RegisteredClass::ClassID)0x0BC6; // resource +0x20 (MyomersClassID)
if (passes == 1) {
resource->velocityEfficiency = -1.0f; // +0x190
resource->accelerationEfficiency = -1.0f; // +0x194
for (int i = 0; i < 5; ++i)
resource->seekVoltage[i] = -1.0f; // +0x198..
resource->seekVoltageRecommendedIndex = -1; // +0x1AC
}
// "VelocityEfficiency" / "AccelerationEfficiency" -- required scalars.
if (!subsystem_file->GetEntry(model_name, "VelocityEfficiency",
&resource->velocityEfficiency) // s_VelocityEfficiency_00511709
&& resource->velocityEfficiency == -1.0f) {
// Warning: "<subsystem> Missing VelocityEfficiency!" (0051171c)
return False;
}
if (!subsystem_file->GetEntry(model_name, "AccelerationEfficiency",
&resource->accelerationEfficiency) // s_AccelerationEfficiency_00511739
&& resource->accelerationEfficiency == -1.0f) {
// Warning: "<subsystem> Missing AccelerationEfficiency!" (00511750)
return False;
}
// "SeekVoltage" notation list: each child either sets the recommended index
// or appends a seek-voltage fraction (up to 5). (00511771)
NameList *list = subsystem_file->MakeEntryList(model_name, "SeekVoltage");
if (list == 0 && resource->seekVoltageRecommendedIndex == -1) {
// Warning: "<model> missing SeekVoltage " (0051177d)
} else if (list != 0) {
Scalar *out = resource->seekVoltage; // +0x198
Scalar *end = resource->seekVoltage + 5;
for (NameList::Entry *node = list->GetFirstEntry();
node != 0;
node = node->GetNextEntry())
{
if (strcmp(node->GetName(), "SeekVoltageRecommendedIndex") == 0) // 00511793
resource->seekVoltageRecommendedIndex = node->GetAtoi(); // -> +0x1AC
else if (out < end)
*out++ = (Scalar)node->GetAtof(); // append fraction
}
delete list;
if (resource->seekVoltageRecommendedIndex == -1) {
// Warning: "<model> missing SeekVoltageRecommendedIndex!" (005117af)
return False;
}
}
return True;
}
//***************************************************************************
// Myomers::AvailableOutput @004b8ac0
//***************************************************************************
//
// Core drive computation: returns the locomotion drive available for a given
// input voltage. (helper; called by RegisterMaxOutput and SeekVoltageResponse.)
//
// base = ownerMech[0x34C] * (input_voltage / seekVoltage[recommendedIndex])
// ^ Mech base-speed scalar ^ normalise to the recommended gear
//
// heatFactor (thermal degradation, mirrors the other PoweredSubsystem leaves):
// if currentTemp >= degradationTemp:
// if currentTemp >= failureTemp -> 0.0 (burned out)
// else d = currentTemp - degradationTemp;
// heatFactor = (base==0)? 1.0 : 1.0 - d*d/heatRangeSquared
// else -> 1.0 (cold, full drive)
//
// return (1.0 - damage[0x158]) * base * heatFactor;
// ^ accumulated damage/wear (this[0xE0]->structureLevel@0x158) bleeds off output.
//
Scalar Myomers::AvailableOutput(Scalar input_voltage)
{
Scalar base = OwnerBaseSpeed() // *(Mech@this[0xD0] + 0x34C)
* (input_voltage / seekVoltage[recommendedSeekVoltageIndex]); // @0x330[@0x324]
Scalar heatFactor;
if (degradationTemperature <= currentTemperature) { // 0x118 <= 0x114
if (failureTemperature <= currentTemperature) { // 0x11C <= 0x114
heatFactor = 0.0f;
} else {
Scalar d = currentTemperature - degradationTemperature;
heatFactor = (base == 0.0f)
? 1.0f
: (base - d * d * (base / heatRangeSquared)) / base; // == 1 - d*d/heatRangeSquared
}
} else {
heatFactor = 1.0f;
}
Scalar damage = DamageStructureLevel(); // *(this[0xE0] + 0x158)
return (1.0f - damage) * base * heatFactor; // _DAT_004b8b98 = 1.0
}
//***************************************************************************
// Myomers::RegisterMaxOutput @004b8ef0
//***************************************************************************
//
// Computes this myomer's full-throttle drive (AvailableOutput at the top gear
// seekVoltage[maxSeekVoltageIndex]) and raises the owner Mech's top-speed
// field (Mech[0x7A0]) to it. Called once per myomer while the Mech is being
// assembled (loop @part_012.c:10322 over the Mech's myomer list @0x1EB).
//
void Myomers::RegisterMaxOutput()
{
Scalar best = AvailableOutput(seekVoltage[maxSeekVoltageIndex]); // @0x330[@0x32C]
if (best > OwnerMaxSpeed()) // Mech[0x7A0]
SetOwnerMaxSpeed(best);
}
//***************************************************************************
// Myomers::SeekVoltageResponse slot 15 @004b8f94
//***************************************************************************
//
// PoweredSubsystem virtual slot 15 (base @004b1780) == the shared subsystem
// vtbl+0x3C sampler the SeekVoltageGraph's Execute (@004c6934) plots -- THIS
// is what draws the myomer engineering page's POWER curve (issue #11; the
// old "drives a cockpit speed gauge" guess and the GetSpeedReading name are
// retired). Converts AvailableOutput(input_voltage) into the graph's 0..1
// x-response: scaled by 3.6 (m/s -> km/h), normalised to a 350 km/h full
// scale, then FUN_004dd138 == SQRT (part_015.c:4026 -- the old "fp magnitude
// / fabs" reading was wrong; sqrt is also what Emitter's analog @004bb42c
// applies to its power ratio). (Decompiled body discards the result ->
// returned via FPU.)
//
Scalar Myomers::SeekVoltageResponse(Scalar input_voltage)
{
Scalar output = AvailableOutput(input_voltage); // @004b8ac0
Scalar r = (output * 3.6f - 0.0f) / 350.0f;
// (FUN_004dd138 routes a negative operand to the matherr handler; output
// >= 0 in practice -- guarded for port safety.)
return (r > 0.0f) ? (Scalar)sqrt((double)r) : 0.0f; // FUN_004dd138 (sqrt)
}
//***************************************************************************
// issue #11 bridge -- the Myomers leg of the SeekVoltageGraph sample dispatch
// (see emitter.cpp BTSeekVoltageSample). Guarded: a non-Myomers subsystem
// samples 0 (only emitter/myomer pages own a graph in the shipped config).
//***************************************************************************
Scalar BTMyomersSeekSample(void *subsystem, Scalar voltage)
{
Entity *entity = (Entity *)subsystem;
if (entity == 0 || !entity->IsDerivedFrom(Myomers::ClassDerivations))
return 0.0f;
return ((Myomers *)entity)->SeekVoltageResponse(voltage); // @004b8f94
}
//***************************************************************************
// Myomers::HasVoltage slot 16 @004b8f3c
//***************************************************************************
//
// Override of PoweredSubsystem::HasVoltage (slot 16, base @004b0b5c) -- Gitea
// #62 rename: the base was misnamed IsSourceShorted, asserting the inverse of
// what it computes. Myomers tightens it: adequate = at least the SELECTED SEEK
// voltage, not merely non-zero.
// Returns True only when the (resolved) voltage source can supply at least
// the currently-selected seek voltage AND is in the Ready state (state==2).
//
// if (source == 0) source = ResolveVoltageSource(); // FUN_00417ab4(this+0x1D0)
// if (source == 0) return False;
// return seekVoltage[currentSeekVoltageIndex] <= source->outputVoltage
// && source->state == 2; // source+0x1DC, source+0x210
//
Logical Myomers::HasVoltage(Subsystem *source)
{
Generator *gen = source ? (Generator *)source
: (Generator *)ResolveVoltageSource(); // FUN_00417ab4(this+0x1D0)
if (gen == 0)
return False;
if (seekVoltage[currentSeekVoltageIndex] <= gen->MeasuredVoltage() // @0x330[@0x320] <= src+0x1DC
&& gen->GeneratorStateOf() == Generator::GeneratorReady) // src+0x210 == 2
return True;
return False;
}
// The un-powered recovery rate the Performance hands its own damage zone each
// tick, as a NEGATIVE damageAmount: the literal 0xbc343958 stored at @004b8bf0.
static const Scalar MyomerRecoveryPerTick = -0.010999999940395355f; // 0xbc343958
//***************************************************************************
// Myomers::MyomersSimulation Performance @004b8b9c
//***************************************************************************
//
// THE REGISTERED PERFORMANCE -- the outer wrapper, decoded in full 2026-07-28.
// The ctor's [0x511620] resolves to 0x4b8b9c, NOT 0x4b8d18; the port had
// registered the inner drive-heat integrator and dropped this whole wrapper.
// Five blocks, in binary order:
//
// @004b8bab chain PoweredSubsystem::PoweredSubsystemSimulation (0x4b0bd0)
// @004b8bb9 un-powered self-repair of this myomer's own zone (INERT -- see
// the block comment; the original is dead code here too)
// @004b8c1e republish outputVoltage@0x344 from the resolved source
// @004b8c5a republish speedEffect@0x31C, the drive handed to the mover
// @004b8ceb run the inner integrator -- ONLY when outputVoltage > 0
//
// Restoring the first block alone fixed Gitea #70 (torso twist dead after a
// respawn): the Torso is a PowerWatcher that MIRRORS this subsystem's
// electrical level, and with the base machine never running, a Myomers that
// lost power in the death/reset window sat at NoVoltage forever. The other
// four are the rest of that same dropped wrapper.
//
void Myomers::MyomersSimulation(Scalar time_slice)
{
// 1. @004b8bab -- the base electrical state machine. MUST come first, and
// must precede any heat-model gate: OwnerAdvancedDamage() is off below
// veteran, so gating ahead of this denied the machine to most pilots.
PoweredSubsystemSimulation(time_slice); // call 0x4b0bd0
// 2. @004b8bb9-0x4b8c1b -- the INTENDED un-powered self-repair. While this
// myomer has NO VOLTAGE and its own zone is not yet destroyed, hand that
// zone a NEGATIVE explosive Damage every tick. DamageZone::TakeDamage is
// T0 (engine/MUNGA/DAMAGE.cpp:374) and reads only damageAmount/damageType:
// damageLevel += damageAmount * damageScale[damageType];
// Clamp(damageLevel, 0.0f, 1.0f);
// so the READING is "a myomer you power down slowly heals, and the `< 1.0`
// test stops a destroyed zone being resurrected".
//
// ⚠ BUT IT IS INERT -- IN THE ORIGINAL TOO. [T0, measured 2026-07-28]
// A subsystem's private zone is built by the 2-arg trivial ctor
// `new DamageZone(this, 0)`, and DAMAGE.cpp:187-190 zeroes ALL FIVE
// damageScale[] entries; DamageZone::Reset never touches them, and the only
// other writer in the codebase is Mech__DamageZone (the MECH's streamed
// zones, a different class -- mechdmg.cpp:246-253). So damageScale stays
// {0,0,0,0,0} for the life of the zone and the sum above is always
// `damageLevel += amount * 0.0f` == no change. Confirmed live: seeded to
// 0.6, held at NoVoltage for ~1500 ticks, damageLevel never moved off 0.6
// (BT_MYOMERS_REPAIR_TEST below). The crit path reaches subsystem damage a
// different way -- it writes damageLevel DIRECTLY (mechsub.cpp
// DistributeCriticalHit pins *(this[0x38]+0x158) = 1.0f) -- which is why
// subsystems can still be destroyed even though this route cannot.
//
// Reconstructed anyway, and deliberately NOT "fixed": this is what the 1995
// binary does, and inventing a working repair here would be a behavior we
// made up. Kept so the dead branch is visible rather than silently missing.
// (impactPoint/burstCount are set for fidelity; the callee ignores both.)
if (electricalStateAlarm.GetLevel() == PoweredSubsystem::NoVoltage // this+0x278 == 1
&& DamageStructureLevel() < 1.0f) // zone+0x158 < _DAT_004b8d10
{
extern int BTMechEntityPosition(void *entity, float *out_xyz); // mech4.cpp
float ownerXYZ[3] = { 0.0f, 0.0f, 0.0f };
BTMechEntityPosition(owner, ownerXYZ); // owner+0x100
Damage repair; // FUN_0041db7c (Damage::Damage)
repair.damageType = Damage::ExplosiveDamageType; // +0x00 = 2
repair.damageAmount = MyomerRecoveryPerTick; // +0x04 = 0xbc343958
repair.impactPoint.x = ownerXYZ[0]; // +0x20 <- FUN_00408440
repair.impactPoint.y = ownerXYZ[1];
repair.impactPoint.z = ownerXYZ[2];
repair.burstCount = 1; // +0x2C
ApplyZoneDamage(repair); // zone vtable +0x18
}
// 3. @004b8c1e-0x4b8c5a -- republish the input voltage. (The binary also
// zeroes +0x344 when simulationState == Destroyed just above this, then
// overwrites it unconditionally here; that store is dead in the original
// too, so it is not reproduced.)
Generator *source = (Generator *)ResolveVoltageSource(); // FUN_00417ab4(this+0x1D0)
outputVoltage = (electricalStateAlarm.GetLevel() == PoweredSubsystem::Ready
&& source != 0)
? source->MeasuredVoltage() // source+0x1DC
: 0.0f;
// 4. @004b8c5a-0x4b8ce8 -- republish speedEffect, the live drive fed to the
// mover. Clamp the input voltage to the SELECTED gear and then to the top
// gear, then normalise AvailableOutput by the same Mech base speed it was
// scaled by -- so speedEffect is a 0..1 FRACTION of full drive carrying the
// gear ratio, the thermal-degradation curve and the accumulated zone damage.
if (!HasVoltage(0)) // vtable +0x40 (slot 16)
{
speedEffect = 0.0f;
}
else
{
Scalar drive = outputVoltage; // @0x344
if (drive >= seekVoltage[currentSeekVoltageIndex]) // @0x330[@0x320]
drive = seekVoltage[currentSeekVoltageIndex];
if (drive > seekVoltage[maxSeekVoltageIndex]) // @0x330[@0x32C]
drive = seekVoltage[maxSeekVoltageIndex];
speedEffect = AvailableOutput(drive) / OwnerBaseSpeed(); // @004b8ac0 / (owner+0x34C)
}
// BRING-UP SCAFFOLDING (temporary, env-gated, off by default).
//
// BT_MYOMERS_REPAIR_TEST=<level> seeds this myomer's OWN zone to <level> once,
// at frame 300, so the un-powered self-repair above has something to walk
// back. Nothing else can damage that zone on a bench: unaimed self-damage
// goes through the mech's hit-location table and never crits a myomer, so
// block 2 of the Performance would otherwise stay unexercised. Pair it with
// BT_POWER_DETACH_TEST=Myomers to open the NoVoltage window.
// Self-contained: it also cuts the voltage link and drops the subsystem into
// MANUAL, so the AutoConnect hunt does NOT put it straight back (which is
// what BT_POWER_DETACH_TEST does -- that one re-attaches within a frame or
// two, far too short a window to watch a repair).
if (getenv("BT_MYOMERS_REPAIR_TEST"))
{
static int s_rtFrame = 0;
if (++s_rtFrame == 300)
{
Scalar seed = (Scalar)atof(getenv("BT_MYOMERS_REPAIR_TEST"));
if (seed <= 0.0f) seed = 0.5f;
SetSubsystemDamageLevel(seed); // mechsub.cpp bridge
DetachFromVoltageSource(); // @004b0e30
modeAlarm.SetLevel(ManualConnect); // stay dark (no auto-hunt)
DEBUG_STREAM << "[myo] SEED zone damage = " << seed
<< ", detached + forced Manual" << std::endl << std::flush;
}
}
// PROBE (BT_MYOMERS_LOG): the wrapper's outputs. Sample every frame while
// the electrical state is anything but Ready -- the NoVoltage window is the
// self-repair window and it only lasts about a second, so a once-a-second
// probe would step right over the transient.
if (getenv("BT_MYOMERS_LOG"))
{
static int s_ml = 0;
int notReady = (electricalStateAlarm.GetLevel() != PoweredSubsystem::Ready);
if (notReady || (s_ml % 120) == 0)
{
DEBUG_STREAM << "[myo] " << (GetName() ? GetName() : "?")
<< " elec=" << (int)electricalStateAlarm.GetLevel()
<< " outV=" << outputVoltage
<< " speed=" << speedEffect
<< " dmg=" << DamageStructureLevel()
<< " gear=" << currentSeekVoltageIndex
<< std::endl << std::flush;
}
++s_ml;
}
// 5. @004b8ceb -- the drive-heat integrator runs ONLY with voltage present.
if (outputVoltage > 0.0f) // vs _DAT_004b8d14 (0.0f)
MyomersDriveHeat(time_slice); // call 0x4b8d18
}
//***************************************************************************
// Myomers::MyomersDriveHeat inner @004b8d18
//***************************************************************************
//
// Per-tick drive/heat integration. Reached only from the wrapper above, and
// only while outputVoltage > 0.
//
// Reads the owner Mech's motion state and the seek-voltage ratio, then -- when
// the sim-control "advanced damage" gate is on (FUN_004ad7d4: *(Mech[0xD0]+0x190)
// +0x260) -- accumulates mechanical-work heat into the HeatSink heat
// accumulator @0x1C8:
//
// v = |Mech velocity vector| (Mech +0x1C4/0x1C8/0x1CC, magnitude)
// a = |Mech accel/desired vector| (Mech +0x82C/0x830/0x834, magnitude)
// vDot= Mech[0x20C] * (dot of the velocity vector with itself) * _DAT_004b8ee4(0.5)
// ratio = seekVoltage[current]/seekVoltage[recommended], clamped >= 1.0
// damageGain = 1.0 + damage[0x158]
// velTerm = (1 - accelerationEfficiency) * |Vy| * Mech[0x20C] * *Mech[0x250] * dt
// accTerm = (1 - accelerationEfficiency) * |v| * |a| * Mech[0x20C] * dt
// work = (1 - velocityEfficiency) * vDot
// heat[0x1C8] += ratio*ratio * damageGain * (velTerm + work + accTerm)
//
// (Field roles in the work expression are best-effort; the structure -- heat
// rises with the square of the gear ratio, with damage, and with the
// inefficiency complements of VelocityEfficiency/AccelerationEfficiency -- is
// faithful to the decompiled arithmetic.)
//
void Myomers::MyomersDriveHeat(Scalar time_slice)
{
if (!OwnerAdvancedDamage()) // FUN_004ad7d4 gate
return;
Scalar velMag = OwnerVelocityMag(); // |Mech velocity| (+0x1C4/0x1C8/0x1CC)
Scalar accMag = OwnerDriveMag(); // |Mech drive vector| (+0x82C/0x830/0x834)
Scalar vy = OwnerVelocityY(); // Mech +0x1C8
Scalar k = OwnerMotionGain(); // Mech +0x20C
Scalar m = OwnerMass(); // *Mech[0x250]
Scalar velComplement = 1.0f - accelerationEfficiency; // @0x354
Scalar workComplement = 1.0f - velocityEfficiency; // @0x350
Scalar damageGain = 1.0f + DamageStructureLevel(); // this[0xE0]+0x158
Scalar ratio = seekVoltage[currentSeekVoltageIndex] // @0x330[@0x320]
/ seekVoltage[recommendedSeekVoltageIndex];// @0x330[@0x324]
if (ratio < 1.0f) ratio = 1.0f;
Scalar work = k * (velMag * velMag) * 0.5f; // dot(v,v)*0.5 == |v|^2*0.5 (_DAT_004b8ee4)
pendingHeat /* @0x1C8 */ +=
ratio * ratio * damageGain *
( velComplement * vy * k * m * time_slice
+ workComplement * work
+ velComplement * velMag * accMag * k * time_slice );
}
//***************************************************************************
// Myomers::HandleMessage slot 9 @004b8a8c
//***************************************************************************
// Thin override; forwards to PoweredSubsystem::HandleMessage (@004b0efc).
Logical Myomers::HandleMessage(int message)
{
return PoweredSubsystem::HandleMessage(message); // @004b0efc
}
//***************************************************************************
// Myomers::ResetToInitialState slot 10 @004b8aa4
//***************************************************************************
// Thin override; forwards to PoweredSubsystem::ResetToInitialState (@004b0e6c).
void Myomers::ResetToInitialState(Logical powered)
{
PoweredSubsystem::ResetToInitialState(powered); // @004b0e6c
}
//***************************************************************************
// Myomers::ConnectToMover (mislabeled)
//***************************************************************************
//
// ⚠ MISATTRIBUTION (task #5 correction): @004b9550/@004b95b8 are NOT Myomers
// functions -- the weapon message-table @0x511860 proves they are
// MechWeapon::ConfigureMappables (id 9) / ChooseButton (id 10), the weapon->
// button grouping handlers (the +0x31C coincidence: Myomers speedEffect vs
// MechWeapon TriggerState). The bodies below are retained as the port's
// Myomers stand-ins but carry no binary address.
//
// Attaches the live SpeedEffect output (@0x31C) into the Mech's JointedMover
// roster (the mover at **(Mech[0xD0]+0x128)). Driven by a connect message
// whose payload index is at message+0xC:
// index = abs(message->value) - 1;
// if (message->value < 1) { mover->Detach(index); this[0x110] = -1; }
// else { this[0x110] = 0; mover->Attach(index, &speedEffect, this, 10, 9, 0); }
// (mover vtable +0x38 = Attach, +0x3C = Detach.)
//
void Myomers::ConnectToMover(SubsystemMessage &message)
{
int index = abs(message.value) - 1; // message+0xC
if (message.value < 1) {
MoverDetach(index); // owner mover vtable +0x3C
/* base+0x110 = -1 (detached) -- inert, no base accessor yet */
} else {
/* base+0x110 = 0 (attached) -- inert, no base accessor yet */
MoverAttach(index, &speedEffect); // owner mover vtable +0x38, feed @0x31C
// (original args: index,&speedEffect,this,10,9,0)
}
}
//***************************************************************************
// Myomers::DisconnectFromMover (stand-in; no binary address)
//***************************************************************************
// Counterpart of ConnectToMover: detaches the SpeedEffect feed.
// if (message->value > 0)
// mover->Release(message->value, &speedEffect); // mover vtable +0x44
void Myomers::DisconnectFromMover(SubsystemMessage &message)
{
if (message.value > 0) { // message+0xC
MoverRelease(message.value, &speedEffect); // owner mover vtable +0x44, feed @0x31C
}
}
//***************************************************************************
// Myomers::ToggleSeekVoltageMessageHandler @004b8a48
//***************************************************************************
// Handler table @0x51158C: {9, "ToggleSeekVoltage"}. Reconstructed FAITHFULLY
// from the raw disassembly (tools/disas2.py 0x4b8a48; the decomp shards missed
// the untagged gap 0x4b8837..0x4b8a8c) -- issue #19 fix, 2026-07-21:
// 0x4b8a50 call 0x4ac9c8 novice lockout (BTPlayerRoleLocksAdvanced) -> ret
// 0x4b8a5b call 0x4ad7d4 heat-model experience gate -> ret if OFF
// 0x4b8a68 msg+0xc <= 0 press only (release is negative)
// 0x4b8a6f idx@0x320 = (idx + 1) % (maxSeekVoltageIndex@0x32C + 1)
// Note: modulo (max+1) from ZERO -- minSeekVoltageIndex is NOT consulted, and
// there is no ResetFiringState (both unlike the Emitter sibling @004ba478).
void Myomers::ToggleSeekVoltageMessageHandler(ReceiverDataMessageOf<int> *message)
{
if (IsDamaged()) // FUN_004ac9c8 -- NOVICE lockout bridge
{
return;
}
if (!HeatModelActive() || message->dataContents <= 0) // FUN_004ad7d4; msg+0xc
{
return;
}
currentSeekVoltageIndex = // @0x320
(currentSeekVoltageIndex + 1) % (maxSeekVoltageIndex + 1); // @0x32C
if (getenv("BT_SEEK_LOG"))
DEBUG_STREAM << "[seek] " << GetName() << " -> gear "
<< currentSeekVoltageIndex << std::endl;
}
//===========================================================================//
// WAVE 6 -- compile-time OVERFLOW lock (the real class must fit the 0x358
// factory alloc). Like Sensor, the heat-leaf base (PoweredSubsystem:HeatSink)
// is NOT byte-exact to the binary, so we lock only the overflow bound, not the
// exact field offsets -- nothing reads Myomers at a raw offset today (the mover
// feed + gauge readouts are inert) and MyomersSimulation uses named members.
// The de-shim dropped the owner*/segmentFlags backing fields to make room.
//===========================================================================//
struct MyomersLayoutCheck
{
// BYTE-EXACT: with PoweredSubsystem byte-exact (ends 0x31C) and the phantom
// moverConnection tail removed, Myomers' own fields (ctor @004b8fec: speedEffect@0x31C
// .. accelerationEfficiency@0x354) land exactly at 0x31C..0x358.
static_assert(offsetof(Myomers, speedEffect) == 0x31C, "Myomers::speedEffect @0x31C (attr 0x12)");
static_assert(offsetof(Myomers, seekVoltage) == 0x330, "Myomers::seekVoltage @0x330 (attr 0x17)");
static_assert(sizeof(Myomers) == 0x358, "sizeof(Myomers) 0x358 (factory alloc, byte-exact)");
};
//===========================================================================//
// WAVE 6 factory bridge -- Myomers (factory case 0xBC6, "Actuator" stub).
// The real class at 0xBC6 (ctor @004b8fec) is Myomers (the mech's artificial-
// muscle drive); the factory built an Actuator RECON_SUBSYS stub. alloc 0x358.
// The subsystem constructs (resolves its Generator + builds the seek-voltage
// gear table) and ticks its real Performance, but is currently INERT w.r.t.
// locomotion/heat (no-op mover feed + the OwnerAdvancedDamage()==False sim gate)
// -- so it cannot regress the gait/drive. Authentic coupling = a follow-up.
//===========================================================================//
Subsystem *CreateMyomersSubsystem(Mech *owner, int id, void *seg)
{
return (Subsystem *) new (Memory::Allocate(0x358))
Myomers(owner, id, (Myomers::SubsystemResource *)seg);
}
//
// DeathReset (#55 step 4) -- the respawn sweep's entry for the Myomers.
//
void Myomers::DeathReset(int reset_command)
{
ResetToInitialState(reset_command != 0); // @004b8aa4
}