Gait: the AUTHENTIC walk lean -- interior/exterior clip sets, level cockpit + leaning mechs (task #59)

The MadCat (and every leg-mech) leans -8deg into a walk / -11deg into a run --
an authored jointhip (hingex) pose in the EXTERIOR gait clips that the walk/run
CYCLE clips never rebind, so it HOLDS.  The 1995 game keeps a separate INTERIOR
('i'-suffix) clip set for the mech you PILOT: those shake jointshakey (cockpit
rattle) and OMIT jointhip, so your own view stays level while everyone else sees
you lean.  The port had this entirely dead -- the local cockpit mech pitched
-8deg into the ground (user-reported "staring at the ground").

ROOT CAUSE (two stacked bugs):
- The authentic ctor clip-set gate (@part_012.c:10308-10320) was reconstructed
  as no-op stubs: LoadLowDetailBody/LoadHighDetailBody MISLABELED the
  FUN_004a80d4/86c8 GAIT-CLIP loader addresses as a "body LOD" pair, so the gate
  did nothing and a separate unconditional LoadLocomotionClips always loaded
  EXTERIOR clips.
- LoadLocomotionClipsExt (the interior loader) was a stub aliased to the
  exterior loader.

FIX:
- Reconstructed the real 4-char INTERIOR loader (byte-exact vs @004a86c8; the
  'i'-suffix table dumped from the exe @0x10d74d: swri/wwri/wwli/...; all 18
  base clips confirmed present in BTL4.RES for mad/blh/ava).
- Fixed the ctor gate: getenv("L4VIEWEXT") || (instanceFlags&0xC)==4 -> exterior
  (replicant / forced external view); else -> interior (local cockpit master).
  Removed the mislabeling no-op stubs.
- PORT ADAPTATION (MaintainViewClipSet, per-frame in PerformAndWatch): the port
  never sets the replicant COPY bit (all MP mechs build as local masters --
  heat-sim/scoring/torso-watcher-connect all run on both, by design), so the
  ctor gate lands everyone on interior.  Pick the set by VIEWPOINT instead: the
  mech you pilot keeps interior (level cockpit), every other mech flips once to
  exterior (the lean).  Reloads only on a viewpoint-status change; the sets
  share stride data so the swap is seamless.  Model pointers stashed at ctor.

VERIFIED live (BT_HIP_LOG probe): your walking mech writes jointhip=0 (level);
the peer's replica in the other pod's view leans at exactly -8.0/-11.1deg --
the authored clip values.  A/B control: L4VIEWEXT=1 forces the walking master
back to 74 sustained -8/-11 writes.

KB: locomotion.md ("NO walk lean" audit conclusion CORRECTED -- the lean is
authored + sustained + clip-set-specific), asset-formats.md (.ANI parses
pitch/yaw/roll for all joint types; KeyJointPos translation; cycle clips carry
no pitch), open-questions.md (item closed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-13 14:46:22 -05:00
co-authored by Claude Fable 5
parent 02cdfd6576
commit 2e9c78d604
8 changed files with 348 additions and 39 deletions
+204 -17
View File
@@ -470,31 +470,218 @@ void
//###########################################################################
//###########################################################################
// LoadLocomotionClipsExt
// LoadLocomotionClipsExt (the INTERIOR clip set)
//
// @004a86c8
//
// Structurally identical to LoadLocomotionClips, but reads from the *second*
// gait-clip suffix table at 0050d94d -- these are 4-character codes (note the
// extra uStack_19 byte the decomp materialises in the name buffer) -- and the
// optional-set markers also carry the extra character. Used for the mech
// configuration whose clip names do not fit the 3-letter scheme. The cached
// targets (0x530/0x534/0x538/0x34c/0x52c/0x350, the optional sets, and the
// capability flags) are exactly the same as the base loader.
//
// (Body omitted here for brevity; it differs from LoadLocomotionClips only in
// the suffix-string operands and the 4-char name length. See @004a86c8 for
// the byte-exact form -- the call/measure/store sequence is line-for-line
// identical.)
// The 4-character suffix table @0050d94d is the 3-letter codes with a trailing
// 'i' (swri, wwri, wwli, ... -- byte-verified from BTL4OPT.EXE @file 0x10d74d).
// These are the INTERIOR ('i') gait clips, bound to the LOCAL cockpit MASTER
// (the ctor gate @part_012.c:10308-10320): they animate `jointshakey` (the
// cockpit shake node) and OMIT the `jointhip` walk-lean channel the exterior
// clips carry -- so the pilot's own view stays level while a walking mech
// leans into its stride for everyone ELSE (the exterior clips, network
// replicants + the L4VIEWEXT external view). The call/measure/store sequence
// and the cached targets (0x530/0x534/0x538/0x34c/0x52c/0x350, the optional
// sets, the capability flags) are LINE-FOR-LINE identical to the 3-char
// loader; only the suffix operands differ (each gains the 'i'). [T1: byte-
// exact against @004a86c8 + the suffix-table dump.]
//###########################################################################
//###########################################################################
void
Mech::LoadLocomotionClipsExt(Mech__ModelResource *model)
{
// identical control flow to LoadLocomotionClips with the 0050d94d table;
// see banner. Reconstructed as a thin alias to keep the cached-field
// contract documented in one place.
LoadLocomotionClips(model); // PLACEHOLDER for the 4-char variant @004a86c8
const char *prefix = model->animationPrefix; // param_2 + 0x40
gyroRumbleTimer = 0.0f; // this+0x5c4
// --- stand -> walk : standSpeed is that clip's final-keyframe stride ----
namedClip[ 0] = *ResolveAnimationClip(prefix, "swri"); // 0x5e0
legAnimation.SelectSequence(namedClip[0], PTR_LAB_0050d714, DAT_0050d718, DAT_0050d71c);
standSpeed = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x530
// --- forward walk/run : stride = (s6+s7)/(d6+d7) ------------------------
Scalar s0, d0, s1, d1;
namedClip[ 1] = *ResolveAnimationClip(prefix, "wwri"); // 0x5e4
MeasureClipStride(6, &s0, &d0);
namedClip[ 2] = *ResolveAnimationClip(prefix, "wwli"); // 0x5e8
MeasureClipStride(7, &s1, &d1);
walkStrideLength = (s0 + s1) / (d0 + d1); // 0x534
namedClip[ 3] = *ResolveAnimationClip(prefix, "wsri"); // 0x5ec walk->stand R
namedClip[ 4] = *ResolveAnimationClip(prefix, "wsli"); // 0x5f0 walk->stand L
// --- reverse base : reverseSpeedMax is that clip's final-keyframe stride -
namedClip[ 5] = *ResolveAnimationClip(prefix, "wrri"); // 0x5f4
legAnimation.SelectSequence(namedClip[5], PTR_LAB_0050d720, DAT_0050d724, DAT_0050d728);
reverseSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x538
namedClip[ 6] = *ResolveAnimationClip(prefix, "wrli"); // 0x5f8
// --- reverse cycle : stride = (s_c+s_d)/(d_c+d_d) -----------------------
namedClip[ 7] = *ResolveAnimationClip(prefix, "rrri"); // 0x5fc
MeasureClipStride(0x0c, &s0, &d0);
namedClip[ 8] = *ResolveAnimationClip(prefix, "rrli"); // 0x600
MeasureClipStride(0x0d, &s1, &d1);
reverseStrideLength = (s0 + s1) / (d0 + d1); // 0x34c
// REVERSE + BUMP set (same slot map as the 3-char loader; see its banner) --
animationClips[14] = *ResolveAnimationClip(prefix, "rwri"); // 0x604 state 0xE run->walk R
animationClips[15] = *ResolveAnimationClip(prefix, "rwli"); // 0x608 state 0xF run->walk L
animationClips[0x20] = *ResolveAnimationClip(prefix, "bmpi");// 0x64c bump/stagger clip
gimpBaseClip = animationClips[0x20];
animationClips[16] = *ResolveAnimationClip(prefix, "sbri"); // 0x60c state 0x10 stand->back R
legAnimation.SelectSequence(animationClips[16], PTR_LAB_0050d72c, DAT_0050d730, DAT_0050d734);
gimpSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x52c
animationClips[17] = *ResolveAnimationClip(prefix, "sbli"); // 0x610 state 0x11 stand->back L
animationClips[20] = *ResolveAnimationClip(prefix, "bsri"); // 0x61c state 0x14 back->stand R
animationClips[21] = *ResolveAnimationClip(prefix, "bsli"); // 0x620 state 0x15 back->stand L
// --- reverse cycle : stride = -(s+s)/(d+d), stored negative (carried s1/d1)
animationClips[18] = *ResolveAnimationClip(prefix, "bbri"); // 0x614 state 0x12 back cycle R
MeasureClipStride(0x12, &s0, &d0);
animationClips[19] = *ResolveAnimationClip(prefix, "bbli"); // 0x618 state 0x13 back cycle L
MeasureClipStride(0x13, &s0, &d0);
gimpStrideLength = -((s0 + s1) / (d0 + d1)); // 0x350
//
// ---- OPTIONAL clip set 1: JUMP JETS (probe "wgli") ----
//
jumpCapable = 0; // this+0x580
if (ResolveAnimationClip(prefix, "wgli") != 0)
{
jumpCapable = 1;
animationClips[22] = *ResolveAnimationClip(prefix, "wgli"); // 0x624 state 0x16
legAnimation.SelectSequence(animationClips[22], PTR_LAB_0050d738, DAT_0050d73c, DAT_0050d740);
jumpRunSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x53c
animationClips[23] = *ResolveAnimationClip(prefix, "wgri"); // 0x628 state 0x17
legAnimation.SelectSequence(animationClips[23], PTR_LAB_0050d744, DAT_0050d748, DAT_0050d74c);
jumpWalkSpeedMax = legAnimation.keyframeData[legAnimation.keyframeCount].stride; // 0x540
animationClips[24] = *ResolveAnimationClip(prefix, "ggri"); // 0x62c state 0x18
MeasureClipStride(0x18, &s0, &d0);
jumpRunStrideLength = s0 / d0; // 0x544
animationClips[25] = *ResolveAnimationClip(prefix, "ggli"); // 0x630 state 0x19
MeasureClipStride(0x19, &s0, &d0);
jumpWalkStrideLength = s0 / d0; // 0x548
animationClips[26] = *ResolveAnimationClip(prefix, "gsli"); // 0x634 state 0x1A
animationClips[27] = *ResolveAnimationClip(prefix, "gsri"); // 0x638 state 0x1B
}
//
// ---- OPTIONAL clip set 2: SQUAT (probe "squi") ----
//
hasReverseGimpSet = 0; // this+0x584 (squatCapable)
if (ResolveAnimationClip(prefix, "squi") != 0)
{
hasReverseGimpSet = 1;
animationClips[3] = *ResolveAnimationClip(prefix, "squi"); // 0x5d8
animationClips[2] = *ResolveAnimationClip(prefix, "sqdi"); // 0x5d4
}
//
// ---- OPTIONAL clip set 3: TURN-IN-PLACE (probe "trni") ----
//
hasCrashSet = 0; // this+0x588 (turnCapable)
if (ResolveAnimationClip(prefix, "trni") != 0)
{
hasCrashSet = 1;
animationClips[4] = *ResolveAnimationClip(prefix, "trni"); // 0x5dc
}
DEBUG_STREAM << "[loadclips] INTERIOR ('i') set: fScale=" << forwardThrottleScale
<< " walkStride=" << walkStrideLength << " jumpCapable=" << jumpCapable
<< "\n" << std::flush;
}
//###########################################################################
//###########################################################################
// MaintainViewClipSet (port adaptation, task #59)
//
// The authentic clip-set gate (@part_012.c:10308-10320) keys on the replicant
// COPY bit (instanceFlags & 0xC == 4), which the port never sets -- ALL MP mechs
// are built as local masters (replication rides the mech registry + update
// records, not the copy-performance path; the heat sim / scoring / torso
// watcher-connect all run on both, by design). So the ctor gate lands every
// mech on the INTERIOR set. We restore the authentic RESULT by selecting the
// set by VIEWPOINT instead, maintained per frame: the mech you PILOT (== the
// viewpoint entity) keeps the INTERIOR set (level cockpit -- you never see your
// own mech externally in the real pod); every OTHER mech gets the EXTERIOR set
// (the authored -8deg jointhip walk lean, which you DO see from your cockpit /
// the chase cam). Reloads only on a viewpoint-status CHANGE (no per-frame
// thrash), and the two sets share identical stride data so the swap is seamless.
// The model pointer is stashed at ctor (the GameModel resource persists for the
// mission). Fixed array -- mech counts are tiny, no allocator in the tick path.
//###########################################################################
//###########################################################################
namespace {
struct BTClipEntry { const Mech *mech; Mech__ModelResource *model; int interior; };
BTClipEntry s_clipReg[32];
int s_clipRegCount = 0;
}
void BTStashClipState(const Mech *m, Mech__ModelResource *model, int interior)
{
for (int i = 0; i < s_clipRegCount; ++i)
if (s_clipReg[i].mech == m)
{
s_clipReg[i].model = model;
s_clipReg[i].interior = interior;
return;
}
if (s_clipRegCount < 32)
{
s_clipReg[s_clipRegCount].mech = m;
s_clipReg[s_clipRegCount].model = model;
s_clipReg[s_clipRegCount].interior = interior;
++s_clipRegCount;
}
}
void BTUnstashClipState(const Mech *m)
{
for (int i = 0; i < s_clipRegCount; ++i)
if (s_clipReg[i].mech == m)
{
s_clipReg[i] = s_clipReg[--s_clipRegCount];
return;
}
}
void
Mech::MaintainViewClipSet()
{
if (application == 0 || !BTEnvOn("BT_GAIT_CUTOVER", 1))
return;
if (getenv("L4VIEWEXT") != 0) // forced-external debug view: leave EXTERIOR everywhere
return;
Entity *vp = application->GetViewpointEntity();
if (vp == 0) // viewpoint not assigned yet -- keep the current set
return;
BTClipEntry *e = 0;
for (int i = 0; i < s_clipRegCount; ++i)
if (s_clipReg[i].mech == this) { e = &s_clipReg[i]; break; }
if (e == 0 || e->model == 0) // not registered / no model -- nothing to flip
return;
const int wantInterior = ((Entity *)this == vp) ? 1 : 0;
if (wantInterior == e->interior) // already holding the right set
return;
if (wantInterior)
LoadLocomotionClipsExt(e->model); // interior: level cockpit (your own mech)
else
LoadLocomotionClips(e->model); // exterior: the authored walk lean (every other mech)
e->interior = wantInterior;
DEBUG_STREAM << "[clipfix] mech " << (void *)this << " -> "
<< (wantInterior ? "INTERIOR (level)" : "EXTERIOR (lean)") << "\n" << std::flush;
}