BT410 5.3.93: the clip loader runs LIVE -- the Mad Cat measures itself, and a 1995 copy-paste bug ships again on purpose

LoadLocomotionClips + ResolveAnimationClip + MeasureClipStride + LoadClipSlot
reconstructed and WIRED into the ctor's GameModel block.  animationClips[] is
no longer an empty array: every gait slot resolves by the model's animation
prefix, and the locomotion constants are now MEASURED from the authored clips
instead of asserted as bring-up defaults.  First live run, arena mission:

  [mech] clips 'mad': standSpeed=5.23 walkStride=18.51 revStride=56.05
                      revSpeedMax=26.26 gimpSpeedMax=-4.23 gimpStride=-20.26
                      limpSet=1

That line carries three verifications at once: the prefix printing as text
proves the Mech__ModelResource layout is right at +0x40; the reverse figures
come out NEGATIVE exactly as the transition machines expect; and the mission
ran clean to live driving afterwards (703 log lines, no fault).

DRIVING FEEL CHANGED, deliberately: speedDemand at 0.6 throttle went 14.4 ->
26.9, because the placeholder top speed (30) gave way to the measured 56.05.
The Mad Cat is simply faster than the bring-up guess.  Authenticity arriving,
not a regression.

TWO BINARY BEHAVIOURS REPRODUCED ON PURPOSE, both documented at the function:

  The speed caps read keyframeData[keyframeCount] -- one entry PAST the last
  frame.  Fencepost is the binary's own (0x690 + 8 + [0x670]*0xc); whether the
  authored table has count+1 entries is unestablished, but the clips were
  authored against this read and the measured values are sane.

  The reverse-cycle stride is computed from STALE locals.  The decomp is
  unambiguous: bbr and bbl are both measured into local_8/local_c, and the
  divide's second terms come from local_10/local_14 -- still holding the
  RUN-LEFT figures.  gimpStrideLength = -((bbl + rrl_stale)/(...)).  A 1995
  copy-paste bug, shipped in every pod for thirty years, reproduced here with
  a comment pointing at the wwr/wwl block that shows the intended pattern.

  (And an earlier scare resolved: the negation IS in the binary -- the very
  next instruction is 0x350 = -0x350.  My first decomp window cut one line
  short and briefly indicted the donor's minus sign.)

ONE DELIBERATE DIVERGENCE, tagged [T3]: the binary dereferences every resolve
result unguarded -- a model missing a mandatory clip crashes on load.  Here a
miss stores NullResourceID (SelectSequence resolves it to an inert controller)
and the dependent measurement is skipped.  Keeps the boot alive on unverified
clip sets; revisit when the fleet's models are known-good.  Measurement binds
pass a NULL finished-callback (measurement parses, never plays -- the binary's
live pointers can never fire there).

Ctor additionally zero-initializes the whole gait channel -- globalTimeScale
defaulting to 1 specifically, because zero would silence every clip advance --
and fills the clip array with NullResourceID before the loader runs, so the
uninitialized-member class of bug (see 5.3.83) is closed here BEFORE the
consumers arrive.

MECH.HPP: the optional limp set carved out (hasGimpClips + 4 measured limp
figures + gyroRumbleTimer); reservedState 150 -> 140.

Next: the four Advance* entry points -- the last link before the legs move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-02 12:23:04 -05:00
co-authored by Claude Fable 5
parent 64f99ffcc8
commit a87c49c158
4 changed files with 427 additions and 3 deletions
+67
View File
@@ -219,6 +219,37 @@ Mech::Mech(
bodyTargetSpeed = 0.0f;
currentBodySpeed = 0.0f;
//
// Gait channel state (mech2.cpp). The measured constants (standSpeed,
// gimpSpeedMax, gimpStrideLength and the four limp figures) get their
// real values in LoadLocomotionClips below; these defaults keep every
// divide in the transition machines finite if a clip set is missing.
// globalTimeScale MUST default to 1 -- zero would silence every clip
// advance. Unfilled clip slots hold NullResourceID, which SelectSequence
// resolves to an empty, inert controller.
//
legCycleSpeed = 0.0f;
bodyCycleSpeed = 0.0f;
forwardCycleRate = 1.0f;
gimpCycleRate = 1.0f;
standSpeed = 1.0f;
gimpSpeedMax = 1.0f;
gimpStrideLength = -1.0f; // the measured value is negative too
globalTimeScale = 1.0f;
hasGimpClips = 0;
gimpLeftSpeedMax = 1.0f;
gimpRightSpeedMax = 1.0f;
gimpLeftStrideLength = 1.0f;
gimpRightStrideLength = 1.0f;
gyroRumbleTimer = 0.0f;
{
int i;
for (i = 0; i < AnimationSlotCount; ++i)
{
animationClips[i] = ResourceDescription::NullResourceID;
}
}
eyepointRotation = EulerAngles::Identity;
lookPitch = 0.0f;
lookYaw = 0.0f;
@@ -529,6 +560,42 @@ Mech::Mech(
a = model->lookBackAngle;
if (a > -360.0f && a < 360.0f) lookBackAngle = a * RAD_PER_DEG;
}
//
// The gait clip loader -- resolves every animation clip by
// the model's prefix and MEASURES the stride/speed constants
// from the clips themselves, replacing the bring-up defaults
// above. Guarded on the prefix looking like text because
// this ModelResource layout is only partially verified; a
// garbage prefix would just probe nonsense names (soft), but
// the log line makes a layout miss visible.
//
if (
model->animationPrefix[0] >= 'a' &&
model->animationPrefix[0] <= 'z'
)
{
LoadLocomotionClips(model);
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[mech] clips '"
<< model->animationPrefix << "': standSpeed="
<< standSpeed << " walkStride=" << walkStrideLength
<< " revStride=" << reverseStrideLength
<< " revSpeedMax=" << reverseSpeedMax
<< " gimpSpeedMax=" << gimpSpeedMax
<< " gimpStride=" << gimpStrideLength
<< " limpSet=" << hasGimpClips
<< endl << flush;
}
}
else if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[mech] animationPrefix not text ("
<< (int)(unsigned char)model->animationPrefix[0]
<< ") -- clip loader SKIPPED, layout suspect"
<< endl << flush;
}
}
modelDesc->Unlock();
}
+38 -3
View File
@@ -385,6 +385,24 @@
Scalar carryover,
int move_joints);
//
//--------------------------------------------------------------------
// The gait clip loader (mech2.cpp). ResolveAnimationClip maps the
// model's animation prefix + a 3-char suffix to a clip resource;
// MeasureClipStride binds a loaded slot and integrates its keyframe
// strides; LoadLocomotionClips fills animationClips[] and measures
// every gait constant from the clips themselves.
//--------------------------------------------------------------------
//
ResourceDescription::ResourceID *
ResolveAnimationClip(const char *prefix, const char *suffix);
void
MeasureClipStride(int slot, Scalar *total, Scalar *last_key);
void
LoadLocomotionClips(ModelResource *model);
int
LoadClipSlot(int slot, const char *prefix, const char *suffix);
//
// The respawn heal-and-move (binary @0049fb74): reposition the SAME
// entity at the drop-zone origin, kill all motion, clear the death
@@ -620,6 +638,22 @@
// leaves that slot off the end.
//
int animationClips[AnimationSlotCount];
//
// The OPTIONAL limp clip set (slots 22-27). LoadLocomotionClips
// probes for the "wgl" clip; a model without it leaves hasGimpClips 0
// and those slots unfilled, so the limp machine must never be entered
// for such a mech. (These four measured figures ARE the limp's --
// unlike gimpSpeedMax/gimpStrideLength above, which despite the names
// are measured from the REVERSE clips. See MECH2.NOTES.md.)
//
int hasGimpClips;
Scalar gimpLeftSpeedMax;
Scalar gimpRightSpeedMax;
Scalar gimpLeftStrideLength;
Scalar gimpRightStrideLength;
Scalar gyroRumbleTimer; // binary mech+0x5c4; the
// clip loader zeroes it
AverageOf<Scalar> telemetryFilter[5];
CString resourceNameA;
CString resourceNameB;
@@ -719,10 +753,11 @@
// reconstructed with named fields.
//
//
// 41 ints carved out for the gait channel above (2 AlarmIndicators = 4,
// 8 Scalars, animationClips[0x1d] = 29); 191 -> 150.
// 51 ints carved out for the gait channel above (2 AlarmIndicators = 4,
// 8 Scalars, animationClips[0x21] = 33, the optional limp set = 6);
// 191 -> 140.
//
int reservedState[150];
int reservedState[140];
};
#endif
+266
View File
@@ -23,6 +23,10 @@
# include <mechmppr.hpp>
#endif
#if !defined(APP_HPP)
# include <app.hpp>
#endif
//
//#############################################################################
// A mech walks on two parallel clip channels.
@@ -478,3 +482,265 @@ Scalar
return 0.0f;
}
//
//#############################################################################
// @004a7f50 -- prefix + suffix -> the clip's resource ID.
//
// The clip names are the model's 3-char animation prefix with a 3-char gait
// suffix appended ("mad" + "wwr" = madwwr), resolved by name over the
// animation resources. Returns a pointer to the found description's
// resourceID; NULL when the model has no such clip -- which is a REAL case
// (the limp set is optional), so callers must tolerate it.
//#############################################################################
//
ResourceDescription::ResourceID *
Mech::ResolveAnimationClip(const char *prefix, const char *suffix)
{
Check(this);
Check_Pointer(prefix);
Check_Pointer(suffix);
char
clip_name[12];
strcpy(clip_name, prefix);
strcat(clip_name, suffix);
ResourceDescription
*description = application->GetResourceFile()->FindResourceDescription(
clip_name,
ResourceDescription::AnimationResourceType,
ResourceDescription::NullResourceID);
return (description != NULL) ? &description->resourceID : NULL;
}
//
//#############################################################################
// @004a8054 -- bind the clip at animationClips[slot] into the leg channel and
// integrate its keyframe strides. Returns (via the out parameters) the total
// cycle distance and the final keyframe time; the loader divides total by
// time to recover a cycle speed.
//
// The callback is NULL on purpose: measurement only ever PARSES the clip
// (SelectSequence), it never plays it, so the finished callback can never
// fire. The binary passes a live pointer here; NULL is behaviourally
// identical and avoids arming a transition machine mid-load. [T3]
//#############################################################################
//
void
Mech::MeasureClipStride(int slot, Scalar *total, Scalar *last_key)
{
Check(this);
Verify(slot >= 0 && slot < AnimationSlotCount);
legAnimation.SelectSequence(animationClips[slot], NULL, 0, 0);
*total = 0.0f;
*last_key = 0.0f;
int
frame;
for (frame = 0; frame < legAnimation.keyframeCount; ++frame)
{
Scalar
frame_time = legAnimation.keyframeTimes[frame];
*total += (frame_time - *last_key) *
legAnimation.keyframeData[frame].stride;
*last_key = frame_time;
}
}
//
//#############################################################################
// @004a80d4 -- resolve and cache every gait clip, measuring the gait
// constants from the clips themselves as it goes. This is where standSpeed,
// walkStrideLength, reverseSpeedMax, reverseStrideLength, gimpSpeedMax and
// gimpStrideLength actually COME FROM -- they are properties of the authored
// animations, not authored numbers.
//
// Two binary behaviours reproduced deliberately; neither is a transcription
// slip. See the sidecar before "fixing" either:
//
// * The speed caps read keyframeData[keyframeCount] -- one entry PAST the
// last frame (the binary reads 0x690 + 8 + [0x670]*0xc).
//
// * The reverse-cycle stride divides the bbl measurement by STALE data:
// both bbr and bbl are measured into the same pair, so the divide takes
// its second terms from whatever the run cycle left behind. A 1995
// copy-paste bug, shipped, and therefore reproduced -- the walk and run
// cycles above it show what was obviously intended.
//
// DIVERGENCE FROM THE BINARY, on purpose: the binary dereferences every
// ResolveAnimationClip result unguarded -- a mech whose model lacks a
// MANDATORY clip crashes on load. Here a miss stores NullResourceID (which
// SelectSequence resolves to an empty, inert controller) and the dependent
// measurement is skipped, leaving the bring-up default in place. [T3: keeps
// the current boot alive on models whose clip sets have not been verified;
// revisit once every fleet mech is known-good.]
//#############################################################################
//
//
// Resolve one slot: store the clip ID or NullResourceID. Returns whether the
// clip exists, so dependent measurements can be skipped on a miss.
//
int
Mech::LoadClipSlot(int slot, const char *prefix, const char *suffix)
{
ResourceDescription::ResourceID
*clip_ID = ResolveAnimationClip(prefix, suffix);
animationClips[slot] =
(clip_ID != NULL) ? *clip_ID : ResourceDescription::NullResourceID;
return clip_ID != NULL;
}
void
Mech::LoadLocomotionClips(ModelResource *model)
{
Check(this);
Check_Pointer(model);
const char
*prefix = model->animationPrefix;
//
// Zero-initialized because the guarded skips below can reach the reverse
// divide with the run pair unmeasured -- a path the (unguarded) binary
// does not have, so the stale-pair reproduction must not become an
// uninitialized read on top of it.
//
Scalar
total_a = 0.0f, last_a = 0.0f,
total_b = 0.0f, last_b = 0.0f;
gyroRumbleTimer = 0.0f;
//
// Stand -> walk. standSpeed is the clip's final-entry stride.
//
if (LoadClipSlot(5, prefix, "swr"))
{
legAnimation.SelectSequence(animationClips[5], NULL, 0, 0);
standSpeed =
legAnimation.keyframeData[legAnimation.keyframeCount].stride;
}
//
// The forward walk cycle: stride = (s6 + s7) / (d6 + d7).
//
if (
LoadClipSlot(6, prefix, "wwr") &&
LoadClipSlot(7, prefix, "wwl")
)
{
MeasureClipStride(6, &total_a, &last_a);
MeasureClipStride(7, &total_b, &last_b);
walkStrideLength = (total_a + total_b) / (last_a + last_b);
}
LoadClipSlot(8, prefix, "wsr");
LoadClipSlot(9, prefix, "wsl");
//
// Walk -> run. reverseSpeedMax is measured from wrr the same way
// standSpeed is from swr.
//
if (LoadClipSlot(10, prefix, "wrr"))
{
legAnimation.SelectSequence(animationClips[10], NULL, 0, 0);
reverseSpeedMax =
legAnimation.keyframeData[legAnimation.keyframeCount].stride;
}
LoadClipSlot(11, prefix, "wrl");
//
// The run cycle.
//
if (
LoadClipSlot(12, prefix, "rrr") &&
LoadClipSlot(13, prefix, "rrl")
)
{
MeasureClipStride(12, &total_a, &last_a);
MeasureClipStride(13, &total_b, &last_b);
reverseStrideLength = (total_a + total_b) / (last_a + last_b);
}
LoadClipSlot(14, prefix, "rwr");
LoadClipSlot(15, prefix, "rwl");
//
// The bump/crash stagger clip, slot 0x20 -- the reason the clip array is
// bigger than the state-name table.
//
LoadClipSlot(0x20, prefix, "bmp");
//
// The reverse set. gimpSpeedMax is measured from the entry clip; the
// cycle stride divide below reproduces the binary's stale-pair bug (see
// the header comment) and is negated exactly where the binary negates.
//
if (LoadClipSlot(16, prefix, "sbr"))
{
legAnimation.SelectSequence(animationClips[16], NULL, 0, 0);
gimpSpeedMax =
legAnimation.keyframeData[legAnimation.keyframeCount].stride;
}
LoadClipSlot(17, prefix, "sbl");
LoadClipSlot(20, prefix, "bsr");
LoadClipSlot(21, prefix, "bsl");
if (
LoadClipSlot(18, prefix, "bbr") &&
LoadClipSlot(19, prefix, "bbl")
)
{
MeasureClipStride(18, &total_a, &last_a);
MeasureClipStride(19, &total_a, &last_a); // the binary's stale pair:
// total_b/last_b still hold
// the run-cycle figures
gimpStrideLength = (total_a + total_b) / (last_a + last_b);
gimpStrideLength = -gimpStrideLength;
}
//
// The OPTIONAL limp set. Probe for wgl; a model without it has no limp
// clips at all, and the limp machine must never be entered for it.
//
hasGimpClips = 0;
if (ResolveAnimationClip(prefix, "wgl") != NULL)
{
hasGimpClips = 1;
if (LoadClipSlot(22, prefix, "wgl"))
{
legAnimation.SelectSequence(animationClips[22], NULL, 0, 0);
gimpLeftSpeedMax =
legAnimation.keyframeData[legAnimation.keyframeCount].stride;
}
if (LoadClipSlot(23, prefix, "wgr"))
{
legAnimation.SelectSequence(animationClips[23], NULL, 0, 0);
gimpRightSpeedMax =
legAnimation.keyframeData[legAnimation.keyframeCount].stride;
}
if (LoadClipSlot(24, prefix, "ggr"))
{
MeasureClipStride(24, &total_a, &last_a);
gimpLeftStrideLength = total_a / last_a;
}
if (LoadClipSlot(25, prefix, "ggl"))
{
MeasureClipStride(25, &total_a, &last_a);
gimpRightStrideLength = total_a / last_a;
}
LoadClipSlot(26, prefix, "gsl");
LoadClipSlot(27, prefix, "gsr");
}
Check_Fpu();
}
+56
View File
@@ -179,3 +179,59 @@ and `LoadLocomotionClipsExt` (@004a86c8, the 4-char-code variant). Note the
manifest attributes all four to **mech2.cpp** while BT411 files them under
mech3 — the manifest's attribution comes from the binary's own file tagging,
so they belong here.
## The clip loader is in (2026-08-02) — and it ran live
`ResolveAnimationClip` / `MeasureClipStride` / `LoadClipSlot` /
`LoadLocomotionClips` are reconstructed and WIRED: the ctor's GameModel block
calls the loader while the model is locked, replacing the Phase 5.3 bring-up
locomotion defaults with values measured from the actual clips. First live
run (arena mission, MAD):
```
[mech] clips 'mad': standSpeed=5.23 walkStride=18.51 revStride=56.05
revSpeedMax=26.26 gimpSpeedMax=-4.23 gimpStride=-20.26 limpSet=1
```
The 'mad' prefix printing as text is itself evidence the `Mech__ModelResource`
layout is right at +0x40. The reverse figures come out negative, as the
transition machines expect. **Driving feel changed with this**: speedDemand at
0.6 throttle went 14.4 → 26.9, because the placeholder top speed (30) gave way
to the measured 56.05. That is authenticity arriving, not a regression.
### Two binary behaviours reproduced on purpose
**The speed caps read `keyframeData[keyframeCount]`** — one entry past the
last frame (`0x690 + 8 + [0x670]*0xc`). Whether the authored table carries
count+1 entries or the read lands on adjacent resource bytes is not yet
established; it is what the binary does, the clips were authored against it,
and the measured values above look sane.
**The reverse-cycle stride is computed from STALE data.** The decomp is
unambiguous: bbr and bbl are both measured into `local_8/local_c`, then the
divide takes its second terms from `local_10/local_14` — still holding the
run-left (rrl) figures. `gimpStrideLength = -((bbl + rrl_stale)/(bbl_t +
rrl_t_stale))`. A 1995 copy-paste bug, shipped in every pod, reproduced here
with a comment. The wwr/wwl and rrr/rrl blocks above it show the intended
pattern. (Also settled: the negation IS in the binary — `0x350 = -0x350` on
the very next instruction — an earlier decomp window cut just before it and
briefly suggested otherwise.)
### One deliberate divergence
The binary dereferences every `ResolveAnimationClip` result unguarded — a
model missing a mandatory clip crashes on load. Here a miss stores
`NullResourceID` (SelectSequence resolves that to an empty, inert controller)
and the dependent measurement is skipped, keeping the bring-up default.
Tagged [T3] in the source; revisit once every fleet mech's clip set is
known-good. The measurement binds also pass a NULL finished-callback where
the binary passes live pointers — measurement only parses, never plays, so
the callback cannot fire; NULL avoids arming a transition machine mid-load.
### What "next" looks like now
The array is filled and every constant is measured. The remaining half of
this TU is the four `Advance*` entry points (wired into `Mech::Simulate`) and
the two `Gimp*ClipFinished` machines. When `AdvanceLegAnimation` lands, the
gait will select clips and SEQCTL will write joints — the first frame where
the legs actually move.