diff --git a/restoration/source410/BT/MECH.HPP b/restoration/source410/BT/MECH.HPP index 32c7d008..8a0e6b03 100644 --- a/restoration/source410/BT/MECH.HPP +++ b/restoration/source410/BT/MECH.HPP @@ -141,6 +141,54 @@ } }; +//########################################################################### +//####################### MechAnimationState ############################ +//########################################################################### +// +// The gait clip a mech is currently playing. Recovered VERBATIM from the +// 0x3c-byte-stride name table at .data:0050cfe8 -- the table the binary's +// "Unsupported mech animation" assert indexes -- so the names and their order +// are the original's, not a reading of behaviour. +// +// The cycle alternates LEFT and RIGHT because each clip is one stride: a +// walk is Right -> Left -> Right forever, and the transitions in and out of +// stand / run / reverse each have their own handed pair so the mech always +// leaves a cycle on the correct foot. +// + enum MechAnimationState + { + StandingAnimation = 0x00, + RightStandToWalkAnimation = 0x01, + RightWalkForwardAnimation = 0x02, + LeftWalkForwardAnimation = 0x03, + RightWalkToStandAnimation = 0x04, + LeftWalkToStandAnimation = 0x05, + RightWalkToRunAnimation = 0x06, + LeftWalkToRunAnimation = 0x07, + RightRunAnimation = 0x08, + LeftRunAnimation = 0x09, + RightRunToWalkAnimation = 0x0a, + LeftRunToWalkAnimation = 0x0b, + RightStandToReverseAnimation = 0x0c, + LeftStandToReverseAnimation = 0x0d, + RightReverseAnimation = 0x0e, + LeftReverseAnimation = 0x0f, + RightReverseToStandAnimation = 0x10, + LeftReverseToStandAnimation = 0x11, + LeftWalkToGimpAnimation = 0x12, + RightWalkToGimpAnimation = 0x13, + LeftGimpAnimation = 0x14, + RightGimpAnimation = 0x15, + LeftGimpToStandAnimation = 0x16, + RightGimpToStandAnimation = 0x17, + FallForwardAnimation = 0x18, + FallBackwardAnimation = 0x19, + FallLeftAnimation = 0x1a, + FallRightAnimation = 0x1b, + CrashAnimation = 0x1c, + AnimationCount = 0x1d + }; + //########################################################################### //############################## Mech ############################### //########################################################################### @@ -274,6 +322,44 @@ void Simulate(Scalar time_slice); + // + //-------------------------------------------------------------------- + // The gait transition machine (mech2.cpp). + // + // Set*Animation binds a channel's clip player to the clip for a state + // and records the state; *Transition is the shared tail every handler + // ends in (bind the next state, then spend the leftover time in it). + // + // *ClipFinished are the callbacks the clip players invoke at end of + // clip -- hence static, taking the mech explicitly. They are + // RE-ENTRANT BY CONTRACT: each re-arms its channel and advances the + // carryover itself, returning the distance that carryover covered, so + // SequenceController::Advance folds the result into its own return. + //-------------------------------------------------------------------- + // + void + SetLegAnimation(int state); + void + SetBodyAnimation(int state); + + Scalar + LegTransition(int next_state, Scalar advance_time, int move_joints); + Scalar + BodyTransition(int next_state, Scalar advance_time, int move_joints); + + static Scalar + LegClipFinished( + Mech *mech, + unsigned callback_arg, + Scalar carryover, + int move_joints); + static Scalar + BodyClipFinished( + Mech *mech, + unsigned callback_arg, + Scalar carryover, + int move_joints); + // // The respawn heal-and-move (binary @0049fb74): reposition the SAME // entity at the drop-zone origin, kill all motion, clear the death @@ -472,6 +558,39 @@ StateIndicator collisionState; SequenceController legAnimation; SequenceController bodyAnimation; + + // + // GAIT ANIMATION (mech2.cpp). Two parallel channels, each a clip + // player plus a state alarm holding the current MechAnimationState: + // + // LEG channel -- the locally-simulated gait. Its transitions read + // the LIVE commanded speed from the controls mapper. + // BODY channel -- the displayed-motion gait, whose advanced distance + // is what feeds the mech's forward motion. Its + // transitions read bodyTargetSpeed instead, so a + // dead-reckoned or networked mech walks correctly + // without a local controls mapper. + // + // The alarms ARE the state (binary mech+0x3b0 / +0x728 mirror the + // alarm level); read them with GetLevel rather than keeping a copy. + // + AlarmIndicator legStateAlarm; + AlarmIndicator bodyStateAlarm; + Scalar legCycleSpeed; // channel-A cycle speed + Scalar bodyCycleSpeed; // channel-B cycle speed + Scalar forwardCycleRate; // cycle-speed slew rate + Scalar gimpCycleRate; // slew rate while limping + Scalar standSpeed; // "moving at all" threshold + Scalar gimpSpeedMax; // limp cycle speed cap + Scalar gimpStrideLength; // limp clip length (NEGATIVE + // as authored -- the machines + // fold the sign) + Scalar globalTimeScale; // multiplies every increment + // + // Clip handle per MechAnimationState (binary mech+0x5cc, indexed by + // the state enum). Resolved from the mech's model resource. + // + int animationClips[AnimationCount]; AverageOf telemetryFilter[5]; CString resourceNameA; CString resourceNameB; @@ -570,7 +689,11 @@ // mech2/mech3/mech4 simulation. Reserved until that path is // reconstructed with named fields. // - int reservedState[191]; + // + // 41 ints carved out for the gait channel above (2 AlarmIndicators = 4, + // 8 Scalars, animationClips[0x1d] = 29); 191 -> 150. + // + int reservedState[150]; }; #endif diff --git a/restoration/source410/BT/MECH2.CPP b/restoration/source410/BT/MECH2.CPP new file mode 100644 index 00000000..58b260b8 --- /dev/null +++ b/restoration/source410/BT/MECH2.CPP @@ -0,0 +1,476 @@ +//===========================================================================// +// File: mech2.cpp // +// Project: BattleTech Brick: Entity Manager // +// Contents: Mech gait animation -- the transition machine // +//---------------------------------------------------------------------------// +// Date Who Modification // +// -------- --- ---------------------------------------------------------- // +// // +//---------------------------------------------------------------------------// +// Copyright (C) 1995, Virtual World Entertainment, Inc. // +// All Rights reserved worldwide // +// This unpublished sourcecode is PROPRIETARY and CONFIDENTIAL // +//===========================================================================// + +#include +#pragma hdrstop + +#if !defined(MECH_HPP) +# include +#endif + +#if !defined(MECHMPPR_HPP) +# include +#endif + +// +//############################################################################# +// A mech walks on two parallel clip channels. +// +// The LEG channel is the locally-simulated gait. Its transitions read the +// LIVE commanded speed out of the controls mapper, so it responds to the +// stick the instant it moves. +// +// The BODY channel is the displayed motion, and the distance IT advances is +// what carries the mech forward. Its transitions read bodyTargetSpeed -- +// a snapshot -- which is what lets a dead-reckoned or networked mech walk +// properly with no controls mapper of its own. +// +// Both channels run the same state machine over the same clips; only the +// speed they consult differs. That is the whole reason the two ClipFinished +// functions below are near-twins rather than one shared routine, and the +// symmetry is load-bearing: where the binary's two jump tables agree, a +// disagreement in this file is a bug. +// +// Each clip is ONE STRIDE, which is why every state is handed. A walk is +// Right, Left, Right, ... and each entry to and exit from a cycle has its own +// handed pair so the mech always leaves on the correct foot. +// +// The machine only ever runs at END OF CLIP. SequenceController::Advance +// calls the channel's finished callback, which picks the next state, re-arms +// the channel, and spends the leftover time in the new clip -- returning the +// distance that leftover covered so Advance can fold it in. Getting that +// contract wrong double-counts the mech's forward motion. +//############################################################################# +// + +// +//############################################################################# +// @004a7fc4 -- bind the leg channel to a state's clip and record the state. +//############################################################################# +// +void + Mech::SetLegAnimation(int state) +{ + Check(this); + Verify(state >= 0 && state < AnimationCount); + + legAnimation.SelectSequence( + animationClips[state], + (void *)Mech::LegClipFinished, + 0, + 0); + legStateAlarm.SetLevel((unsigned)state); +} + +// +//############################################################################# +// @004a800c -- the body channel's equivalent. +// +// This one also drives the animation StateIndicators, which is how the audio +// subsystem's watchers learn a gait changed. Guarded to the constructed +// range so an out-of-range clip cannot trip StateIndicator's own Verify. +//############################################################################# +// +void + Mech::SetBodyAnimation(int state) +{ + Check(this); + Verify(state >= 0 && state < AnimationCount); + + bodyAnimation.SelectSequence( + animationClips[state], + (void *)Mech::BodyClipFinished, + 0, + 0); + bodyStateAlarm.SetLevel((unsigned)state); + + animationState.SetState(state); + replicantAnimationState.SetState(state); +} + +// +//############################################################################# +// The shared tails (@0x4a6a06 leg / @0x4a6e66 body) every handler ends in: +// bind the next state, then spend the carryover inside it. +//############################################################################# +// +Scalar + Mech::LegTransition(int next_state, Scalar advance_time, int move_joints) +{ + Check(this); + + SetLegAnimation(next_state); + return legAnimation.Advance(advance_time, move_joints); +} + +Scalar + Mech::BodyTransition(int next_state, Scalar advance_time, int move_joints) +{ + Check(this); + + SetBodyAnimation(next_state); + return bodyAnimation.Advance(advance_time, move_joints); +} + +// +//############################################################################# +// @004a6928 -- the LEG channel's end-of-clip machine (jump table @0x4a69aa). +// +// Reads the live commanded speed from the controls mapper. A mech with no +// mapper reads zero and simply idles, which is the correct behaviour for a +// replicant. +//############################################################################# +// +Scalar + Mech::LegClipFinished( + Mech *mech, + unsigned /* callback_arg */, + Scalar carryover, + int move_joints + ) +{ + Check(mech); + + // + // The binary reads subsystemArray[0] -- the roster's controls-mapper slot. + // A mech without one (a replicant) reads zero and idles, which is right. + // + Scalar + demand = 0.0f; + if (mech->subsystemArray != NULL && mech->subsystemArray[0] != NULL) + { + demand = + ((MechControlsMapper *)mech->subsystemArray[0])->GetSpeedDemand(); + } + + Scalar + cycle_rate = mech->forwardCycleRate, + time_scale = mech->globalTimeScale, + cycle = mech->legCycleSpeed, + tail_time = carryover * time_scale; + + switch (mech->legStateAlarm.GetLevel()) + { + // + // Standing and the idle group -- nothing to transition to. + // + case 0: case 1: case 22: case 23: case 24: case 25: case 26: case 27: + return 0.0f; + + case 2: + mech->legStateAlarm.SetLevel(1); + return 0.0f; + + // + // The transition-END clips: having arrived, fall back to standing. + // + case 3: case 4: case 8: case 9: case 20: case 21: + case 28: case 29: case 30: case 31: case 32: + mech->legStateAlarm.SetLevel(0); + return 0.0f; + + // + // Walking, right foot down (@0x4a6aad). Three ways out: stop if the + // demand has fallen below the "moving at all" threshold, step up toward + // the run cycle if it is over the walk cap, otherwise take the next + // stride on the other foot. + // + // The stop and step-up tests each check the DEMAND and the CURRENT CYCLE + // SPEED slewed by one carryover -- so a momentary flick of the stick + // cannot yank the mech out of a stride it has already committed to. + // + case 5: case 6: case 14: + if ( + demand < mech->standSpeed && + (cycle - cycle_rate * carryover) < mech->standSpeed + ) + { + return mech->LegTransition(9, tail_time, move_joints); + } + if ( + demand > mech->walkStrideLength && + (cycle + cycle_rate * carryover) > mech->walkStrideLength + ) + { + return mech->LegTransition(0xb, tail_time, move_joints); + } + return mech->LegTransition( + 7, + carryover * cycle * time_scale / mech->walkStrideLength, + move_joints); + + // + // Walking, left foot down (@0x4a69d6) -- the mirror. + // + case 7: case 15: + if ( + demand < mech->standSpeed && + (cycle - cycle_rate * carryover) < mech->standSpeed + ) + { + return mech->LegTransition(8, tail_time, move_joints); + } + if ( + demand > mech->walkStrideLength && + (cycle + cycle_rate * carryover) > mech->walkStrideLength + ) + { + return mech->LegTransition(0xa, tail_time, move_joints); + } + return mech->LegTransition( + 6, + carryover * cycle * time_scale / mech->walkStrideLength, + move_joints); + + // + // Running / reversing (@0x4a6bdb and @0x4a6b63): drop back to the walk + // cycle when the demand decays, else alternate feet. + // + case 10: case 12: + if ( + demand < mech->reverseSpeedMax && + (cycle - cycle_rate * carryover) < mech->reverseSpeedMax + ) + { + return mech->LegTransition(0xf, tail_time, move_joints); + } + return mech->LegTransition( + 0xd, + carryover * cycle * time_scale / mech->reverseStrideLength, + move_joints); + + case 11: case 13: + if ( + demand < mech->reverseSpeedMax && + (cycle - cycle_rate * carryover) < mech->reverseSpeedMax + ) + { + return mech->LegTransition(0xe, tail_time, move_joints); + } + return mech->LegTransition( + 0xc, + carryover * cycle * time_scale / mech->reverseStrideLength, + move_joints); + + // + // Limping (@0x4a6c17 and @0x4a6cc4). gimpStrideLength is authored + // NEGATIVE, so the cycle time comes out negative and has to be folded + // positive before it can be spent -- the binary does exactly this at + // @0x4a6c6e / @0x4a6d3d. + // + case 16: case 18: + if ( + demand > mech->gimpSpeedMax && + (mech->gimpCycleRate * carryover + cycle) > mech->gimpSpeedMax + ) + { + return mech->LegTransition(0x15, tail_time, move_joints); + } + { + Scalar + cycle_time = carryover * cycle * time_scale / mech->gimpStrideLength; + if (cycle_time <= 0.0f) + { + cycle_time = -cycle_time; + } + return mech->LegTransition(0x13, cycle_time, move_joints); + } + + case 17: case 19: + if ( + demand > mech->gimpSpeedMax && + (mech->gimpCycleRate * carryover + cycle) > mech->gimpSpeedMax + ) + { + return mech->LegTransition(0x14, tail_time, move_joints); + } + { + Scalar + cycle_time = carryover * cycle * time_scale / mech->gimpStrideLength; + if (cycle_time <= 0.0f) + { + cycle_time = -cycle_time; + } + return mech->LegTransition(0x12, cycle_time, move_joints); + } + } + + // + // Falls, crashes and the death clips play out and stop here. + // + return 0.0f; +} + +// +//############################################################################# +// @004a6d8c -- the BODY channel's end-of-clip machine (jump table @0x4a6e0a). +// +// Structurally identical to the leg machine above, reading bodyTargetSpeed +// instead of the live mapper demand. Kept as its own routine because that is +// how the binary has it, and because the two tables are each other's check. +//############################################################################# +// +Scalar + Mech::BodyClipFinished( + Mech *mech, + unsigned /* callback_arg */, + Scalar carryover, + int move_joints + ) +{ + Check(mech); + + Scalar + cycle_rate = mech->forwardCycleRate, + time_scale = mech->globalTimeScale, + cycle = mech->bodyCycleSpeed, + demand = mech->bodyTargetSpeed, + tail_time = carryover * time_scale; + + switch (mech->bodyStateAlarm.GetLevel()) + { + case 0: case 1: case 22: case 23: case 24: case 25: case 26: case 27: + return 0.0f; + + case 2: + mech->bodyStateAlarm.SetLevel(1); + return 0.0f; + + case 3: case 4: case 8: case 9: case 20: case 21: + case 28: case 29: case 30: case 31: case 32: + mech->bodyStateAlarm.SetLevel(0); + return 0.0f; + + // + // Walking, right foot down (@0x4a6f11). + // + case 5: case 6: case 14: + if ( + demand < mech->standSpeed && + (cycle - cycle_rate * carryover) < mech->standSpeed + ) + { + return mech->BodyTransition(9, tail_time, move_joints); + } + if ( + demand > mech->walkStrideLength && + (cycle + cycle_rate * carryover) > mech->walkStrideLength + ) + { + return mech->BodyTransition(0xb, tail_time, move_joints); + } + return mech->BodyTransition( + 7, + carryover * cycle * time_scale / mech->walkStrideLength, + move_joints); + + // + // Walking, left foot down (@0x4a6e36). + // + case 7: case 15: + if ( + demand < mech->standSpeed && + (cycle - cycle_rate * carryover) < mech->standSpeed + ) + { + return mech->BodyTransition(8, tail_time, move_joints); + } + if ( + demand > mech->walkStrideLength && + (cycle + cycle_rate * carryover) > mech->walkStrideLength + ) + { + return mech->BodyTransition(0xa, tail_time, move_joints); + } + return mech->BodyTransition( + 6, + carryover * cycle * time_scale / mech->walkStrideLength, + move_joints); + + // + // Running / reversing (@0x4a7041 and @0x4a6fc7). + // + case 10: case 12: + if ( + demand < mech->reverseSpeedMax && + (cycle - cycle_rate * carryover) < mech->reverseSpeedMax + ) + { + return mech->BodyTransition(0xf, tail_time, move_joints); + } + return mech->BodyTransition( + 0xd, + carryover * cycle * time_scale / mech->reverseStrideLength, + move_joints); + + case 11: case 13: + if ( + demand < mech->reverseSpeedMax && + (cycle - cycle_rate * carryover) < mech->reverseSpeedMax + ) + { + return mech->BodyTransition(0xe, tail_time, move_joints); + } + return mech->BodyTransition( + 0xc, + carryover * cycle * time_scale / mech->reverseStrideLength, + move_joints); + + // + // The reverse cycle (@0x4a707d and @0x4a712c). Note these are the BACK + // gait, not a limp, despite sharing the gimp caps: while the demand stays + // below gimpSpeedMax the cycle alternates 0x12 <-> 0x13, and a forward + // demand leaves through the back-to-stand pair. Reading them as "gimp, + // fall back to standing" makes the body loop stand -> reverse-entry + // forever, which is a slow reverse with a wrong-footed exit. + // + case 16: case 18: + if ( + demand > mech->gimpSpeedMax && + (mech->gimpCycleRate * carryover + cycle) > mech->gimpSpeedMax + ) + { + return mech->BodyTransition(0x15, tail_time, move_joints); + } + { + Scalar + cycle_time = carryover * cycle * time_scale / mech->gimpStrideLength; + if (cycle_time <= 0.0f) + { + cycle_time = -cycle_time; + } + return mech->BodyTransition(0x13, cycle_time, move_joints); + } + + case 17: case 19: + if ( + demand > mech->gimpSpeedMax && + (mech->gimpCycleRate * carryover + cycle) > mech->gimpSpeedMax + ) + { + return mech->BodyTransition(0x14, tail_time, move_joints); + } + { + Scalar + cycle_time = carryover * cycle * time_scale / mech->gimpStrideLength; + if (cycle_time <= 0.0f) + { + cycle_time = -cycle_time; + } + return mech->BodyTransition(0x12, cycle_time, move_joints); + } + } + + return 0.0f; +} diff --git a/restoration/source410/BT/MECH2.NOTES.md b/restoration/source410/BT/MECH2.NOTES.md new file mode 100644 index 00000000..a67485a7 --- /dev/null +++ b/restoration/source410/BT/MECH2.NOTES.md @@ -0,0 +1,120 @@ +# MECH2.CPP — reconstruction notes + +**Status: the TRANSITION MACHINE reconstructed (compile-verified 2026-08-02, +BT 51/51, links clean). The four per-frame `Advance*` entry points and the two +`Gimp*ClipFinished` machines are NOT yet written — see "What is deferred".** + +`mech2.cpp` is the mech's gait: which walk clip is playing, when it changes, +and to what. It sits between the locomotion demand and the clip player: + +``` +Mech::Simulate speed/turn demand + -> Mech::AdvanceLegAnimation DEFERRED -- the per-frame entry point + -> SequenceController::Advance [[SEQCTL]] -- keyframes -> joint writes + -> Mech::LegClipFinished THIS FILE -- end of clip, pick the next + -> Mech::LegTransition THIS FILE -- bind it, spend the leftover +``` + +## The two channels, and why they are near-duplicates + +A mech runs two parallel clip channels over the same state machine and the +same clips. The only difference is which speed the transitions consult: + +| | reads | why | +|---|---|---| +| **LEG** (`legAnimation`, `legStateAlarm`) | the LIVE mapper `GetSpeedDemand()` | responds to the stick immediately | +| **BODY** (`bodyAnimation`, `bodyStateAlarm`) | `bodyTargetSpeed`, a snapshot | lets a dead-reckoned or networked mech walk with no mapper of its own | + +That is why `LegClipFinished` and `BodyClipFinished` are near-twins rather +than one shared routine — it is how the binary has it (two separate jump +tables, @0x4a69aa and @0x4a6e0a). **Keep them twins.** Where the two tables +agree, a divergence in this file is a bug, and that mutual check is worth more +than the duplication costs. + +## Every clip is one stride + +Which is why every state is handed. A walk is Right, Left, Right… and each +entry to and exit from a cycle has its own handed pair so the mech always +leaves a cycle on the correct foot. The `MechAnimationState` enum (now in +[[MECH]]'s header) is **verbatim** from the 0x3c-stride name table at +`.data:0050cfe8` — the table the "Unsupported mech animation" assert indexes — +so the names and their order are the original's, not inferred from behaviour. + +## The commit test, in both machines + +Every walk handler has three exits, and the two that leave the cycle test +**both** the demand and the current cycle speed slewed by one carryover: + +```c +if (demand < standSpeed && (cycle - cycleRate * carryover) < standSpeed) + -> walk-to-stand +if (demand > walkStrideLength && (cycle + cycleRate * carryover) > walkStrideLength) + -> toward the run cycle +else + -> the next stride, other foot +``` + +Requiring both means a momentary flick of the stick cannot yank the mech out +of a stride it has already committed to. Dropping either half of those +conjunctions would give a mech that stutters between gaits on noisy input. + +## Two things that read wrong and are not + +**`gimpStrideLength` is authored NEGATIVE.** The cycle time computed from it +comes out negative and is folded positive before being spent (binary +@0x4a6c6e / @0x4a6d3d). The fold is not defensive coding — remove it and the +limp plays backwards. + +**States 16–19 on the body channel are the REVERSE gait, not a limp**, despite +sharing the `gimpSpeedMax` / `gimpCycleRate` caps. While the demand stays +below the cap the cycle alternates 0x12 ↔ 0x13; a forward demand exits through +the back-to-stand pair. BT411 records having read these as "gimp, not decoded, +fall back to standing" twice, which makes the body loop stand → reverse-entry +forever — a slow reverse with a wrong-footed exit. The reading here is by +structural symmetry with the leg table, where every previously-decoded body +case mirrors its leg twin. + +## What is deferred + +Six of the twelve functions the manifest attributes to this TU: + +| | why it is not here yet | +|---|---| +| `AdvanceLegAnimation` @004a5028 | the per-frame entry points — the next increment | +| `AdvanceBodyAnimation` @004a5678 | | +| `AdvanceBodyAnimationGimp` @004a5bf8 | the airborne/jump-jet flavours | +| `AdvanceLegAnimationGimp` @004a71f4 | | +| `GimpBodyClipFinished` @004a6344 | the limp transition machines, entered from the | +| `GimpLegClipFinished` @004a7970 | top of the two `*ClipFinished` above on gimp level 3/4 | + +The gimp-level branch at the top of both `*ClipFinished` is therefore also +absent: a limping mech currently runs the normal machine. That branch needs a +TU-safe read of the graphic alarm level (BT411 routes it through a +`mechdmg.cpp` bridge to avoid an `AlarmIndicator` ODR split) — worth +reproducing carefully rather than reaching for the alarm directly. + +**Nothing calls any of this yet.** The `Advance*` functions are the entry +points and they are the deferred half, so no gait state is ever selected and a +run behaves exactly as before. Same honest caveat as [[SEQCTL]]: this is a +blocker removed, not a behaviour delivered. + +## Header changes + +`MECH.HPP` gained the enum, the six method declarations, and the channel +state: `legStateAlarm` / `bodyStateAlarm` (`AlarmIndicator` — read the state +with `GetLevel`, the binary's mech+0x3b0 / +0x728 are mirrors of the alarm +level, so no separate int is kept), `legCycleSpeed`, `bodyCycleSpeed`, +`forwardCycleRate`, `gimpCycleRate`, `standSpeed`, `gimpSpeedMax`, +`gimpStrideLength`, `globalTimeScale`, and `animationClips[AnimationCount]`. +41 ints carved from `reservedState`, 191 → 150. + +`walkStrideLength`, `reverseStrideLength`, `reverseSpeedMax` and +`bodyTargetSpeed` were already present from the Phase 5.3 locomotion work and +are reused, not duplicated. + +**Still unsourced: `animationClips[]`.** The array is declared but nothing +fills it. The clip handles come from the mech's model resource, and resolving +them is a prerequisite for the `Advance*` increment — `SetLegAnimation` would +otherwise hand `SelectSequence` a garbage ID. `SelectSequence` tolerates a +missing resource (empty controller, inert playback), so this fails soft rather +than crashing, but it must be wired before the gait can do anything.