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>
12 KiB
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:
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 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 cycle plays
backwards. (The sign is applied at MEASUREMENT, not authored into the data —
see the slot map below.)
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[AnimationSlotCount]
(0x21 — see the correction below). 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-16–19
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 22–27 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.
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.