Sauron: "toggled through advanced controls from standard to advanced and back
to standard -- lost torso control."
The cycle is 0 Basic -> 1 Standard -> 2 Veteran -> WRAPS TO BASIC, so getting
from "advanced" back to Standard PASSES THROUGH BASIC, whose arm re-centres the
torso. @004afbe0 is a complete spec and the port got three things wrong:
iVar1 = mech+0x438 (TORSO) iVar2 = mech+0x5b4 (HUD)
if (mode == 0) { // BASIC
*(iVar1 + 0x1f0) = 0; // analogTwistAxis
*(iVar1 + 0x274) = 1; // recenterActive
*(iVar1 + 0x220) = *(iVar1 + 0x228); // vertLimitTop
*(iVar1 + 0x224) = *(iVar1 + 0x22c); // vertLimitBottom
*(iVar2 + 0x2a0) = 1; // HUD flickerActive
} else if (mode - 1U < 2) { // STANDARD/VETERAN
*(iVar1 + 0x220) = *(iVar1 + 0x230);
*(iVar1 + 0x224) = *(iVar1 + 0x234);
}
1. WRONG CELL. Basic called CommandRecenter() -> centerCommand (@0x208), the
HELD-BUTTON cell: TorsoSimulation re-arms recenterActive from it EVERY frame
it is non-zero, and only the input path clears it -- a mode switch has no
button release to follow. Digital twist commands are processed BEFORE the
centerCommand block, so while it is set they are overridden as fast as they
are applied: the torso stops responding. The binary sets recenterActive
(@0x274) directly -- a ONE-SHOT that self-clears on settle
(`recenterActive = Recenter(dt)`) and is cancelled by any twist input.
2. THE ELEVATION-LIMIT SWAP WAS MISSING ENTIRELY. Two authored pairs exist --
BASIC @0x228/@0x22C (full top, HALF bottom) vs STANDARD/VETERAN @0x230/@0x234
(the full pair) -- and all four members were ctor-written and read by NOTHING.
Basic never restricted downward travel; the assisted modes never restored it.
3. Basic also raises the HUD's flickerActive (@0x2A0) so the horizon re-settles
with the torso it just re-centred. Not ported. (New BTSetHudFlickerActive
bridge in hud.cpp -- mechmppr sees Subsystem*, not HUD.)
Also removed an invented SetAnalogElevationAxis(0); the binary zeroes only 0x1F0.
MEASURED A/B (scratchpad/night13/modecycle.sh, LEGACY=1 for the old path;
BT_LEGACY_MODE_RECENTER=1 is the revert switch):
ctrCmd=1 samples legacy 26 fixed 0
vLim pairs fixed run shows BOTH -- (-0.698..0.349) = -40..20 deg
assisted, and (-0.349..0.349) = -20..20 deg Basic.
Before this commit only the ctor pair ever appeared.
WHAT IS *NOT* PROVEN. I did not reproduce Sauron's PERMANENT loss. In this
bench the legacy latch is periodic, not sticky:
..........LLLL......LLLL......LLLL......LLLL......LLLL......LL
because the desktop key bridge writes centerCommand every frame and zeroes it
when no button is held, so it self-recovers. The torso IS locked while the cell
is set, which is the symptom -- but whether it stays locked depends on the input
path OWNING that cell. On the glass/pad route (Sauron's) nothing may clear it,
which would make it permanent. So: mechanism fixed and binary-grounded, exact
field persistence unverified. Field-verify by cycling modes on a pad build.
Probe: the BT_TORSO_LOG gate line now carries ctrCmd / recen / vLim.
Bench hook: BT_MODECYCLE_EVERY=<n> cycles the mode from the mapper (the pod's
own route is console key 0x13d -- not a RIO button, so BT_BTNTEST cannot press
it, and mech4's BT_MODECYCLE_TEST counter did not advance in a solo run).
NOTE the bench needs BT_KEY_BRIDGE=1: with a PadRIO present the key-bridge
block that consumes the cycle is skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
1179 lines
48 KiB
C++
1179 lines
48 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*)¤tTwist - (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;
|
|
}
|
|
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;
|
|
}
|