BT410 5.3.92: the gait slot map -- the clip array is bigger than the name table, and the "gimp" members are the REVERSE figures

Went to source animationClips[] and found two things: a bug in what 5.3.91
committed, and a naming trap that has already cost BT411 a shipped defect.

THE BUG, MINE.  I sized animationClips[AnimationCount] -- 0x1d, from the enum.
Wrong.  The name table stops at 0x1d but the ARRAY does not: slot 0x20
(mech+0x64c) is the bump/crash clip the mech binds on a hard wall impact.  So
the array is [AnimationSlotCount] = 0x21 and Set*Animation's Verify bounds
against that instead.  Sizing a real array off a name table that stops earlier
reads fine and corrupts whatever sits next door; caught by reading the clip
loader, not by the compiler.

THE SLOT MAP, recovered from LoadLocomotionClips and now written down in full
(suffix, meaning, and which measured constant each clip yields):

   5      swr        stand -> walk        standSpeed
   6/7    wwr/wwl    forward walk CYCLE   walkStrideLength = (s6+s7)/(d6+d7)
   8/9    wsr/wsl    walk -> stand
   10/11  wrr/wrl    walk -> run          reverseSpeedMax
   12/13  rrr/rrl    run CYCLE            reverseStrideLength
   14/15  rwr/rwl    run -> walk
   16/17  sbr/sbl    stand -> back        gimpSpeedMax
   18/19  bbr/bbl    reverse CYCLE        gimpStrideLength (NEGATED here)
   20/21  bsr/bsl    back -> stand
   22/23  wgl/wgr    walk -> limp         gimpLeft/RightSpeedMax
   24/25  ggr/ggl    limp CYCLE           gimpLeft/RightStrideLength
   26/27  gsl/gsr    limp -> stand
   0x20   bmp        bump / crash stagger

THE TRAP: THE "gimp*" MEMBERS ARE THE REVERSE FIGURES, NOT THE LIMP ONES.
gimpSpeedMax and gimpStrideLength are measured from sbr and bbr/bbl -- the
reverse gait.  The real limp has its own gimpLeft*/gimpRight* pair.  This is
the same bad naming that produced the states-16-19 misreading recorded in
5.3.91, and it has now caused the same error twice from two directions.

Also settled: gimpStrideLength's negative sign is applied AT MEASUREMENT, not
authored into the data -- which is where the fold in the transition machines
comes from.  And the limp clips are OPTIONAL: the loader probes for wgl and
leaves hasGimpClips 0 with slots 22-27 unfilled if the model lacks it, so the
deferred gimp branch must check that before routing into the limp machine.

THE ENUM IS NOT THE SLOT MAP, and MECH.HPP now says so at the enum itself.
The names are verbatim from the binary and authoritative AS NAMES, but slot
0x0e takes the run-to-walk clip while the table calls it RightReverseAnimation,
and the forward walk alternates 6/7 rather than the pair the WalkForward names
suggest.  Read the slot map for "what does this play"; read the enum for "what
did the original call this index".

ATTRIBUTION NOTE: the four clip helpers (ResolveAnimationClip @004a7f50,
MeasureClipStride @004a8054, LoadLocomotionClips @004a80d4,
LoadLocomotionClipsExt @004a86c8) are exactly the four addresses the manifest
lists under mech2.cpp that BT411 files under mech3.  The manifest's attribution
comes from the binary's own file tagging, so they belong here -- which also
accounts for all 12 of mech2's functions.

BT 51/51.  Still nothing calls the gait.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-02 08:15:17 -05:00
co-authored by Claude Fable 5
parent d8aea8d871
commit 3629755d90
3 changed files with 218 additions and 126 deletions
+33 -4
View File
@@ -186,9 +186,34 @@
FallLeftAnimation = 0x1a,
FallRightAnimation = 0x1b,
CrashAnimation = 0x1c,
AnimationCount = 0x1d
AnimationCount = 0x1d,
//
// The clip ARRAY is larger than the name table. Slot 0x20 holds the
// bump/crash clip (mech+0x64c) bound on a hard wall impact, so the
// array runs to 0x21 entries even though only 0x1d are named.
//
AnimationSlotCount = 0x21
};
//
// A WARNING ABOUT THE NAMES ABOVE, which are verbatim from the binary's table
// and therefore authoritative as NAMES -- but are NOT a reliable guide to what
// each slot actually plays.
//
// The clip loader's slot assignments (LoadLocomotionClips) disagree with them.
// Slot 0x0e takes the "rwr" run-to-walk clip while the table calls 0x0e
// RightReverseAnimation; the reverse gait actually lives at 0x10-0x15
// (sbr/sbl entry, bbr/bbl cycle, bsr/bsl exit); and the states the gait
// machine alternates between while walking forward are 6 and 7, not the
// pair the "WalkForward" names suggest.
//
// Read the slot map in MECH2.NOTES.md when the question is "what does this
// state play"; read the enum when the question is "what did the original call
// this index". Conflating the two is how BT411 shipped a reverse gait that
// played its coming-to-a-halt frames mid-cycle.
//
//###########################################################################
//############################## Mech ###############################
//###########################################################################
@@ -587,10 +612,14 @@
// 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.
// Clip handle per gait SLOT (binary mech+0x5cc, indexed by state).
//
int animationClips[AnimationCount];
// SIZED 0x21, NOT AnimationCount. The name table stops at 0x1d but
// the array does not: slot 0x20 (mech+0x64c) is the bump/crash clip
// the mech binds on a hard wall impact. Sizing this by the enum
// leaves that slot off the end.
//
int animationClips[AnimationSlotCount];
AverageOf<Scalar> telemetryFilter[5];
CString resourceNameA;
CString resourceNameB;
+6 -2
View File
@@ -63,7 +63,11 @@ void
Mech::SetLegAnimation(int state)
{
Check(this);
Verify(state >= 0 && state < AnimationCount);
//
// Bounded by the SLOT count, not the name count: slot 0x20 is the
// bump/crash clip and is legitimately bound on a wall impact.
//
Verify(state >= 0 && state < AnimationSlotCount);
legAnimation.SelectSequence(
animationClips[state],
@@ -86,7 +90,7 @@ void
Mech::SetBodyAnimation(int state)
{
Check(this);
Verify(state >= 0 && state < AnimationCount);
Verify(state >= 0 && state < AnimationSlotCount);
bodyAnimation.SelectSequence(
animationClips[state],
+179 -120
View File
@@ -1,120 +1,179 @@
# 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 1619 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.
# 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 1619 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.
## The slot map (LoadLocomotionClips) — and why the enum is not it
Recovered from the clip loader. **`animationClips[slot]` semantics, which do
NOT match the name-table enum**, plus what each measured constant is taken
from. Suffixes are the 3-char codes the loader appends to the model's
animation prefix:
| slot | clip | meaning | measures |
|---|---|---|---|
| 5 | `swr` | stand → walk R | `standSpeed` = final-keyframe stride |
| 6 / 7 | `wwr` / `wwl` | the forward walk CYCLE | `walkStrideLength` = (s6+s7)/(d6+d7) |
| 8 / 9 | `wsr` / `wsl` | walk → stand | |
| 10 / 11 | `wrr` / `wrl` | walk → run | `reverseSpeedMax` from slot 10 |
| 12 / 13 | `rrr` / `rrl` | the run CYCLE | `reverseStrideLength` = (s12+s13)/(d12+d13) |
| 14 / 15 | `rwr` / `rwl` | run → walk | |
| 16 / 17 | `sbr` / `sbl` | stand → back (reverse entry) | `gimpSpeedMax` from slot 16 |
| 18 / 19 | `bbr` / `bbl` | the reverse CYCLE | `gimpStrideLength` = ****(s+s)/(d+d) |
| 20 / 21 | `bsr` / `bsl` | back → stand (reverse exit) | |
| 22 / 23 | `wgl` / `wgr` | walk → limp | `gimpLeft/RightSpeedMax` |
| 24 / 25 | `ggr` / `ggl` | the limp CYCLE | `gimpLeft/RightStrideLength` |
| 26 / 27 | `gsl` / `gsr` | limp → stand | |
| **0x20** | `bmp` | bump / crash stagger | — |
Three things fall out of this table that are easy to get wrong:
**The `gimp*`-named members are the REVERSE figures, not the limp ones.** The
names are historical. `gimpSpeedMax` / `gimpStrideLength` are measured from
`sbr` and `bbr`/`bbl` — the reverse gait. The actual limp has its own
`gimpLeft*` / `gimpRight*` pair. This is the same trap as the states-1619
misreading recorded above, from the same bad naming.
**`gimpStrideLength` is negated at the point of measurement** — that is where
the negative sign the transition machines fold comes from, not from the
authored data being odd.
**The limp clips are OPTIONAL.** The loader probes for `wgl`; if the model
lacks it, `hasGimpClips` stays 0 and slots 2227 are never filled. So a
limping mech on a model without limp clips must fall through to the normal
machine — which is, conveniently, exactly what the deferred gimp branch will
have to check.
### Corrected after the fact
`animationClips` was first sized `[AnimationCount]` (0x1d) from the enum. That
is wrong — slot 0x20 is the bump clip, so the array is `[AnimationSlotCount]`
(0x21) and `Set*Animation`'s `Verify` bounds against that. Sizing a real array
off a name table that stops earlier is the sort of thing that reads fine and
corrupts the object next door; caught by reading the loader, not by the
compiler.
### Still to source
Filling the array needs `Mech::ResolveAnimationClip` (@004a7f50) and
`Mech::MeasureClipStride` (@004a8054) plus `LoadLocomotionClips` (@004a80d4)
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.