Files
TeslaRel410/restoration/source410/BT/MECH2.NOTES.md
T
CydandClaude Fable 5 7c2363d89f BT410 5.3.96: the LIMP is real -- all twelve mech2 functions reconstructed, and the drag leg shows up on the wire
The "gimp" family had two conflicting donor readings -- limp vs jump-jet --
flagged in 5.3.94 rather than guessed at.  The binary settles it: LIMP.  The
mode test in every gimp function is mech+0x40 in {3,4}, the same values the
damage model documents as "limp gait graphic (left 3 / right 4)"; there is no
jump-jet control anywhere in the pod cockpit; and the donor's port routing
("graphicAlarm 3/4") was the same field under a different name.  Its
"run-jump clip" mech3 annotations were the misreading that started the murk.

RECONSTRUCTED, completing all 12 of mech2's census functions:

  GimpLegClipFinished  @004a7970    GimpBodyClipFinished @004a6344
  AdvanceLegAnimationGimp @004a71f4 AdvanceBodyAnimationGimp @004a5bf8
  + the limp branch atop both normal *ClipFinished
  + the limp pick in Simulate (replaces the normal advancers while limping)

HOW A LIMP WORKS, now from the bytes rather than description:

  It replaces ONE stride.  Limping left, the right stride (6) hands off to
  the left limp figure (0x16 -> the self-cycling 0x18) while the other leg
  keeps its normal clips.  The asymmetry IS the limp.

  Both machines CLAMP THEIR DEMAND while in a cycle -- the leg machine
  writes the mapper's own speedDemand cell down to the damaged side's cap
  (new MechControlsMapper::SetSpeedDemand, matching the binary's direct
  mapper+0x128 write), the body machine clamps bodyTargetSpeed, both floor
  at zero.  A limping mech cannot command more than its figure carries, nor
  reverse out of a forward cycle.

  The limp advancers keep states 0x16-0x1b LIVE -- the normal advancers
  treat those as the reset group, which is exactly why the limp flavours
  must be selected while limping or the figure is neutralized mid-cycle.
  No death latch, no wind-down: the movement modes are exclusive.

  ALSO EXPLAINED IN PASSING: Ghidra's 3760-byte FUN_004a6344 -- the census's
  largest function -- is really THREE functions.  The two normal ClipFinished
  callbacks (@004a6928/@004a6d8c) are reached only via data pointers, so the
  decompiler folded them into the gimp-body machine's extent.

All movement-mode reads route through Mech::MovementMode() (mech+0x40 == the
simulation state), which honours a BT_FORCE_LIMP=3|4 dev hook so the gait
could be verified before the damage model's limp hook exists.

VERIFIED, two runs on the rig:

  NO-REGRESSION: the normal mission's speed sequence is BIT-IDENTICAL to
  5.3.95 (7.31972, 26.6726, 22.1601, ...).  The branch costs nothing.

  FORCED LEFT LIMP (new pod_render_limp.conf): the mapper still demands
  26.9; the hull lurches at 10-23.  And on the wire the healthy walk's tight
  pose-count pairs (650/649 ... 434/421) BREAK to a 3x asymmetry -- the
  drag-leg joint at 18 poses against its partner's 55.  The limp is visible
  in the data exactly the way it will be visible on screen.

STILL OPEN in this family: the damage hook (leg zone >= 0.5 -> mode 3/4, a
MECHDMG increment -- nothing sets the mode in real play yet), and
IntegrateMotion's remaining pieces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 13:19:10 -05:00

20 KiB
Raw Blame History

MECH2.CPP — reconstruction notes

Status: ALL TWELVE FUNCTIONS RECONSTRUCTED (2026-08-03) — the gait AND the limp are live-verified. The walk shows 16 of 22 joints in paired strides; the forced limp breaks the pair symmetry exactly as it should (drag-leg joint at 18 poses vs its partner's 55) with the hull lurching at 1023 under a 26.9 demand.

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 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[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-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.

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.

The stride became the speed (2026-08-03)

The STAGED seam in Simulate is half-closed: the body channel's measured distance is now the mech's speed (currentBodySpeed = stride / dt), which is the binary's own relationship (IntegrateMotion @004ab1c8 sets local velocity z = distance/dt). The explicit acceleration model survives only as a [T3] fallback for a model with no gait clips at all.

Two findings made this safe rather than speculative:

The gait slew rate IS the authored acceleration. The binary's ctor sets forwardCycleRate, gimpCycleRate and groundCycleRate all from the model's maxAcceleration (rec+0x44; 30 for the Mad Cat), and airborneCycleRate from superStopAcceleration. So the stride-driven speed slews at exactly the rate the old model used — feel preserved, source authentic. Our ctor now sets forwardCycleRate/gimpCycleRate from the same guarded model read. (This also retires the ctor default of 1.0, which would have taken ~27 s to reach speed.)

Live curve, arena run: first sample 7.32 (stepping off through the stand→walk clip), then 2230 oscillating around the demanded 26.9 — the per-stride speed variation of a real gait, where the old model pinned 26.9022 flat. Cadence in the hull motion is the authentic behaviour.

Still staged from IntegrateMotion: the airborne flavour pick (movementMode 3/4 && +0x580), the dead-reckon latency fold (+0x778/+0x77c), the local-velocity/orientation integration proper, and the turn-in-place dispatcher. One correction queued by the raw decomp: +0x598 is written by a VECTOR op in IntegrateMotion's replicant branch (FUN_00408644 on floats), so motionEventName is likely a Point3D, not a CString — revisit when that branch is reconstructed.

The limp, settled and live (2026-08-03)

The "gimp" family had two conflicting donor readings — limp vs jump-jet. The binary settles it: limp. The mode test in every gimp function is mech+0x40 ∈ {3, 4}, the same values the damage model documents as "limp gait graphic (left 3 / right 4)"; there is no jump-jet control anywhere in the cockpit; and the donor's own port routing (graphic-alarm 3/4) was reading the same field under a different name. The donor's "run-jump clip" annotations in its mech3 member map were its misreading, which is why its mech2 header said "airborne/jump-jet" — recorded so nobody re-litigates it.

How the limp actually works (from @004a7970/@004a6344 + the advancers @004a71f4/@004a5bf8, all four now reconstructed):

  • The limp replaces ONE stride. Limping left, the right stride (6) hands off to the left limp figure (0x16 → the self-cycling 0x18); the other leg's clips keep their normal alternation. That asymmetry is what reads as a limp rather than a different gait.
  • Both machines CLAMP THEIR DEMAND while in a cycle — the leg machine writes the mapper's own speedDemand cell down to the damaged side's cap (hence MechControlsMapper::SetSpeedDemand), the body machine clamps bodyTargetSpeed, and both floor at zero. A limping mech cannot command more speed than its figure carries, nor reverse out of a forward cycle.
  • The limp flavours of Advance* keep states 0x160x1b LIVE (the normal flavours treat them as the reset group — which is precisely why the limp flavours must be selected while limping, or the figure gets neutralized mid-cycle). They carry no death latch and no wind-down: movement modes are exclusive, a limping mech is not falling.
  • All movement-mode reads go through Mech::MovementMode() (mech+0x40 == the simulation state), which also honours the BT_FORCE_LIMP=3|4 dev hook (set once in the ctor) so the machinery can be exercised before the damage model's limp hook exists.

Verification, two runs:

  • No-regression: the normal mission's speed sequence is BIT-IDENTICAL to 5.3.95 (7.31972, 26.6726, 22.1601, …) — the branch costs nothing when not limping.
  • Forced left limp (pod_render_limp.conf): demand stays 26.9, hull lurches at 1023; and on the wire the healthy run's tight pose-count pairs (650/649 … 434/421) break to a 3× asymmetry — the drag-leg joint at 18 poses against its partner's 55. No fault, no "Unsupported mech animation".

Still open in this family: the damage hook itself (leg zone ≥ 0.5 → mode 3/4 — a MECHDMG increment; nothing sets the mode in real play yet), and IntegrateMotion's remaining pieces (orientation/velocity integration, the dead-reckon fold, turn-in-place arming).