Files
BT411/game/reconstructed/myomers.hpp
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

393 lines
19 KiB
C++

//===========================================================================//
// File: myomers.hpp //
// Project: BattleTech Brick: Entity Manager //
// Contents: Myomers subsystem -- the mech's artificial-muscle drive. //
// Converts available voltage into locomotion "speed effect" and //
// the mechanical work that feeds the heat model. //
//---------------------------------------------------------------------------//
// Date Who Modification //
// -------- --- ---------------------------------------------------------- //
// --/--/95 ?? Initial coding. //
//---------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. All Rights reserved //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
//
// RECONSTRUCTED from the shipped binary (BTL4OPT.EXE). NO header survived;
// every declaration below is recovered from the decompiled cluster
// @0x4b8a48 .. @0x4b95b8 (just before MechWeapon @0x4b95ec), the object
// vtable @005117dc, the Myomers attribute IndexEntry table @00511588, and
// the embedded string pool @00511660 ("Myomers", "ToggleSeekVoltage",
// "SpeedEffect", "CurrentSeekVoltageIndex", "RecommendedSeekVoltageIndex",
// "MinSeekVoltageIndex", "MaxSeekVoltageIndex", "SeekVoltage",
// "OutputVoltage", "VelocityEfficiency", "AccelerationEfficiency",
// "SeekVoltageRecommendedIndex"). See myomers.cpp for per-method @ADDR
// evidence. Field offsets in comments are byte offsets in the shipped
// object (runtime size 0x358, alloc'd by Mech factory case 0xBC6 @part_012).
//
// ===========================================================================
// IMPORTANT CORRECTION (supersedes the earlier powersub.cpp / powersub.hpp
// "Myomers" block):
//
// powersub.cpp/.hpp described a class it CALLED "Myomers" at vtable
// @0050fb0c / ctor @004b1d18 / classID 0x0BC3, with inferred members
// outputVoltage/powered/voltageAvailable. That was a MISLABEL. @004b1d18
// is **Sensor** (proven by the @0050fae0 string pool "Sensor / RadarPercent
// / SelfTest / BadVoltage" and the surviving SENSOR.HPP); it is now in
// sensor.cpp with classID 0x0BC3.
//
// The REAL Myomers is THIS class:
// vtable @005117dc (NB: PTR_LAB_005117dc)
// ctor @004b8fec
// dtor @004b9114
// CSS @004b9140 (stamps classID 0x0BC6, streamed model size 0x1B0)
// DefaultData @0051154c
// classID 0x0BC6 (Mech factory case 0xBC6, alloc 0x358)
// The "outputVoltage/powered/voltageAvailable" triad invented in powersub
// has NO string backing and must be retired. Myomers' real members are
// the SpeedEffect / SeekVoltage[] drive table below.
// ===========================================================================
//
// A Myomers IS-A PoweredSubsystem (powersub.hpp): it is driven from a
// Generator segment, spins up over StartTime, and tracks the same electrical
// state machine. On top of that it owns a discrete table of "seek voltages"
// (drive gears), selectable at run time, and reports the resulting locomotion
// "speed effect" both to the Mech's mover and to the heat model.
//
#if !defined(MYOMERS_HPP)
# define MYOMERS_HPP
#if !defined(POWERSUB_HPP)
//
// powersub.hpp STILL declares a stale class it calls "Myomers" (the mislabeled
// Sensor -- vtable 0050fb0c / ctor 4b1d18 / classID 0xBC3, resolved to Sensor in
// sensor.hpp + CLASSMAP.md, with inferred members outputVoltage/powered/
// voltageAvailable that have no string backing). The AUTHENTIC Myomers is
// classID 0xBC6, declared below. We may not edit powersub.hpp, so the stale
// token is neutralized for the duration of the include, then the real class is
// declared. CROSS-FAMILY: powersub.hpp should DELETE its stale 'Myomers'
// (it duplicates the Sensor now owned by sensor.hpp).
//
# define Myomers Myomers__powersub_stale
# include "powersub.hpp"
# undef Myomers
#endif
//##################### Forward Class Declarations #######################
class Mech;
//###########################################################################
// SubsystemMessage -- minimal local stand-in.
//
// CROSS-FAMILY: the engine subsystem-message type is not present in the
// reconstructed/engine headers available here. The mover connect/disconnect
// handlers only read the signed attachment index/handle at message+0xC, so a
// one-field stand-in lets this module compile. Real need: the BT
// SubsystemMessage class.
//
#if !defined(BT_SUBSYSTEM_MESSAGE_SHIM)
# define BT_SUBSYSTEM_MESSAGE_SHIM
struct SubsystemMessage
{
int value; // message+0xC : signed mover-attachment index/handle
};
#endif
//###########################################################################
//############################# Myomers #################################
//###########################################################################
//
// vtable @005117dc, ctor @004b8fec, dtor @004b9114, shared data @0051154c.
// RegisteredClass::MyomersClassID == 0x0BC6 (stamped by CreateStreamedSubsystem
// @004b9140 into resource +0x20; streamed model size 0x1B0 into resource +0x24).
//
class Myomers:
public PoweredSubsystem
{
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Shared Data Support
//
public:
static Derivation ClassDerivations;
static SharedData DefaultData; // @0051154c
// task #14: accessor idiom (function-local statics) -- the old empty
// static MessageHandlers/AttributeIndex members starved the class of
// BOTH its own 7 binary attrs AND every inherited resolve (temps,
// InputVoltage) -> the Myomer engineering panel's children fed NULL.
static Receiver::MessageHandlerSet& GetMessageHandlers();
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Streamed model resource.
//
// Extends the PoweredSubsystem resource (which ends at startTime @+0x18C).
// The myomer-specific fields begin at +0x190. Parsed by
// CreateStreamedSubsystem @004b9140; streamed model size is 0x1B0.
//
struct Myomers__SubsystemResource:
public PoweredSubsystem::SubsystemResource
{
Scalar velocityEfficiency; // +0x190 "VelocityEfficiency" (-1.0 == missing -> error)
Scalar accelerationEfficiency; // +0x194 "AccelerationEfficiency" (-1.0 == missing -> error)
Scalar seekVoltage[5]; // +0x198 "SeekVoltage" (fraction of rated; list ends at -1.0 sentinel)
int seekVoltageRecommendedIndex;// +0x1AC "SeekVoltageRecommendedIndex" (-1 == missing -> error)
};
typedef Myomers__SubsystemResource SubsystemResource;
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Attribute Support
//
// Recovered IndexEntry table @00511588 (each record {attribID, nameptr,
// byte_offset|1, 0}). ToggleSeekVoltage (id 0x09) is a mappable COMMAND
// bound to the toggle handler @004b8a48; the rest are data attributes.
// (PoweredSubsystem::NextAttributeID == 0x12, so SpeedEffect resolves to
// 0x12 and the enum below is contiguous.)
//
// ToggleSeekVoltage -> id 0x09 -> handler @004b8a48 (command)
// SpeedEffect -> id 0x12 -> @0x31C
// CurrentSeekVoltageIndex -> id 0x13 -> @0x320
// RecommendedSeekVoltageIndex -> id 0x14 -> @0x324
// MinSeekVoltageIndex -> id 0x15 -> @0x328
// MaxSeekVoltageIndex -> id 0x16 -> @0x32C
// SeekVoltage -> id 0x17 -> @0x330 (array base)
// OutputVoltage -> id 0x18 -> @0x344
//
public:
enum {
ToggleSeekVoltageCommandID = 0x09, // mappable command (not in the attribute id run)
SpeedEffectAttributeID = PoweredSubsystem::NextAttributeID, // 0x12
CurrentSeekVoltageIndexAttributeID, // 0x13
RecommendedSeekVoltageIndexAttributeID, // 0x14
MinSeekVoltageIndexAttributeID, // 0x15
MaxSeekVoltageIndexAttributeID, // 0x16
SeekVoltageAttributeID, // 0x17
OutputVoltageAttributeID, // 0x18
NextAttributeID // 0x19
};
private:
protected:
static AttributeIndexSet& GetAttributeIndex(); // @00511588 table, task #14
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Test Class Support
//
public:
static Logical
TestClass(Mech&);
Logical
TestInstance() const;
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Construction and Destruction
//
public:
Myomers(
Mech *owner,
int subsystem_ID,
SubsystemResource *subsystem_resource,
SharedData &shared_data = DefaultData
); // @004b8fec
~Myomers(); // @004b9114
static int
CreateStreamedSubsystem( // @004b9140
NotationFile *model_file,
const char *model_name,
const char *subsystem_name,
SubsystemResource *subsystem_resource,
NotationFile *subsystem_file,
const ResourceDirectories *directories,
int passes = 1
);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Drive model.
//
// AvailableOutput() is the shared core: it converts an input voltage into
// the locomotion drive available, scaled by the Mech's base-speed factor,
// throttled by the heat-degradation curve, and attenuated by accumulated
// damage. RegisterMaxOutput() pushes the strongest myomer's full-throttle
// output to the Mech so the cockpit can show top speed.
//
public:
Scalar
AvailableOutput(Scalar input_voltage); // @004b8ac0 (helper, returns drive)
void
RegisterMaxOutput(); // @004b8ef0 (-> owner Mech max speed)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Simulation Support
//
// MyomersSimulation is the per-tick Performance: it integrates the work
// done driving the Mech's velocity/acceleration and accumulates the
// resulting heat (gated by the sim-control "advanced damage" flag).
//
public:
typedef void
(Myomers::*Performance)(Scalar time_slice);
void
SetPerformance(Performance performance)
{
Check(this);
activePerformance = (Simulation::Performance)performance;
}
// The REGISTERED Performance is the wrapper @004b8b9c (the ctor's
// [0x511620] resolves there, not to @004b8d18). It chains the base
// electrical machine, runs the un-powered self-repair, republishes
// outputVoltage/speedEffect, and only then -- and only when there IS
// voltage -- runs the inner drive-heat integrator @004b8d18 below.
void
MyomersSimulation(Scalar time_slice); // @004b8b9c (Performance)
void
MyomersDriveHeat(Scalar time_slice); // @004b8d18 (inner, voltage-gated)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Subsystem virtual overrides (vtable @005117dc)
//
public:
Logical
HandleMessage(int message); // slot 9, @004b8a8c (-> PoweredSubsystem @004b0efc)
void
ResetToInitialState(Logical powered = True); // slot 10, @004b8aa4 (-> PoweredSubsystem @004b0e6c)
// #55 step 4: the respawn sweep (Mech::Reset -> DeathReset) never
// reached this class -- it fell through to the empty Subsystem base, so
// this subsystem kept its previous life's state. Chains our own
// authentic slot-10 body.
void DeathReset(int reset_command);
// slot 15 (PoweredSubsystem base @004b1780) -- the SeekVoltageGraph's
// voltage-response sampler (issue #11: the shared subsystem
// vtbl+0x3C slot the graph's Execute @004c6934 calls; Emitter's
// analog is @004bb42c): sqrt(AvailableOutput(v)*3.6 / 350)
// -- normalised sqrt of the drive speed in km/h against a
// 350 km/h full scale. (Renamed from the best-effort
// "GetSpeedReading"; FUN_004dd138 is sqrt, not fabs.)
Scalar
SeekVoltageResponse(Scalar input_voltage); // slot 15, @004b8f94
// slot 16 (PoweredSubsystem::IsSourceShorted base @004b0b5c) -- Myomers
// requires the source to supply at least the selected seek voltage
// AND be in the Ready (==2) state.
Logical
HasVoltage(Subsystem *source = 0); // slot 16, @004b8f3c (overrides PoweredSubsystem::HasVoltage; Gitea #62 rename)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Mover-connection message handlers (bound via the simulation message map;
// route the live SpeedEffect @0x31C into / out of the Mech's JointedMover
// roster at owner Mech[0x128]).
//
protected:
void
ConnectToMover(SubsystemMessage &message); // @004b9550 (attach SpeedEffect feed)
void
DisconnectFromMover(SubsystemMessage &message); // @004b95b8 (detach SpeedEffect feed)
public:
// ToggleSeekVoltage message handler @004b8a48 (Myomers handler table
// @0x51158C: {9, "ToggleSeekVoltage"}). Issue #19: was an EMPTY stub +
// never registered, so the ADV-mode seek-gear button was silently
// swallowed (Receiver::Receive NullHandler). Body reconstructed from
// the raw disassembly (tools/disas2.py 0x4b8a48 -- the address falls in
// the untagged decomp gap 0x4b8837..0x4b8a8c):
// novice lockout (@4ac9c8) -> heat-model gate (@4ad7d4) -> press only
// (msg+0xc > 0) -> currentSeekVoltageIndex@0x320 =
// (idx + 1) % (maxSeekVoltageIndex@0x32C + 1). No ResetFiringState
// (unlike the Emitter sibling @004ba478).
enum { ToggleSeekVoltageMessageID = 9 };
void
ToggleSeekVoltageMessageHandler(ReceiverDataMessageOf<int> *message); // @004b8a48
protected:
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// CROSS-FAMILY compile shims (owning Mech / JointedMover roster / DamageZone).
//
// The drive and heat arithmetic below reads values that logically live on the
// owning Mech (base-speed, top-speed, velocity/acceleration, motion gain,
// mass, the sim-control "advanced damage" gate), on the Mech's JointedMover
// roster (Attach/Detach/Release of the SpeedEffect feed), and on this
// segment's DamageZone (structureLevel). None of those are reachable from a
// PoweredSubsystem here (owner is an incomplete Mech*, JointedMover exposes no
// roster API, and DamageZone is not in this hierarchy), so they are backed
// locally to keep the formulas intact and compiling. The real accessors
// belong on Mech / JointedMover / DamageZone -- see report CROSS-FAMILY NEEDS.
//
protected:
// WAVE 6 un-stub -- CROSS-FAMILY Mech/Mover/DamageZone couplings. These
// logically read the owning Mech's motion state, its JointedMover roster,
// and this segment's DamageZone -- none reachable from a PoweredSubsystem
// here (owner is an incomplete Mech*, JointedMover exposes no roster API,
// DamageZone is not in this hierarchy). To let the real class FIT its 0x358
// factory alloc, the shim BACKING FIELDS are dropped and the accessors
// return the neutral defaults the ctor used to prime, so the subsystem
// CONSTRUCTS + TICKS but is INERT w.r.t. locomotion/heat: the mover feed is
// a no-op and MyomersSimulation early-returns on OwnerAdvancedDamage()==False.
// ⚠ FAITHFUL FOLLOW-UP: redirect these to real Mech/JointedMover/DamageZone
// accessors + the advanced-damage gate (SubsystemMessageManager 0xBD3) so the
// mech actually slows when damaged/overheated and motion generates heat --
// that path DOES drive the live mover, so reconcile it with the gait cutover
// FIRST (both would feed the mover).
Scalar OwnerBaseSpeed() const { return 1.0f; } // Mech +0x34C (neutral)
Scalar OwnerMaxSpeed() const { return 0.0f; } // Mech +0x7A0
void SetOwnerMaxSpeed(Scalar) { } // no-op (real: write Mech top speed)
Scalar OwnerVelocityMag() const { return 0.0f; } // |Mech velocity|
Scalar OwnerDriveMag() const { return 0.0f; } // |Mech drive/accel|
Scalar OwnerVelocityY() const { return 0.0f; } // Mech velocity.y
Scalar OwnerMotionGain() const { return 0.0f; } // Mech +0x20C
Scalar OwnerMass() const { return 0.0f; } // *Mech +0x250
// FUN_004ad7d4 [T1] -- the SAME player+0x260 heat-model experience gate
// as HeatSink::HeatModelActive() (the "advanced damage" name was a
// misread; the old `return False` stub kept movement heat off for
// every tier). Issue #2: routed through the btplayer.cpp bridge.
// (MyomersSimulation stays arithmetically inert until the Owner*
// accessors above are wired -- every heat term multiplies a 0.)
Logical OwnerAdvancedDamage() const
{
extern int BTPlayerExperienceHeatModelOn(void *owner_mech); // btplayer.cpp (FUN_004ad7d4)
return BTPlayerExperienceHeatModelOn(owner) ? True : False;
}
// This subsystem's OWN zone damage [0,1] (0 intact, 1 destroyed) -- the
// binary reads *(this[0xE0] + 0x158) here. Was a `return 0.0f` stand-in,
// which pinned damage to zero and so made AvailableOutput's (1 - damage)
// factor and the drive-heat damageGain permanently ideal: a shot-up
// myomer drove exactly as well as a fresh one. GetSubsystemDamageLevel()
// is the base's complete-type bridge to that same cell (mechsub.cpp:234),
// which sensor.cpp:312 already reads the same way.
Scalar DamageStructureLevel() const{ return GetSubsystemDamageLevel(); } // DamageZone +0x158
void MoverAttach(int index, Scalar *feed) { (void)index; (void)feed; } // JointedMover vtable +0x38 (no-op)
void MoverDetach(int index) { (void)index; } // JointedMover vtable +0x3C (no-op)
void MoverRelease(int handle, Scalar *feed) { (void)handle; (void)feed; } // JointedMover vtable +0x44 (no-op)
// Live-master gate constants (the ctor reads owner->simulationFlags).
enum {
SegmentCopyMask = 0x0C, // (flags & 0xC): 0 == master, 4 == damaged copy
MasterHeatSinkFlag = 0x0100 // live-master segment bit
};
// The mover attachment handle lives at BASE+0x110 (a MechSubsystem slot), NOT a
// Myomers own field -- declaring it here appended a 4-byte tail that overflowed the
// 0x358 alloc once the PoweredSubsystem base became byte-exact. The coupling is
// inert (ConnectToMover -> no-op MoverAttach), so the handle is write-only today;
// when the real mover feed is revived, write base+0x110 via a base accessor.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Local data. Offsets are byte offsets into the shipped object.
//
protected:
Scalar speedEffect; // @0x31C attr 0x12; ctor 1.0f -- live drive fed to the mover
int currentSeekVoltageIndex; // @0x320 attr 0x13; ctor = recommended -- selected drive "gear"
int recommendedSeekVoltageIndex;// @0x324 attr 0x14; ctor = resource +0x1AC
int minSeekVoltageIndex; // @0x328 attr 0x15; ctor 0
int maxSeekVoltageIndex; // @0x32C attr 0x16; ctor = (seekVoltageCount - 1)
Scalar seekVoltage[5]; // @0x330 attr 0x17; ctor = resource fraction * source ratedVoltage
Scalar outputVoltage; // @0x344 attr 0x18; runtime output voltage
Scalar heatRange; // @0x348 ctor = failureTemperature - degradationTemperature
Scalar heatRangeSquared; // @0x34C ctor = heatRange*heatRange (degradation-curve denominator)
Scalar velocityEfficiency; // @0x350 resource +0x190
Scalar accelerationEfficiency; // @0x354 resource +0x194
friend struct MyomersLayoutCheck;
};
#endif