The last link is in. AdvanceLegAnimation (@004a5028) and AdvanceBodyAnimation
(@004a5678) reconstructed and wired into Mech::Simulate, and the first live
run put a walking gait on the wire:
before 22 handles, 1 animating (the vehicle root)
after 22 handles, 16 animating, ZERO 2-float records
root 812 poses; six PAIRS at 650/649, 599/596, 563/563,
542/540, 531/529, 434/421; three slow joints at 25
Six left/right pose-count pairs is six joints per leg cycling in alternating
strides -- the symmetry is itself evidence that the handed clip alternation
(Right, Left, Right, each clip one stride) is running correctly. Chain, end
to end, every stage previously verified in isolation and now live together:
mapper demand -> AdvanceLegAnimation (state machine) -> SelectSequence
-> SequenceController::Advance (keyframes -> Joint::SetHinge/SetRotation)
-> BTL4HingeRenderable (5.3.88 matrix transport) -> 12-float flush
-> render bridge applies. Mission drove itself clean, no fault.
RECONSTRUCTED FROM THE RAW DECOMP, NOT THE DONOR -- and the sidecar says why:
BT411's versions carry port-era replicant accommodations (its mapper cell
does not replicate; the binary's does) and a turn-in-place dispatcher it
relocated INTO the leg machine from mech4's master performance. None of that
is 1995 code. Here the leg version reads the mapper unconditionally, exactly
as decompiled, and state 4's ARMING stays where the binary has it -- in
mech4, not yet reconstructed.
THE TWO CHANNELS DIFFER MORE THAN THEIR ClipFinished TWINS DO, all
binary-verified: the leg version has the wind-down block, the turn-in-place
case and the "Standing Not Supported" guard; the body version has none of
those, its case 4 sits in the plain-advance group, and move_joints reaches
every body Advance AND its reset's Reset -- the caller decides whether the
body channel poses joints or only measures stride. Wired accordingly: leg
poses, body measures (move_joints 0), body distance dropped at a seam marked
STAGED -- consuming it as the forward step is IntegrateMotion's job (mech4).
Also faithful: case 0 FALLS THROUGH so a freshly armed clip advances the same
frame it was selected; the plain group's Standing guard is unreachable via
that fall-through and catches direct entry only; each cycle plays its clip at
cycle/stride of the authored rate (a slow walk IS the walk clip played slow);
and the reverse cycle's caps are all negative with the advance ratio folded
positive at the end.
UNSOURCED, named in the sidecar rather than invented: idleStrideScale
(+0x5ac, defaults 1) and runSpeedMax (+0x7a0, the run cycle's upward cap --
LoadLocomotionClips does not set it; defaulted huge so it never binds until
its real writer is found). ForceUpdate(8) is a STAGED no-op pending the
replication emitter.
Still deferred: the airborne Advance* flavours (@004a5bf8/@004a71f4, jump
jets) and the Gimp*ClipFinished limp machines.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
292 lines
15 KiB
Markdown
292 lines
15 KiB
Markdown
# MECH2.CPP — reconstruction notes
|
||
|
||
**Status: THE GAIT IS LIVE (2026-08-03). Transition machine + clip loader +
|
||
the two GROUND `Advance*` entry points reconstructed and wired into
|
||
`Mech::Simulate`; first live run showed 16 of 22 joints animating in paired
|
||
left/right strides. Still deferred: the two AIRBORNE `Advance*` flavours and
|
||
the `Gimp*ClipFinished` limp machines — 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.
|
||
|
||
## The entry points are in — and the legs walk (2026-08-03)
|
||
|
||
`AdvanceLegAnimation` (@004a5028) and `AdvanceBodyAnimation` (@004a5678)
|
||
reconstructed **from the raw decomp, not the BT411 donor** — the donor's
|
||
versions carry port-era replicant accommodations and a turn-in-place
|
||
dispatcher relocated from mech4's master performance, none of which is 1995
|
||
code. In the binary the mapper cell replicates, so the leg version reads the
|
||
mapper unconditionally, and what ARMS state 4 is mech4's dispatcher (not yet
|
||
reconstructed — a mech can hold a turn clip if something else arms it, but
|
||
nothing arms it yet).
|
||
|
||
Wired into `Mech::Simulate`: leg channel with joints, body channel as pure
|
||
measurement (`move_joints` 0). The body's measured distance is dropped for
|
||
now — consuming it as the forward step is `IntegrateMotion`'s job (mech4).
|
||
The seam is marked STAGED at the call site.
|
||
|
||
**Live verification** (arena mission, auto-throttle, `BT_JOINTS=1`):
|
||
|
||
```
|
||
before 22 handles on the wire, 1 animating (the root)
|
||
after 22 handles on the wire, 16 animating, 0 two-float records
|
||
root 812 poses; six PAIRS at 650/649, 599/596, 563/563,
|
||
542/540, 531/529, 434/421; three slow joints at 25
|
||
```
|
||
|
||
Six left/right pairs is six joints per leg cycling in alternating strides —
|
||
the pose-count symmetry is itself evidence the handed clip alternation is
|
||
running correctly. No fault, mission drove itself clean.
|
||
|
||
### Structure notes for the next reader
|
||
|
||
* **The channels differ more than the ClipFinished twins do.** The leg
|
||
version has the wind-down block (cycle decayed → drop to standing), the
|
||
turn-in-place case 4, and the "Standing Not Supported" guard; the body
|
||
version has NONE of those — its case 4 sits in the plain group, and its
|
||
reset passes `move_joints` through to `Reset`. All binary-verified.
|
||
* **Case 0 falls THROUGH.** Arming stand→walk (or stand→reverse) drops into
|
||
the plain-advance group so the new clip advances the same frame. The
|
||
Standing guard inside that group is unreachable via the fall-through
|
||
(arming rewrote the level) — it catches direct entry only.
|
||
* **A slow walk is the walk clip played slow.** Each cycle advances its clip
|
||
at `cycle/strideLength` of the authored rate, with the cycle speed slewed
|
||
toward the demand inside per-gait caps. The reverse cycle's caps are all
|
||
NEGATIVE and its advance ratio is folded positive — the clip is authored
|
||
backward, not played backward.
|
||
* **Two members remain UNSOURCED**: `idleStrideScale` (+0x5ac, defaults 1)
|
||
and `runSpeedMax` (+0x7a0, the run cycle's upward cap — not set by
|
||
LoadLocomotionClips; defaulted huge so it never binds). Finding their real
|
||
writers is open work (model resource? mech3/mech4?).
|
||
* `ForceUpdate(8)` (the leg-state update-record request, binary @004a4c54
|
||
inline) is a STAGED no-op — our replication emitter is not reconstructed.
|