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
+57 -19
View File
@@ -246,8 +246,9 @@ inline ReconRegistryT *ReconRegistry() { static ReconRegistryT r; return &r; }
// segment/joint API -- it is no longer a free stand-in.
template<class...A> inline Recon LookupVideoObject(A&&...) { return Recon(); }
template<class...A> inline int ResourceFindByName(A&&...) { return 0; }
template<class...A> inline void LoadLowDetailBody(A&&...) {}
template<class...A> inline void LoadHighDetailBody(A&&...) {}
// (LoadLowDetailBody / LoadHighDetailBody removed task #59 -- they were no-op
// stubs mislabeling the FUN_004a80d4/86c8 GAIT-CLIP loaders as a body-LOD
// pair; the authentic clip-set gate now calls the real loaders.)
template<class...A> inline void FinishConstruction(A&&...) {}
template<class...A> inline void ResetPose(A&&...) {}
template<class...A> inline void SetStatusState(A&&...) {}
@@ -1354,21 +1355,19 @@ Mech::Mech(
}
//
// Choose the LOD / view variant of the body model.
// CLIP-SET / VIEW gate (@part_012.c:10308-10320). CORRECTION (task #59):
// the two branch targets here are FUN_004a80d4 / FUN_004a86c8 -- which are
// the GAIT-CLIP loaders (LoadLocomotionClips / LoadLocomotionClipsExt), NOT
// a "body LOD" pair (the old LoadLowDetailBody/LoadHighDetailBody names were
// no-op stubs, so this authentic gate did NOTHING and every mech silently
// fell back to the unconditional exterior load below -> the local cockpit
// mech played the EXTERIOR clips and leaned -8deg into every walk). The
// only real side effect at THIS point is the L4VIEWEXT flag; the clip LOAD
// itself is deferred to the gait-clip site below (after animationClips[] is
// zeroed), where the same three-way selection is applied.
//
if (getenv("L4VIEWEXT") != 0) // FUN_004dee74
{
Wword(0x15f) = 1;
LoadLowDetailBody(model); // FUN_004a80d4 [mech2]
}
else if ((instanceFlags & 0xc) == 4)
{
LoadLowDetailBody(model); // FUN_004a80d4 [mech2]
}
else
{
LoadHighDetailBody(model); // FUN_004a86c8 [mech2]
}
//
// Tell every heatable subsystem to attach to the body model.
@@ -1449,12 +1448,49 @@ Mech::Mech(
animationClips[ci] = 0;
// P3 STEP 7 (state-machine path): load the full gait clip set + speed caps into
// animationClips[5..26] via LoadLocomotionClips (mech3.cpp @004a80d4 -- real). GATED
// (BT_GAIT_CUTOVER) so the default STEP-1/2 path is untouched while the AdvanceBody
// Animation state machine is brought up (it derefs ResolveAnimationClip unconditionally
// -> will fault on any clip this mech's content lacks; observe under the gate first).
// animationClips[5..26]. GATED (BT_GAIT_CUTOVER) so the default STEP-1/2 path is
// untouched while the AdvanceBodyAnimation state machine is brought up (it derefs
// ResolveAnimationClip unconditionally -> will fault on any clip this mech's content
// lacks; observe under the gate first).
//
// AUTHENTIC clip-set gate (@part_012.c:10308-10320, task #59): the local cockpit
// MASTER gets the INTERIOR clip set (LoadLocomotionClipsExt @004a86c8, 4-char 'i'
// suffix) whose clips shake `jointshakey` and OMIT the `jointhip` walk lean; network
// REPLICANTS and the forced L4VIEWEXT external view get the EXTERIOR set
// (LoadLocomotionClips @004a80d4, 3-char) carrying the authored -8deg lean. This is
// the SINGLE loader call the decomp ctor makes (the earlier no-op body-LOD gate was
// this same decomp branch mis-reconstructed -- see the L4VIEWEXT block above).
//
// PORT NOTE (task #59): the authentic discriminator is the replicant COPY bit
// (instanceFlags & 0xC == 4), which the 1995 game sets when it constructs a
// network replicant. The port does NOT set that bit at construction (all MP
// mechs are built as local masters; replication rides the mech registry +
// update-records, not the copy-performance path) -- so BOTH the local mech
// and the peer's replica currently take the ELSE (interior) branch. That is
// correct for the LOCAL cockpit (level, the reported bug) but means the peer's
// mech does not carry the exterior -8deg lean in the OTHER pod's view yet;
// that lights up automatically once the replicant copy bit is set at
// construction (tracked in open-questions -- do NOT fake it with a viewpoint
// test here: the clips load once at ctor, before the viewpoint is assigned).
int loadedInterior = 0;
if (BTEnvOn("BT_GAIT_CUTOVER", 1)) // default ON (=0 to disable)
LoadLocomotionClips(model);
{
if (getenv("L4VIEWEXT") != 0 || (instanceFlags & 0xc) == 4)
{
LoadLocomotionClips(model); // exterior (3-char): replicant / forced-ext view
}
else
{
LoadLocomotionClipsExt(model); // interior (4-char 'i'): the local cockpit master
loadedInterior = 1;
}
}
// Register this mech's model + which clip set it currently holds, so the
// per-frame MaintainViewClipSet() (mech4 PerformAndWatch) can flip it to the
// authentic set once the viewpoint is assigned (the copy bit is never set in
// the port, so the ctor gate lands everyone on interior -- see the note above).
extern void BTStashClipState(const Mech *m, Mech__ModelResource *model, int interior);
BTStashClipState(this, model, loadedInterior);
Wword(0xe5) = 0;
Wword(0xdd) = Wword(0xde) = 1;
@@ -1622,6 +1658,8 @@ Mech::~Mech()
extern void BTDeregisterMech(Entity *m); // task #46 live-mech registry
BTDeregisterMech((Entity *)this);
extern void BTUnstashClipState(const Mech *m); // task #59 clip-set registry
BTUnstashClipState(this);
vtable = &PTR_FUN_0050cfa8;
+1
View File
@@ -930,6 +930,7 @@ protected:
void MeasureClipStride(int slot, Scalar *total, Scalar *lastKey); // @004a8054
void LoadLocomotionClips(Mech__ModelResource *model); // @004a80d4
void LoadLocomotionClipsExt(Mech__ModelResource *model); // @004a86c8
void MaintainViewClipSet(); // task #59 port: interior(viewpoint)/exterior(others)
static int
CreateSubsystemStream(
+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;
}
+6
View File
@@ -1662,6 +1662,12 @@ void
const Logical isPlayerMech =
(application != 0 && (Entity *)this == application->GetViewpointEntity());
// task #59: keep this mech on the authentic gait clip set for its role --
// INTERIOR (level cockpit) if it's the mech you pilot, EXTERIOR (the -8deg
// walk lean) for every other mech. One-time flip per viewpoint change; the
// ctor lands everyone on interior because the port never sets the copy bit.
MaintainViewClipSet();
// DEATH consumer -- runs for EVERY mech (player + spawned targets). On a
// vital kill it collapses + shuts the subsystems down + settles to disabled;
// the drive's movementMode write below is guarded on !IsMechDestroyed so a
+16
View File
@@ -198,6 +198,22 @@ Scalar
int ty = jt ? (int)jt->GetJointType() : 0;
if (ty < 3) // hinge
{
// task #59 discriminator (BT_HIP_LOG): log hinge writes so the
// jointhip walk-lean can be seen. EXTERIOR clips ramp jointhip
// to ~-8deg on stand->walk and hold it; INTERIOR ('i') clips
// never bind jointhip, so it stays silent. Sampled by joint
// slot so the stream stays readable.
if (jt && move_joints)
{
static const int s_hipLog = getenv("BT_HIP_LOG") ? 1 : 0;
if (s_hipLog)
{
const Radian ang = ((const Hinge *)cursor)->rotationAmount;
if (fabsf((float)ang) > 0.02f) // only the leaning ones
DEBUG_STREAM << "[hip] jointIdx=" << jointIndices[j]
<< " deg=" << ((float)ang * 57.2958f) << "\n" << std::flush;
}
}
if (jt && move_joints) jt->SetHinge(*(const Hinge *)cursor); // FUN_0041cfc8
cursor += 8;
}