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>
238 lines
12 KiB
Markdown
238 lines
12 KiB
Markdown
# 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 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.
|