Files
BT411/game/reconstructed/torso.cpp
T
Joe DiPrimaandClaude Opus 5 9657fbb11e control mode: REPRODUCE the "centering fought my control" fight, and prove the fix in Sauron's config
Follow-up to 4ccc2a7, which fixed the mechanism but could not reproduce the
field symptom.  His fuller wording -- the centering "FOUGHT" his control, not
"the torso died" -- is what cracked it.

WHY "FOUGHT" IS THE PRECISE SYMPTOM.  TorsoSimulation's frame order is
  1. digital twist commands   -> currentTwist += d;  recenterActive = 0
  2. centerCommand > 0        -> recenterActive = 1        (re-armed)
  3. analog twist axis != 0   -> currentTwist += d;  recenterActive = 0
  4. if (recenterActive)      -> Recenter(dt)              (drags toward 0)
Desktop/glass torso input is ANALOG (Q/E -> gBTTwistAxis -> stickPosition.x), so
with centerCommand stuck the torso HOLDS while you are actively pushing (step 3
clears the arm) and snaps back the instant you ease off (step 2's arm survives
into step 4).  You can only hold it off-centre by pushing continuously.  That is
"the centering fought my control", exactly.

WHY IT ONLY BITES THE GLASS/POD BUILD -- and why the first bench came back clean.
The ONLY caller of ClearRecenterCommand() sits INSIDE the desktop key-bridge
block, gated on `gBTDrive.forced || !BTRIODevicePresent()`.  With a RIO present
-- and on glass builds PadRIO IS the rioPointer -- the bridge is OFF and NOTHING
ever clears centerCommand, so one pass through Basic pins it at 1 for good.  A
plain desktop build clears it every frame and self-recovers.
The first modecycle.sh run needed BT_KEY_BRIDGE=1 to make the mode-cycle hook
run at all -- and that same flag switched on the only thing that clears the cell,
masking the bug under test.  The hook is now deliberately OUTSIDE that block so
the bench can run the RIO-present configuration.

MEASURED A/B, bridge OFF (Sauron's config), BT_TWIST_PULSE deflect/release:

                          LEGACY                      FIXED
  ctrCmd=1 samples        310  (latched for good)     0
  twist during RELEASE    decays 0.443->0,            HOLDS 2.44346
                          0.900->0.436  (recen=1)
  recen=1 samples         permanently armed           14 (one-shot per Basic
                                                      entry, then self-clears)

So the authentic one-shot re-centre still happens on entering Basic; it just
settles instead of fighting the pilot forever.

New bench hook BT_TWIST_PULSE=<n>: deflect the analog twist axis for n ticks
then RELEASE for n ticks, repeating.  BT_LOCK_SWEEP never releases, so it cannot
show this symptom at all -- the release window IS the measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 13:59:39 -05:00

1203 lines
49 KiB
C++

//===========================================================================//
// File: torso.cpp //
// Project: BattleTech Brick: Entity Manager //
// Contents: Torso subsystem -- torso twist (yaw) and elevation (pitch) aim //
//---------------------------------------------------------------------------//
// Date Who Modification //
// -------- --- ---------------------------------------------------------- //
// --/--/95 ?? Initial coding. //
//---------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. All Rights reserved //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
//
// RECONSTRUCTED from the shipped binary (Ghidra pseudo-C, recovered shard
// part_013.c). No header survived; see torso.hpp. Each non-trivial method
// cites the originating @ADDR. Confidence flags: [CONFIDENT] / [BEST-EFFORT]
// / [EXCLUDED] as in gyro.cpp.
//
// Hex constants converted to decimal (from section_dump.txt):
// _DAT_004b6500 = 0.0f _DAT_004b6504 = 0.0001f (zero-vel epsilon)
// _DAT_004b6508 = 0.02f _DAT_004b650c = 0.05f (settle tolerances)
// _DAT_004b64f8 = 0.5f _DAT_004b64fc = 1.0f (ramp cap)
// _DAT_004b6a14 = 0.0f _DAT_004b6a18 = 0.0001f (recenter epsilon)
// _DAT_004b6b08 = 10.0f (min slew-window, ms->s)
// _DAT_004b6fb8 = 0.0174532925f (PI/180, deg->rad) _DAT_004b6fbc = 0.5f
// _DAT_004b76c4 = -1.0f (resource "unset" sentinel)
// DAT_0052140c = milliseconds-per-second tick scale (runtime-initialised;
// reads 0 in the static image -- used as (now-t0)/scale).
//
// Helper-function name mapping:
// FUN_004b18a4 PowerWatcher base constructor
// FUN_004b198c PowerWatcher::CreateStreamedSubsystem
// FUN_004b1804 PowerWatcher::ResetToInitialState (slot 10)
// FUN_004b179c PowerWatcher slot-9 death/voltage handler
// FUN_004b181c PowerWatcher per-frame watch update
// FUN_0041bd34 Subsystem::WriteUpdateRecord (base)
// FUN_00414b60 Clock::Now() (ms)
// FUN_004081e0 Scalar lerp(dst,a,b,t)
// FUN_0041cfa0/0041d020/0041d0a8 skeleton-node get/set transform
// FUN_00404118 NotationFile::ReadScalar FUN_00404088 ReadString
// FUN_004d4b58 stricmp FUN_004dcd00 fabsf
//
#include <bt.hpp>
#pragma hdrstop
#if !defined(TORSO_HPP)
# include <torso.hpp>
#endif
#if !defined(MECH_HPP)
# include <mech.hpp> // complete Mech -- owner->ResolveJoint
#endif
#include <JOINT.hpp> // Joint, JointSubsystem (fwd shim)
#include <ROTATION.hpp> // EulerAngles, Radian, Hinge (fwd shim)
#if !defined(APP_HPP)
# include <app.hpp>
#endif
#if !defined(TESTBT_HPP)
# include <testbt.hpp>
#endif
static const Scalar Zero = 0.0f; // _DAT_004b6500 / _DAT_004b6a14
static const Scalar VelEps = 0.0001f; // _DAT_004b6504 / _DAT_004b6a18
static const Scalar TwistEps = 0.02f; // _DAT_004b6508
static const Scalar SettleEps = 0.05f; // _DAT_004b650c
static const Scalar RampCap = 1.0f; // _DAT_004b64fc
static const Scalar MinSlewMs = 10.0f; // _DAT_004b6b08
static const Scalar DegToRad = 0.0174532925f; // _DAT_004b6fb8
static const Scalar Unset = -1.0f; // _DAT_004b76c4
// DAT_0052140c -- the runtime "ticks-per-second" scale (reads 0 in the static
// image; the engine initialises it from the system clock). TODO: confirm the
// real value once the clock-init is recovered; 1000.0f (ms->s) is consistent
// with the MinSlewMs window above.
static const Scalar MsPerSecond = 1000.0f; // DAT_0052140c
//
// CROSS-FAMILY compile shims for the streamed-subsystem joint validation: the
// engine has no `Skeleton` C++ type with FindNode (only SkeletonClassID streams
// in VDATA.h), so the real joint-existence check could not be recovered. These
// let CreateStreamedSubsystem compile; they conservatively accept any joint.
//
namespace {
struct ReconSkeleton
{
Logical FindNode(const char * /*node_name*/) const { return True; }
};
static ReconSkeleton g_reconSkeleton;
inline ReconSkeleton*
LoadSkeleton(const ResourceDirectories * /*dirs*/, const char * /*name*/)
{ return &g_reconSkeleton; }
// The Torso update record appends three trailing Scalars after the base
// Simulation::UpdateRecord header (recovered offsets +0x10/+0x14/+0x18).
inline Scalar
RecordField(Simulation::UpdateRecord *record, int byte_offset)
{ return *(const Scalar*)((const char*)record + byte_offset); }
inline void
WriteRecordField(Simulation::UpdateRecord *record, int byte_offset, Scalar value)
{ *(Scalar*)((char*)record + byte_offset) = value; }
}
//###########################################################################
// BASE-CHAIN RE-BASE -- compile-time layout locks (STEP 3).
//
// The Torso is read at RAW absolute offsets externally (the gyro cross-link
// stores (char*)sinkSourceSubsystem+0x1D8 == currentTwist, mech.cpp:740; the
// damage-slice, HUD ctor and radar read the same +0x1D8; MechControlsMapper
// reads +0x1F0/+0x1F4/+0x250/+0x274). These asserts fail the BUILD if the
// re-based layout ever drifts, long before any runtime mis-read.
//###########################################################################
// A friend of Torso so it can offsetof() the protected own-block fields.
struct TorsoLayoutCheck
{
static_assert(sizeof(Torso) == 0x280, "sizeof(Torso) must be 0x280 (factory alloc + gyro cross-link)");
static_assert(offsetof(Torso, currentTwist) == 0x1D8, "Torso currentTwist must be at 0x1D8 (gyro linkTarget +0x1D8)");
static_assert(offsetof(Torso, currentElevation) == 0x1E4, "Torso currentElevation must be at 0x1E4");
static_assert(offsetof(Torso, analogTwistAxis) == 0x1F0, "Torso analogTwistAxis must be at 0x1F0 (mapper +0x1F0)");
static_assert(offsetof(Torso, analogElevationAxis) == 0x1F4, "Torso analogElevationAxis must be at 0x1F4 (mapper +0x1F4)");
static_assert(offsetof(Torso, horizontalEnabled) == 0x250, "Torso horizontalEnabled must be at 0x250 (mapper +0x250)");
static_assert(offsetof(Torso, recenterActive) == 0x274, "Torso recenterActive must be at 0x274 (proves the 0x270 pad)");
static_assert(offsetof(Torso, horizontalShadowJointNode) == 0x27C, "Torso last own field at 0x27C (+4 => sizeof 0x280)");
};
//###########################################################################
//###########################################################################
// Torso
//###########################################################################
//###########################################################################
//#############################################################################
// Shared Data Support (DefaultData @00510af8)
//
Derivation
Torso::ClassDerivations(
PowerWatcher::GetClassDerivations(), // returns Derivation* (no &)
"Torso"
);
Receiver::MessageHandlerSet
Torso::MessageHandlers;
Torso::AttributeIndexSet
Torso::AttributeIndex;
// (AUDIO_FIDELITY F16) the real Torso attribute table, binary parity (dense
// ids 3..15 from MechSubsystem::NextAttributeID; offsets are the binary's,
// documented on the members). Un-deadens the torso-twist servo whir:
// TorsoTwistInt01/Ext01 pitch/gate on SpeedOfTorsoHorizontal, the stop clunk
// matches MotionState==2 (the limit-hit frame statusFlags already publishes).
const Torso::IndexEntry
Torso::AttributePointers[]=
{
ATTRIBUTE_ENTRY(Torso, RotationOfTorsoVertical, currentElevation), // 3 @0x1E4
ATTRIBUTE_ENTRY(Torso, RotationOfTorsoHorizontal, currentTwist), // 4 @0x1D8
ATTRIBUTE_ENTRY(Torso, HorizontalLimitRight, horizontalLimitRight), // 5 @0x1DC
ATTRIBUTE_ENTRY(Torso, HorizontalLimitLeft, horizontalLimitLeft), // 6 @0x1E0
ATTRIBUTE_ENTRY(Torso, SpeedOfTorsoVertical, elevationVelocity), // 7 @0x1EC
ATTRIBUTE_ENTRY(Torso, SpeedOfTorsoHorizontal, twistVelocity), // 8 @0x1E8
ATTRIBUTE_ENTRY(Torso, StickPosition, analogTwistAxis), // 9 @0x1F0
ATTRIBUTE_ENTRY(Torso, TorsoUp, elevateUpCommand), // 10 @0x1F8
ATTRIBUTE_ENTRY(Torso, TorsoDown, elevateDownCommand), // 11 @0x1FC
ATTRIBUTE_ENTRY(Torso, TorsoLeft, twistLeftCommand), // 12 @0x200
ATTRIBUTE_ENTRY(Torso, TorsoRight, twistRightCommand), // 13 @0x204
ATTRIBUTE_ENTRY(Torso, TorsoCenter, centerCommand), // 14 @0x208
ATTRIBUTE_ENTRY(Torso, MotionState, statusFlags) // 15 @0x20C
};
Torso::AttributeIndexSet&
Torso::GetAttributeIndex()
{
static Torso::AttributeIndexSet attributeIndex(
ELEMENTS(Torso::AttributePointers),
Torso::AttributePointers,
MechSubsystem::GetAttributeIndex()
);
return attributeIndex;
}
Torso::SharedData
Torso::DefaultData(
&Torso::ClassDerivations,
Torso::MessageHandlers,
Torso::GetAttributeIndex(),
Torso::StateCount
);
//#############################################################################
// Construction / Destruction
//
//
// @004b6b0c [CONFIDENT] -- chains to the PowerWatcher base ctor (FUN_004b18a4)
// with &Torso::DefaultData, installs the Torso vtable (PTR @0051103c). A live
// master segment (flags & 0xC == 0 && flags & 1) gets TorsoSimulation as its
// Performance and isDamagedCopy=0; any other segment gets TorsoCopySimulation
// and isDamagedCopy=1. Angular resource values are converted deg->rad here.
//
Torso::Torso(
Mech *owner,
int subsystem_ID,
SubsystemResource *r,
SharedData &shared_data
):
PowerWatcher(owner, subsystem_ID, r, shared_data)
{
Check(owner);
Check_Pointer(r);
// BASE-CHAIN RE-BASE: the 7 CROSS-FAMILY shim backing fields were deleted
// (they over-sized the object); their accessors now read the real inherited
// base state, so there is nothing to prime here. The master/copy selection
// below already reads the authoritative owner->simulationFlags.
// INTEGRATION (gate reconcile): read OWNER simulationFlags (param_2+0x28) —
// the oracle-verified authoritative source — not the local segment shim.
if ((owner->simulationFlags & SegmentCopyMask) == 0
&& (owner->simulationFlags & MasterHeatSinkFlag) != 0) // owner flags & 0x100 (binary @004b6b0c)
{
isDamagedCopy = 0; // @0x24C
SetPerformance(&Torso::TorsoSimulation); // PTR @00510c10 (-> @004b5cf0)
}
else
{
isDamagedCopy = 1;
SetPerformance(&Torso::TorsoCopySimulation); // PTR @00510c1c (-> @004b65f8)
}
statusFlags = 0; // @0x20C
buttonAccelerationPerSecond = r->buttonAccelerationPerSecond; // @0x210 <- +0x150
buttonAccelerationStart = r->buttonAccelerationStartValue; // @0x214 <- +0x154
baseTwistRate = r->horizontalRotationPerSecond * DegToRad; // @0x23C <- +0xF4
baseElevationRate = r->verticalRotationPerSecond * DegToRad; // @0x240 <- +0xF8
horizontalLimitRight = r->horizontalLimitRight * DegToRad; // @0x1DC <- +0xFC
horizontalLimitLeft = r->horizontalLimitLeft * DegToRad; // @0x1E0 <- +0x100
verticalLimitTop = r->verticalLimitTop * DegToRad; // @0x220 <- +0x104
verticalLimitBottom = r->verticalLimitBottom * DegToRad; // @0x224 <- +0x108
// derived limit copies + half-bottom (used as soft centre / settle band)
twistCenterHigh = verticalLimitTop; // @0x230 = @0x220
twistCenterLow = verticalLimitBottom; // @0x234 = @0x224
elevationCenter = verticalLimitTop; // @0x228 = @0x220
elevationHalfBottom = verticalLimitBottom * 0.5f; // @0x22C (_DAT_004b6fbc)
// SPEC AUDIT (BT_SPEC_LOG, 2026-07-18): dump the streamed torso config so
// it can be diffed against the 1995 manual's per-mech stat sheet
// ("Torso speed (deg/sec)" / "Torso Limit (degrees)"). Values printed in
// DEGREES (the manual's units); horizontalRotationPerSecond IS deg/sec in
// the resource, the limits are stored in degrees pre-DegToRad.
if (getenv("BT_SPEC_LOG"))
{
DEBUG_STREAM << "[spec] torso: speed=" << r->horizontalRotationPerSecond
<< " deg/s limitL=" << r->horizontalLimitLeft
<< " limitR=" << r->horizontalLimitRight
<< " (arc=" << (r->horizontalLimitLeft - r->horizontalLimitRight)
<< ") vRate=" << r->verticalRotationPerSecond
<< " vTop=" << r->verticalLimitTop
<< " vBot=" << r->verticalLimitBottom
<< " hEnabled=" << (int)r->torsoHorizontalEnabled
<< "\n" << std::flush;
}
buttonRampActive = 0; // @0x268
buttonRamp = 0.0f;// @0x26C
horizontalEnabled = r->torsoHorizontalEnabled; // @0x250 <- +0x14C
if (horizontalEnabled)
{
// resolve the two skeleton joints named in the resource:
horizontalJointNode = ResolveJoint(r->torsoHorizontalJoint); // @0x278
horizontalShadowJointNode = ResolveJoint(r->torsoHorizontalShadowJoint); // @0x27C
// bring-up verification (env BT_TORSO_LOG; default OFF): confirm the
// named joints resolved to live nodes and report their joint types.
if (getenv("BT_TORSO_LOG"))
{
DEBUG_STREAM << "[torso] resolve '" << r->torsoHorizontalJoint
<< "' -> " << (void*)horizontalJointNode;
if (horizontalJointNode)
DEBUG_STREAM << " type=" << (int)horizontalJointNode->GetJointType();
DEBUG_STREAM << " ; shadow '" << r->torsoHorizontalShadowJoint
<< "' -> " << (void*)horizontalShadowJointNode;
if (horizontalShadowJointNode)
DEBUG_STREAM << " type=" << (int)horizontalShadowJointNode->GetJointType();
DEBUG_STREAM << "\n" << std::flush;
}
}
// ---- BRING-UP DEMO (env BT_FORCE_TORSO; default OFF; NOT faithful) --------
// The Blackhawk 0xBC5 record has TorsoHorizontalEnabled=0 + empty joint names
// (the binary skips torso joints for this mech, verified). To exercise the
// reconstructed twist path end-to-end (ResolveJoint -> TorsoSimulation ->
// UpdateJoints -> PushTwist -> Joint::SetRotation), force-enable and resolve
// the REAL BLH.SKL torso joints ('jointshakey2' torso body / 'jointtshadow'
// = the "apply torso twist to yaw" hinge), widen the limits, and give a slew
// rate. TorsoSimulation drives the sweep (below). Remove after verification.
if (isDamagedCopy == 0 && getenv("BT_FORCE_TORSO"))
{
// ⚠ HARNESS TRAP (task #58 post-mortem): this hook used to override the
// joint nodes UNCONDITIONALLY with the BLH demo names -- on a mech whose
// resource already resolved REAL torso joints (the MadCat's jointtorso)
// that silently redirected the whole sweep into the SHADOW hinge, and a
// probe run "proved" the cockpit camera ignored the twist (it doesn't;
// the joint chain delivers it). Now: only fill joints that did NOT
// resolve from the resource; a twist-capable mech sweeps its real ones.
horizontalEnabled = True; // @0x250
if (horizontalJointNode == 0)
{
const char *mj = getenv("BT_FORCE_TORSO_JOINT");
if (mj == 0 || *mj == '\0') mj = "jointshakey2";
horizontalJointNode = ResolveJoint(mj); // torso body (ball)
horizontalShadowJointNode = ResolveJoint("jointtshadow"); // shadow twist (hingey)
horizontalLimitLeft = 0.7f; // @0x1E0 ~40 deg
horizontalLimitRight = -0.7f; // @0x1DC
}
baseTwistRate = 1.0f; // @0x23C rad/s slew
if (getenv("BT_TORSO_LOG"))
{
DEBUG_STREAM << "[torso] FORCE-ENABLE node=" << (void*)horizontalJointNode;
if (horizontalJointNode) DEBUG_STREAM << " type=" << (int)horizontalJointNode->GetJointType();
DEBUG_STREAM << " ; shadow node=" << (void*)horizontalShadowJointNode;
if (horizontalShadowJointNode) DEBUG_STREAM << " type=" << (int)horizontalShadowJointNode->GetJointType();
DEBUG_STREAM << "\n" << std::flush;
}
}
// --------------------------------------------------------------------------
effectiveTwistRate = baseTwistRate; // @0x244 = @0x23C
effectiveElevationRate = baseElevationRate; // @0x248 = @0x240
currentTwist = 0.0f; // @0x1D8
currentElevation = 0.0f; // @0x1E4
twistVelocity = 0.0f; // @0x1E8
twistRate = 0.0f; // @0x238
analogTwistAxis = analogElevationAxis = 0.0f; // @0x1F0 / @0x1F4
elevateUpCommand = elevateDownCommand = 0; // @0x1F8 / @0x1FC
twistLeftCommand = twistRightCommand = 0; // @0x200 / @0x204
centerCommand = 0; // @0x208
hitElevTop = hitElevBottom = 0; // @0x258 / @0x25C
hitTwistLeft = hitTwistRight = 0; // @0x260 / @0x264
recenterActive = 0; // @0x274
lastUpdateTime = GetLastUpdateTime(); // @0x254 = this[5]
// bring-up verification (env BT_TORSO_LOG): the gyro cross-links to
// (Torso*)+0x1D8 (== currentTwist) at a RAW offset (mech.cpp:740), so the
// compiled layout MUST place currentTwist at 0x1D8 and the object must fit the
// 0x280 factory alloc. Log both so a mismatch is caught immediately.
if (getenv("BT_TORSO_LOG"))
{
DEBUG_STREAM << "[torso] ctor this=" << (void*)this
<< " sizeof(Torso)=" << (unsigned)sizeof(Torso)
<< " (0x280=" << (unsigned)0x280 << ")"
<< " currentTwist@" << (unsigned)((char*)&currentTwist - (char*)this)
<< " (want 0x1D8=" << (unsigned)0x1D8 << ")"
<< " damagedCopy=" << isDamagedCopy
<< " horizEnabled=" << (int)horizontalEnabled << "\n" << std::flush;
// RESOURCE DIAGNOSTIC: is horizEnabled=0 a genuine content value or a
// mis-read? Dump the resource enable flag + joint names (valid strings =>
// seg is the right record) + the raw int at record+0x14C + the two limit
// scalars (to confirm the resource is a plausible Torso record at all).
DEBUG_STREAM << "[torso] res enabled=" << (int)r->torsoHorizontalEnabled
<< " raw@0x14C=" << *(const int*)((const char*)r + 0x14C)
<< " hJoint='" << r->torsoHorizontalJoint << "'"
<< " sJoint='" << r->torsoHorizontalShadowJoint << "'"
<< " hRotPerSec=" << r->horizontalRotationPerSecond
<< " hLimL=" << r->horizontalLimitLeft
<< "\n" << std::flush;
// Is the record correctly positioned? classID@+0x20 should be 0xBC5 and
// modelSize@+0x24 should be 0x158 for a real Torso record; garbage => the
// seg pointer / stream position is wrong (not a content issue).
DEBUG_STREAM << "[torso] res classID=0x" << std::hex
<< *(const int*)((const char*)r + 0x20)
<< " modelSize=0x" << *(const int*)((const char*)r + 0x24) << std::dec
<< " (want classID=0xBC5 size=0x158)\n" << std::flush;
}
Check_Fpu();
}
//
// @004b6fc0 [BEST-EFFORT, prologue not captured] -- reinstalls the vtable, runs
// the PowerWatcher teardown and frees on the deleting bit. (Mirrors @004b3e88.)
//
Torso::~Torso()
{
Check(this);
Check_Fpu();
}
Logical Torso::TestClass(Mech &) { return True; }
Logical Torso::TestInstance() const { return IsDerivedFrom(ClassDerivations); }
//#############################################################################
// Subsystem virtual overrides
//
//
// @004b5bf8 (slot 10) [CONFIDENT] -- ResetToInitialState. Chains to
// PowerWatcher::ResetToInitialState (FUN_004b1804) first; when (re)powering,
// snaps the effective rates back to the base rates and clears the button ramp.
// Always clears the command inputs / velocity / latches, then re-pushes the
// (centred) twist into the joints via WriteJoints.
//
void
Torso::ResetToInitialState()
{
WatcherResetToInitialState(); // CROSS-FAMILY: PowerWatcher::ResetToInitialState (FUN_004b1804)
// @004b5bf8: on a (re)power (param_2 != 0) restore the effective rates and
// clear all FOUR limit latches (0x258/0x25c/0x260/0x264); always clear the
// command + analog inputs, the aim state and velocities, then re-push the
// centred twist into the joints via WriteJoints.
const Logical powered = True; // bring-up: reset always re-powers
if (powered)
{
effectiveTwistRate = baseTwistRate; // @0x244 = @0x23C
effectiveElevationRate = baseElevationRate; // @0x248 = @0x240
hitElevTop = hitElevBottom = 0; // @0x258 / @0x25C
hitTwistLeft = hitTwistRight = 0; // @0x260 / @0x264
}
elevateUpCommand = elevateDownCommand = 0; // @0x1F8 / @0x1FC
twistLeftCommand = twistRightCommand = 0; // @0x200 / @0x204
centerCommand = 0; // @0x208
analogTwistAxis = analogElevationAxis = 0.0f;// @0x1F0 / @0x1F4
currentTwist = 0.0f; // @0x1D8
currentElevation = 0.0f; // @0x1E4
twistVelocity = 0.0f; // @0x1E8
elevationVelocity = 0.0f; // @0x1EC
twistAtUpdate = 0.0f; // @0x21C
targetTwist = 0.0f; // @0x218
twistRate = 0.0f; // @0x238
recenterActive = 0; // @0x274
Scalar verticalOut;
WriteJoints(verticalOut); // FUN_004b66b4
}
//
// @004b5be0 (slot 9) [BEST-EFFORT, prologue not captured] -- forwards to the
// PowerWatcher slot-9 handler (FUN_004b179c): on a "destroyed" message it clears
// the watched power source's voltage alarm, otherwise chains to the base.
//
Logical
Torso::HandleDeathMessage(Message &message)
{
return WatcherHandleDeathMessage(message); // CROSS-FAMILY: PowerWatcher::HandleDeathMessage (FUN_004b179c)
}
//
// @004b6a78 (slot 6) [CONFIDENT] -- the REPLICANT-side apply (engine semantics
// [T0]: ReadUpdateRecord = write the object FROM the record). Samples the
// clock (FUN_00414b60) into lastUpdateTime, biasing it forward by one interval
// when the elapsed window is below MinSlewMs, chains to the base apply
// (FUN_0041bd34 = ReadUpdateRecord, 2 args), then applies twistAtUpdate /
// twistVelocity / twistRate from the record extras (+0x10 / +0x14 / +0x18).
// (Task #57: previously mislabeled as WriteUpdateRecord -- the direction flip
// made the MASTER consume its own blank record.)
//
void
Torso::ReadUpdateRecord(UpdateRecord *message)
{
lastUpdateTime = GetCurrentTime(); // @0x254
if ((Scalar)(lastUpdateTime - GetLastUpdateTime()) / MsPerSecond < MinSlewMs)
{
lastUpdateTime += (lastUpdateTime - GetLastUpdateTime()); // stretch tiny windows
}
Subsystem::ReadUpdateRecord(message); // FUN_0041bd34
twistAtUpdate = RecordField(message, 0x10); // @0x21C
twistVelocity = RecordField(message, 0x14); // @0x1E8
twistRate = RecordField(message, 0x18); // @0x238
// #67 stream forensics (BT_TORSO_LOG): the copy received atUpd=2.1e-44
// (= int 15 as float) on the rig -- a mis-framed record. Print the raw
// header + payload dwords of every record this torso consumes.
if (getenv("BT_TORSO_LOG"))
{
const unsigned int *raw = (const unsigned int *)message;
DEBUG_STREAM << "[torso-rec-rx] len=" << message->recordLength
<< " subID=" << (int)message->subsystemID
<< " recID=" << (int)message->recordID
<< std::hex
<< " p10=0x" << raw[4] << " p14=0x" << raw[5]
<< " p18=0x" << raw[6] << std::dec
<< " -> atUpd=" << twistAtUpdate
<< " vel=" << twistVelocity << " rate=" << twistRate
<< "\n" << std::flush;
}
}
//
// @004b6a1c (slot 7) [CONFIDENT, raw-disasm recovery -- Ghidra missed the
// function start] -- the MASTER-side serialize (engine semantics [T0]:
// WriteUpdateRecord = fill the record FROM the object). Chains the base
// producer (FUN_0041c500(this, record, model), 3 args), extends the record to
// 0x1C bytes and appends currentTwist / twistVelocity / twistRate at
// +0x10/+0x14/+0x18 (exactly what the replicant's @004b6a78 applies), then
// snapshots twistAtUpdate = currentTwist:
//
// 004b6a2e call 0041c500 ; base WriteUpdateRecord
// 004b6a38 mov [record], 0x1C ; recordLength = 0x1C
// 004b6a44+ record+0x10 = this+0x1D8 ; currentTwist
// 004b6a51 record+0x14 = this+0x1E8 ; twistVelocity
// 004b6a5a record+0x18 = this+0x238 ; twistRate
// 004b6a63+ this+0x21C = this+0x1D8 ; twistAtUpdate = currentTwist
//
void
Torso::WriteUpdateRecord(UpdateRecord *message, int update_model)
{
Subsystem::WriteUpdateRecord(message, update_model); // FUN_0041c500
message->recordLength = 0x1C; // base header 0x10 + 3 Scalars
WriteRecordField(message, 0x10, currentTwist); // @0x1D8
WriteRecordField(message, 0x14, twistVelocity); // @0x1E8
WriteRecordField(message, 0x18, twistRate); // @0x238
twistAtUpdate = currentTwist; // @0x21C snapshot at send
// #67 stream forensics: what the MASTER actually serialized.
if (getenv("BT_TORSO_LOG"))
{
DEBUG_STREAM << "[torso-rec-tx] len=" << message->recordLength
<< " subID=" << (int)message->subsystemID
<< " recID=" << (int)message->recordID
<< " twist=" << currentTwist
<< " vel=" << twistVelocity << " rate=" << twistRate
<< "\n" << std::flush;
}
}
//#############################################################################
// Per-frame simulation
//
//
// @004b5cf0 [CONFIDENT] -- the live-master Performance (PTR @00510c10).
//
// 1. PowerWatcher watch update (FUN_004b181c).
// 2. Latch the effective rates from the base rates, then ZERO them if the
// watched power source is dead (this[0x10]==1), not Ready (this @0x198 != 4),
// or in the Failure heat state (this @0x140 == 2); the Degradation heat state
// (==1) instead halves the twist rate.
// 3. Apply the button-acceleration ramp (start value -> cap) while a command is
// held, integrate the per-axis commands into currentTwist / currentElevation,
// clamp to the software limits, set the limit latches, and -- if releasing --
// run Recenter. The "moved" dirty bit (this @0x18 |= 1) is raised when the
// aim changed beyond TwistEps/SettleEps, and statusFlags reflects which limit
// (if any) is being held.
//
void
Torso::TorsoSimulation(Scalar time_slice)
{
Check(this);
WatcherUpdateWatch(); // CROSS-FAMILY: PowerWatcher per-frame watch (FUN_004b181c)
Scalar twist0 = currentTwist; // snapshot for velocity calc
Scalar elev0 = currentElevation;
effectiveTwistRate = baseTwistRate; // @0x244 = @0x23C
effectiveElevationRate = baseElevationRate; // @0x248 = @0x240
if (HeatModelOff()) effectiveTwistRate = 0.0f; // this[0x10]==1
if (ElectricalStateLevel() != PoweredSubsystem::Ready) effectiveTwistRate = 0.0f; // @0x198 != 4
switch (HeatStateLevel()) // @0x140
{
case HeatSink::DegradationHeat: effectiveTwistRate = baseTwistRate * 0.5f; break; // _DAT_004b64f8
case HeatSink::FailureHeat: effectiveTwistRate = 0.0f; break;
default: break;
}
// BRING-UP DEMO (env BT_FORCE_TORSO): un-gate the slew rate (the forced demo
// torso isn't wired to a live power source, so ElectricalStateLevel would zero
// it) and drive a left/right analog sweep so the reconstructed twist path is
// exercised visibly. Faithful behavior is untouched when the env is unset.
static const int s_forceTorso = getenv("BT_FORCE_TORSO") ? 1 : 0;
if (s_forceTorso)
{
effectiveTwistRate = baseTwistRate; // un-gate (ctor set 1.0 rad/s)
static int s_sweep = 0;
analogTwistAxis = ((++s_sweep / 90) & 1) ? -1.0f : 1.0f; // +/- every ~90 frames
}
// #124 twist-sign bench (BT_FORCE_TWIST=<-1..1>): HOLD the analog axis at a
// constant (the sweep above alternates) -- the sim integrates currentTwist
// to the per-mech limit and stays, giving the cylinder-table bench a known
// twist state at the SIM level, independent of the input plumbing.
{
static float s_forceTwist = -2.0f;
if (s_forceTwist < -1.5f)
{
const char *ft = getenv("BT_FORCE_TWIST");
s_forceTwist = ft ? (float)atof(ft) : -3.0f;
}
if (s_forceTwist >= -1.0f)
{
effectiveTwistRate = baseTwistRate;
analogTwistAxis = s_forceTwist;
}
}
// #131 LOCK-SWEEP (BT_LOCK_SWEEP=<axis 0..1>, default 0.12): pan the torso
// in a slow triangle wave between the twist limits, so the REAL boresight
// sweeps across a stand-off target and past both edges -- the operator-
// visible lock-envelope instrument (watch the ring light/die; the
// [locksweep] log in mech4 records the twist at each lock transition).
{
static float s_lockSweep = -1.0f;
if (s_lockSweep < 0.0f)
{
const char *lsw = getenv("BT_LOCK_SWEEP");
s_lockSweep = lsw ? (float)atof(lsw) : 0.0f;
if (lsw != 0 && s_lockSweep <= 0.0f) s_lockSweep = 0.12f;
if (s_lockSweep > 1.0f) s_lockSweep = 1.0f;
}
// BENCH (BT_TWIST_PULSE=<n>): deflect the analog twist axis for n ticks,
// then RELEASE it for n ticks, repeating. BT_LOCK_SWEEP never releases,
// so it cannot show the reported symptom: with a stuck centerCommand the
// torso holds while you are actively pushing (the analog arm clears
// recenterActive) and snaps back the moment you let go (centerCommand
// re-arms it) -- "the torso centering FOUGHT my control". Measure the
// RELEASE windows: currentTwist should HOLD, not decay toward 0.
{
static const char *s_tp = getenv("BT_TWIST_PULSE");
if (s_tp != 0)
{
static int s_tpN = 0;
int period = atoi(s_tp);
if (period < 1) period = 120;
const int phase = (s_tpN++ / period) % 2;
analogTwistAxis = phase ? 0.0f : 0.6f;
if ((s_tpN % 30) == 0)
DEBUG_STREAM << "[twistpulse] phase=" << (phase ? "RELEASE" : "deflect")
<< " axis=" << analogTwistAxis
<< " twist=" << currentTwist
<< " ctrCmd=" << centerCommand
<< " recen=" << recenterActive << "\n" << std::flush;
}
}
if (s_lockSweep > 0.0f)
{
effectiveTwistRate = baseTwistRate;
// flip direction at the limits (small margin so we visibly cross
// past the target and dwell OFF it at the extremes)
static float s_dir = 1.0f;
if (currentTwist >= horizontalLimitLeft - 0.05f) s_dir = -1.0f;
if (currentTwist <= horizontalLimitRight + 0.05f) s_dir = 1.0f;
analogTwistAxis = s_dir * s_lockSweep;
}
}
// TORSO GATE PROBE (BT_TORSO_LOG): why is the twist rate zero?
if (getenv("BT_TORSO_LOG"))
{
static int s_tl = 0;
if ((s_tl++ % 120) == 0)
{
PoweredSubsystem *w = (PoweredSubsystem *)watchedLink.Resolve();
DEBUG_STREAM << "[torso] hmOff=" << (int)HeatModelOff()
<< " elec=" << (int)ElectricalStateLevel()
<< " heatState=" << (int)HeatStateLevel()
<< " rate=" << effectiveTwistRate
<< " base=" << baseTwistRate
<< " hEn=" << (int)horizontalEnabled
<< " limits=(" << horizontalLimitRight << ".." << horizontalLimitLeft << ")"
<< " axis=" << analogTwistAxis
<< " twist=" << currentTwist
// control-mode recenter state. centerCommand (@0x208) is the
// HELD-button cell -- if it reads 1 with no button down, the
// torso re-arms recenterActive every frame and digital twist is
// dead (Sauron's "lost torso control" after cycling modes).
<< " ctrCmd=" << centerCommand
<< " recen=" << recenterActive
<< " vLim=(" << verticalLimitBottom << ".." << verticalLimitTop << ")"
<< " wIdx=" << watchedSubsystem
<< " w=" << (void*)w
<< " wElec=" << (w ? w->electricalStateAlarm.GetLevel() : -1)
<< " wSrc=" << (void*)(w ? w->ResolveVoltageSource() : 0);
Generator *g = (w ? (Generator *)w->ResolveVoltageSource() : 0);
if (g)
DEBUG_STREAM << " gOut=" << g->MeasuredVoltage()
<< " gRated=" << g->RatedVoltageOf()
<< " minV=" << minVoltage;
DEBUG_STREAM << std::endl;
}
}
Scalar twistStep = effectiveTwistRate * time_slice;
Scalar elevStep = effectiveElevationRate * time_slice;
// button-acceleration ramp (forced to RampCap in the shipped build -- see note)
if (buttonRampActive == 0)
{
buttonRamp = buttonAccelerationStart; // @0x26C <- @0x214
}
else
{
buttonRamp += buttonAccelerationPerSecond * time_slice; // ramp up
buttonRamp = Min(buttonRamp, RampCap);
buttonRampActive = 0;
}
buttonRamp = 1.0f; // NB: @004b5cf0 unconditionally overwrites @0x26C with 1.0f
// ---- digital elevation (pitch) commands @0x1F8 / @0x1FC ----
if (elevateUpCommand > 0) // @0x1F8
{
currentElevation += elevStep * buttonRamp;
currentElevation = Min(currentElevation, verticalLimitTop); // @0x220
buttonRampActive = 1;
}
if (elevateDownCommand > 0) // @0x1FC
{
currentElevation -= elevStep * buttonRamp;
currentElevation = Max(currentElevation, verticalLimitBottom); // @0x224
buttonRampActive = 1;
}
// ---- digital twist (yaw) commands @0x200 / @0x204 ----
if (twistLeftCommand > 0) // @0x200
{
currentTwist += twistStep * buttonRamp;
currentTwist = Min(currentTwist, horizontalLimitLeft); // @0x1E0
recenterActive = 0;
buttonRampActive = 1;
}
if (twistRightCommand > 0) // @0x204
{
currentTwist -= twistStep * buttonRamp;
currentTwist = Max(currentTwist, horizontalLimitRight); // @0x1DC
recenterActive = 0;
buttonRampActive = 1;
}
if (centerCommand > 0) // @0x208
{
recenterActive = 1;
buttonRampActive = 0;
}
// ---- analog (RIO/stick) axes @0x1F0 / @0x1F4: proportional, no button ramp ----
if (analogTwistAxis != Zero) // @0x1F0
{
currentTwist += analogTwistAxis * twistStep;
currentTwist = Min(currentTwist, horizontalLimitLeft); // @0x1E0
currentTwist = Max(currentTwist, horizontalLimitRight); // @0x1DC
recenterActive = 0;
}
if (analogElevationAxis != Zero) // @0x1F4
{
currentElevation += analogElevationAxis * elevStep;
currentElevation = Min(currentElevation, verticalLimitTop); // @0x220
currentElevation = Max(currentElevation, verticalLimitBottom); // @0x224
}
if (recenterActive != 0)
{
recenterActive = Recenter(time_slice); // FUN_004b6918
}
// ---- derive angular velocities + settle detection ----
Scalar oldTwistRate = twistRate; // local_90: OLD rate, snapshot before recompute
if (time_slice > Zero)
{
twistVelocity = (currentTwist - twist0) / time_slice; // @0x1E8
elevationVelocity = (currentElevation - elev0) / time_slice; // @0x1EC
twistRate = twistVelocity; // @0x238 (signed)
twistVelocity = fabsf(twistVelocity);
elevationVelocity = fabsf(elevationVelocity);
}
if (fabsf(twistRate) <= VelEps) twistRate = 0.0f;
if (fabsf(oldTwistRate) <= VelEps) oldTwistRate = 0.0f;
ComputeTargetTwist(); // FUN_004b6510 -> targetTwist (@0x218)
// raise the "moved" dirty bit unless we have settled within tolerance. The
// aim error is measured against targetTwist (0x218) and the settle test uses
// the OLD rate snapshot (not the previous twist angle).
Scalar aimError = currentTwist - targetTwist; // 0x1D8 - 0x218
Logical justStopped = (twistRate == Zero) && (oldTwistRate != Zero);
Scalar rateDelta = twistRate - oldTwistRate;
if (!(fabsf(aimError) <= TwistEps // _DAT_004b6508
&& fabsf(rateDelta) <= SettleEps // _DAT_004b650c
&& !justStopped))
{
SetMovedFlag(); // this @0x18 |= 1
}
// ---- limit latches (edge-triggered) + statusFlags ----
// Cleared when off the limit; set (with statusFlags=2) only on the FRAME the
// limit is newly reached. @0x260/@0x264 latch the twist limits, @0x258/@0x25C
// the elevation limits (verified against part_013.c 4658-4692).
statusFlags = 0;
if (fabsf(currentTwist - horizontalLimitLeft) > VelEps) hitTwistLeft = 0; // @0x260
else if (hitTwistLeft == 0) { hitTwistLeft = 1; statusFlags = 2; }
if (fabsf(currentTwist - horizontalLimitRight) > VelEps) hitTwistRight = 0; // @0x264
else if (hitTwistRight == 0) { hitTwistRight = 1; statusFlags = 2; }
if (fabsf(currentElevation - verticalLimitTop) > VelEps) hitElevTop = 0; // @0x258
else if (hitElevTop == 0) { hitElevTop = 1; statusFlags = 2; }
if (fabsf(currentElevation - verticalLimitBottom) > VelEps) hitElevBottom = 0; // @0x25C
else if (hitElevBottom == 0) { hitElevBottom = 1; statusFlags = 2; }
if (recenterActive != 0) statusFlags = 1;
// Per-frame skeleton write: push currentTwist into the resolved horizontal
// joint(s). In the shipped binary this is UpdateJoints (@004b67ec) -- the
// out-param-free twin of WriteJoints -- with no DIRECT caller in the recovered
// decomp: it was dispatched by the engine's generic per-frame joint pass (an
// indirect/virtual call). This port ticks the torso's Performance here, so we
// resolve that dispatch to a direct call at the same per-frame cadence, which
// is what makes the torso visibly track the aim.
UpdateJoints(); // FUN_004b67ec
Check_Fpu();
}
//
// @004b65f8 [CONFIDENT] -- the damaged-copy Performance (PTR @00510c1c). A copy
// segment does not take commands; it just eases its twist toward the master's
// target. ComputeTargetTwist() reports whether the master is still slewing: if
// not, snap currentTwist to the target; otherwise lerp by
// dt / ((now - lastUpdateTime)/scale + dt). Result is clamped to the limits.
//
void
Torso::TorsoCopySimulation(Scalar time_slice)
{
if (!ComputeTargetTwist()) // FUN_004b6510 -> 0 = settled
{
currentTwist = targetTwist; // @0x1D8 = @0x218
}
else
{
Scalar age = (Scalar)(lastUpdateTime - GetLastUpdateTime()) / MsPerSecond;
currentTwist = Lerp(currentTwist, targetTwist, time_slice / (age + time_slice)); // FUN_004081e0
}
targetTwist = Min(targetTwist, horizontalLimitLeft); // @0x1E0
targetTwist = Max(targetTwist, horizontalLimitRight); // @0x1DC
// Per-frame skeleton write for the replicated (damaged-copy) torso -- same
// external joint pass as the master path (see TorsoSimulation for the @004b67ec
// note). Harmless in single-player bring-up (no copies); correct for MP.
UpdateJoints(); // FUN_004b67ec
// COPY-SIDE PROBE (BT_TORSO_LOG): where does the replicant's twist come from?
if (getenv("BT_TORSO_LOG"))
{
static int s_cl = 0;
if ((s_cl++ % 120) == 0)
DEBUG_STREAM << "[torso-copy] cur=" << currentTwist
<< " target=" << targetTwist
<< " atUpd=" << twistAtUpdate
<< " rate=" << twistRate
<< " vel=" << twistVelocity
<< " lastUpd=" << lastUpdateTime
<< " now=" << GetCurrentTime()
// #148: ComputeTargetTwist ends in Min(limitLeft)/Max(limitRight).
// If the COPY's limits never loaded they are 0/0, which pins
// targetTwist to EXACTLY 0 no matter what the record carried --
// which is what a peer stuck at zero twist would look like.
<< " limL=" << horizontalLimitLeft
<< " limR=" << horizontalLimitRight
<< " enab=" << (int)horizontalEnabled
<< " copy=" << (int)isDamagedCopy << std::endl;
}
}
//#############################################################################
// Internal model helpers
//
//
// @004b6510 [CONFIDENT] -- compute the extrapolated twist target. Picks the
// time base (clamped command timestamp vs. clock), forms an elapsed time in
// seconds, predicts targetTwist = twistAtUpdate + twistRate * elapsed, clamps to
// the limits, and returns 1 while still extrapolating (live), 0 once settled.
//
Logical
Torso::ComputeTargetTwist()
{
int base;
Logical slewing;
if (isDamagedCopy == 0 || lastUpdateTime <= GetCurrentTime())
{
base = GetCurrentTime() - GetLastUpdateTime(); // this[0x10] - this[0x14]
slewing = False;
}
else
{
base = lastUpdateTime - GetLastUpdateTime(); // @0x254 - this[0x14]
slewing = True;
}
Scalar elapsed = (Scalar)base / MsPerSecond;
targetTwist = twistAtUpdate + twistRate * elapsed; // @0x218 = @0x21C + @0x238*t
targetTwist = Min(targetTwist, horizontalLimitLeft); // @0x1E0
targetTwist = Max(targetTwist, horizontalLimitRight); // @0x1DC
return slewing;
}
//
// ResolveJoint -- forward to the owning Mech's shared resolver (FUN_00424b60,
// inlined by the Torso ctor @004b6b0c). Out-of-line so the complete Mech type
// (mech.hpp) is visible; returns NULL for an absent node (ctor guards on it).
//
Joint*
Torso::ResolveJoint(const char *joint_name)
{
Check(owner); // inherited MechSubsystem::owner (Mech*)
return owner->ResolveJoint(joint_name);
}
//
// @004b66b4 (inner block) [CONFIDENT] -- write one twist (yaw) scalar into a
// resolved skeleton node, preserving the node's other DOF. Dispatched on the
// joint type exactly like AnimationInstance::Animate (JMOVER.cpp:1518-1567):
// hinge nodes (types 0..2) take a scalar Radian (FUN_0041d0a8 ==
// Joint::SetRotation(Radian)); ball nodes (types 4..5) take an EulerAngles whose
// YAW carries the twist while pitch/roll are read back and kept (FUN_0041cfa0 ==
// GetEulerAngles, FUN_0041d020 == SetRotation(EulerAngles)). SetRotation sets
// jointModified + ModifyJoints() internally, so we write unconditionally.
//
void
Torso::PushTwist(Joint *node, Scalar twist)
{
if (node == NULL) // ctor may have failed to resolve the node;
{ // the binary trusts it -- guard for bring-up
return;
}
Joint::JointType jt = node->GetJointType(); // node+0x10
// bring-up verification (env BT_TORSO_LOG; default OFF): show the first few
// joint writes so the per-frame path can be confirmed in a headless run.
// ⚠ SAMPLING TRAP (fixed 2026-08-08, #141): this used to sample ONE shared
// static every 30th call. With a master torso and a replicant COPY torso
// both ticking, the calls alternate 1:1 -- so every 30th call is always the
// SAME instance, and the probe reported only the local (untwisted) torso
// while the copy's writes were invisible. Sample per instance-kind instead.
static const int s_log = getenv("BT_TORSO_LOG") ? 1 : 0;
static int s_count[2] = { 0, 0 };
const int kind = isDamagedCopy ? 1 : 0;
if (s_log && (s_count[kind] % 30) == 0 && s_count[kind] < 1800)
{
DEBUG_STREAM << "[torso] PushTwist " << (kind ? "COPY " : "master")
<< " node=" << (void*)node << " type=" << (int)jt
<< " twist=" << (float)twist << "\n" << std::flush;
}
++s_count[kind];
switch (jt) // node+0x10
{
case Joint::HingeXJointType: // types 0..2
case Joint::HingeYJointType:
case Joint::HingeZJointType:
node->SetRotation(Radian(twist)); // FUN_0041d0a8
break;
case Joint::BallJointType: // types 4..5
case Joint::BallTranslationJointType:
{
EulerAngles angles = node->GetEulerAngles(); // FUN_0041cfa0 (keep pitch/roll)
EulerAngles twisted(angles.pitch, twist, angles.roll); // yaw <- twist (index 1)
node->SetRotation(twisted); // FUN_0041d020
break;
}
default: // StaticJointType(3) / NULLJointType(-1)
break;
}
}
//
// @004b66b4 [CONFIDENT] -- write currentTwist into the two horizontal joints
// (main @0x278 + shadow @0x27C) when horizontalEnabled, and report the current
// elevation through `verticalOut`. Each joint is updated as a scalar channel
// (node type < 3) or a vector channel (types 4..5), matching the gyro pattern.
//
void
Torso::WriteJoints(Scalar &verticalOut)
{
verticalOut = currentElevation; // *param_2 = @0x1E4
if (!horizontalEnabled) // @0x250
{
return;
}
PushTwist(horizontalJointNode, currentTwist); // @0x278
PushTwist(horizontalShadowJointNode, currentTwist); // @0x27C
}
//
// @004b67ec [CONFIDENT] -- identical to WriteJoints minus the elevation output;
// used on the paths that only need to refresh the skeleton.
//
void
Torso::UpdateJoints()
{
if (!horizontalEnabled)
{
return;
}
PushTwist(horizontalJointNode, currentTwist);
PushTwist(horizontalShadowJointNode, currentTwist);
}
//
// @004b6918 [CONFIDENT] -- ease currentTwist back toward 0 by effectiveTwistRate
// * dt, clamping so it does not cross zero, and return True while still off
// centre (|currentTwist| > VelEps).
//
Logical
Torso::Recenter(Scalar time_slice)
{
Scalar step = effectiveTwistRate * time_slice; // @0x244
if (currentTwist > Zero)
{
currentTwist -= step;
if (currentTwist < 0.0f) currentTwist = 0.0f;
}
if (currentTwist < Zero)
{
currentTwist += step;
if (currentTwist > 0.0f) currentTwist = 0.0f;
}
return fabsf(currentTwist - Zero) > VelEps; // _DAT_004b6a18
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// CreateStreamedSubsystem -- Torso
//
// @004b6fec [CONFIDENT for the field list / classID / size; body linearised].
// Chains to PowerWatcher::CreateStreamedSubsystem (FUN_004b198c), stamps the
// resource (classID 0x0BC5 @+0x20, model size 0x158 @+0x24), reads the optional
// "TorsoHorizontalEnabled" flag (default True; the string "false" -> False), the
// six mandatory angular/limit scalars and the two acceleration scalars, then --
// only when enabled -- the two joint names, which must resolve in the model's
// "skeleton" file.
//
int
Torso::CreateStreamedSubsystem(
NotationFile *model_file,
const char *model_name,
const char *subsystem_name,
SubsystemResource *r,
NotationFile *subsystem_file,
const ResourceDirectories *directories,
int passes
)
{
if (
!PowerWatcher::CreateStreamedSubsystem( // FUN_004b198c
model_file, model_name, subsystem_name,
r, subsystem_file, directories, passes
)
)
{
return False;
}
r->subsystemModelSize = sizeof(*r); // +0x24 = 0x158
r->classID = RegisteredClass::TorsoClassID; // +0x20 = 0x0BC5
if (passes == 1)
{
// "TorsoHorizontalEnabled": absent => True; the literal "false" => False.
const char *flag = 0;
if (!model_file->GetEntry(subsystem_name, "TorsoHorizontalEnabled", &flag))
{
r->torsoHorizontalEnabled = True;
}
else
{
r->torsoHorizontalEnabled =
(stricmp(flag, "false") == 0) ? False : True; // FUN_004d4b58
}
}
#define REQ_SCALAR(NAME, FIELD) \
if (!model_file->GetEntry(subsystem_name, NAME, &r->FIELD) \
&& r->FIELD == Unset) \
{ DebugStream << subsystem_name << " missing " << NAME << "!"; return False; }
REQ_SCALAR("ButtonAccelerationPerSecond", buttonAccelerationPerSecond) // +0x150
REQ_SCALAR("ButtonAccelerationStartValue", buttonAccelerationStartValue)// +0x154
REQ_SCALAR("HorizontalRotationPerSecond", horizontalRotationPerSecond) // +0xF4
REQ_SCALAR("VerticalRotationPerSecond", verticalRotationPerSecond) // +0xF8
REQ_SCALAR("HorizontalLimitRight", horizontalLimitRight) // +0xFC
REQ_SCALAR("HorizontalLimitLeft", horizontalLimitLeft) // +0x100
REQ_SCALAR("VerticalLimitTop", verticalLimitTop) // +0x104
REQ_SCALAR("VerticalLimitBottom", verticalLimitBottom) // +0x108
if (r->torsoHorizontalEnabled)
{
const char *hj = "Unspecified";
if (!model_file->GetEntry(subsystem_name, "TorsoHorizontalJoint", &hj)
&& strcmp(hj, "Unspecified") == 0)
{ DebugStream << subsystem_name << " missing TorsoHorizontalJoint!"; return False; }
if (strcmp(hj, "Unspecified") != 0) strcpy(r->torsoHorizontalJoint, hj); // +0x10C
const char *sj = "Unspecified";
if (!model_file->GetEntry(subsystem_name, "TorsoHorizontalShadowJoint", &sj)
&& strcmp(sj, "Unspecified") == 0)
{ DebugStream << subsystem_name << " missing TorsoHorizontalShadowJoint!"; return False; }
if (strcmp(sj, "Unspecified") != 0) strcpy(r->torsoHorizontalShadowJoint, sj); // +0x12C
const char *skeleton = 0;
if (!model_file->GetEntry("video", "skeleton", &skeleton))
{
DebugStream << model_name << " is missing skeleton file!";
return -1;
}
ReconSkeleton *skl = LoadSkeleton(directories, skeleton);
if (!skl->FindNode(r->torsoHorizontalJoint))
{
DebugStream << r->torsoHorizontalJoint << " not found in " << skeleton;
return -1;
}
// (shadow-joint lookup follows the same pattern)
}
#undef REQ_SCALAR
Check_Fpu();
return True;
}
//===========================================================================//
// WAVE 4 factory bridge -- Torso (factory case 0xBC5, "SinkSource" label).
// The real class at 0xBC5 (ctor @004b6b0c) is Torso; the factory built a
// HeatSinkSource RECON_SUBSYS stub in its place. Constructing the real Torso
// here is what lets the reconstructed twist -> skeleton path run. The object
// is read at RAW offsets externally (the gyro cross-link -> currentTwist@0x1D8,
// mech.cpp:740), so the compiled layout MUST match the binary 0x280 -- now
// compile-time proven by TorsoLayoutCheck (sizeof(Torso)==0x280) above.
//===========================================================================//
Subsystem *CreateTorsoSubsystem(Mech *owner, int id, void *seg)
{
return (Subsystem *) new (Memory::Allocate(0x280))
Torso(owner, id, (Torso::SubsystemResource *)seg, Torso::DefaultData);
}
//
// STEP 6 bridge -- expose the live torso twist (Torso::currentTwist == the binary
// torso+0x1d8) to mech.cpp / the cylinder damage table without pulling the Torso
// header (which collides with mech.cpp's local subsystem stubs) into mech.cpp.
// The roster torso @mech+0x438 is a Torso (ClassID 0xBC5); 0 when absent.
//
Scalar BTGetTorsoTwist(Subsystem *torso)
{
return torso ? ((Torso *)torso)->CurrentTwist() : 0.0f;
}
//
// Live torso ELEVATION (pitch aim, rad) -- Torso::currentElevation == the
// binary torso+0x1E4. Same complete-type-TU bridge pattern as
// BTGetTorsoTwist above. Consumed by the per-frame eyepoint compose
// (mech4.cpp): the R/F / stick-Y aim pitches the EYE only -- the torso
// geometry does not tilt, exactly like the pod.
//
Scalar BTGetTorsoElevation(Subsystem *torso)
{
return torso ? ((Torso *)torso)->CurrentElevation() : 0.0f;
}
//
// Task #56 bridge -- ADDRESS of the live torso twist for the gyro's external-
// pitch pointer (binary bt_mech stream tail: gyro+0x258 = torso+0x1D8; the gyro
// damage-response @004b2980 reads it per hit). Same complete-type-TU pattern
// as BTGetTorsoTwist above.
//
Scalar *BTGetTorsoTwistAddr(Subsystem *torso)
{
return torso ? ((Torso *)torso)->CurrentTwistAddr() : 0;
}
//
// DeathReset (#55 step 4) -- the respawn sweep's entry for the Torso. Without
// it a respawned mech kept its previous life's twist state and watcher latches
// (playtest night 4: "loosing torso twist function after a death", Gitea #70).
//
void
Torso::DeathReset(int /*reset_command*/)
{
ResetToInitialState(); // @004b5bf8 (takes no arg)
}
//
// #83 bridge: the collision-damage distributor (mech.cpp, FUN_0049ffcc) tests
// roster members against this family's derivation chain (binary GUID 0x510b08).
// Lives here because Torso is a complete type only in this TU.
//
Derivation *
BTTorsoFamily()
{
return &Torso::ClassDerivations;
}