diff --git a/restoration/source410/BT/MECH.CPP b/restoration/source410/BT/MECH.CPP index 6e4165ab..49939d78 100644 --- a/restoration/source410/BT/MECH.CPP +++ b/restoration/source410/BT/MECH.CPP @@ -242,6 +242,13 @@ Mech::Mech( gimpLeftStrideLength = 1.0f; gimpRightStrideLength = 1.0f; gyroRumbleTimer = 0.0f; + idleStrideScale = 1.0f; + runSpeedMax = 1.0e9f; // UNSOURCED cap -- never binds until + // its real source is found + motionEventArmed = 0; + deathAnimationLatched = 0; + legResetLatch = 0; + bodyResetLatch = 0; { int i; for (i = 0; i < AnimationSlotCount; ++i) @@ -1207,6 +1214,24 @@ void } bodyTargetSpeed = speedDemand; + // + //----------------------------------------------------------------------- + // THE GAIT (mech2.cpp). The leg channel poses the skeleton from the live + // demand -- this is what makes the legs actually move; the body channel + // runs as a pure stride measurement (move_joints 0) so the two never + // fight over the same joints. + // + // STAGED SEAM: the binary's caller is IntegrateMotion (mech4 @004ab1c8), + // which also USES the body distance as the mech's forward step and picks + // the airborne flavours by movement mode. Until mech4 lands, the body + // distance is measured-and-dropped and the acceleration model below + // still carries the hull; the two speeds agree because both track the + // same demand. + //----------------------------------------------------------------------- + // + AdvanceLegAnimation(time_slice); + AdvanceBodyAnimation(time_slice, 0); + // //----------------------------------------------------------------------- // Accelerate the actual body speed toward the demand (bounded per frame by diff --git a/restoration/source410/BT/MECH.HPP b/restoration/source410/BT/MECH.HPP index c3083c54..e0318c34 100644 --- a/restoration/source410/BT/MECH.HPP +++ b/restoration/source410/BT/MECH.HPP @@ -403,6 +403,25 @@ int LoadClipSlot(int slot, const char *prefix, const char *suffix); + // + //-------------------------------------------------------------------- + // The per-frame gait entry points (mech2.cpp). Each advances its + // channel's clip by the frame's time slice -- slewing the channel's + // cycle speed toward its demand, driving the state machine at clip + // boundaries through the *ClipFinished callbacks -- and returns the + // cycle DISTANCE covered, which is what locomotion integrates. + // + // The leg version always moves the joints; the body version takes + // move_joints explicitly so the caller can run it as a pure stride + // measurement. (The airborne flavours @004a5bf8/@004a71f4 are a + // later increment.) + //-------------------------------------------------------------------- + // + Scalar + AdvanceLegAnimation(Scalar time_slice); + Scalar + AdvanceBodyAnimation(Scalar time_slice, int move_joints); + // // The respawn heal-and-move (binary @0049fb74): reposition the SAME // entity at the drop-zone origin, kill all motion, clear the death @@ -654,6 +673,45 @@ Scalar gimpRightStrideLength; Scalar gyroRumbleTimer; // binary mech+0x5c4; the // clip loader zeroes it + + // + // Per-frame gait state (the Advance* entry points). + // + // idleStrideScale scales ONLY the idle/transition clip group's + // advance rate (binary +0x5ac); the cycles use their own + // cycle/stride ratio instead. UNSOURCED -- nothing observed sets + // it yet; defaulted 1. + // + // runSpeedMax (+0x7a0) caps the run cycle's upward slew the way + // walkStrideLength caps the walk's. UNSOURCED -- not set by + // LoadLocomotionClips; defaulted huge so it never binds until its + // real source is found. + // + // The two reset latches and the death latch are one-shots the + // fall/reset clip group clears; motionEventName/Armed are cleared + // there too (their consumer is a later TU). + // + Scalar idleStrideScale; + Scalar runSpeedMax; + CString motionEventName; + int motionEventArmed; + int deathAnimationLatched; + int legResetLatch; + int bodyResetLatch; + + // + // STAGED (binary @004a4c54, inline in the original mech.hpp): the + // action-request bits are the update-record request mask -- setting + // bit 3 (mask 8) asks the replication layer to emit the leg-state / + // stability record. Our replication emitter is not reconstructed, + // so the request has nowhere to go yet; the call sites keep the + // binary's shape. + // + void + ForceUpdate(int /*record_mask*/) + { + Check(this); + } AverageOf telemetryFilter[5]; CString resourceNameA; CString resourceNameB; @@ -753,11 +811,13 @@ // reconstructed with named fields. // // - // 51 ints carved out for the gait channel above (2 AlarmIndicators = 4, - // 8 Scalars, animationClips[0x21] = 33, the optional limp set = 6); - // 191 -> 140. + // 60 ints carved out for the gait channel above (2 AlarmIndicators = 4, + // 8 Scalars, animationClips[0x21] = 33, the optional limp set = 6, the + // per-frame gait state = 9 counting CString loosely); 191 -> 131. The + // block is a layout BUDGET, not offset-exact -- MECH-LAYOUT.md holds + // the real offsets. // - int reservedState[140]; + int reservedState[131]; }; #endif diff --git a/restoration/source410/BT/MECH2.CPP b/restoration/source410/BT/MECH2.CPP index 1e51e301..2ee37325 100644 --- a/restoration/source410/BT/MECH2.CPP +++ b/restoration/source410/BT/MECH2.CPP @@ -483,6 +483,453 @@ Scalar return 0.0f; } +// +//############################################################################# +// @004a5028 -- the LEG channel's per-frame update (ground flavour). +// +// Reads the LIVE demand from the controls mapper, arms the death clips off +// the movement mode, slews legCycleSpeed toward the demand inside each +// cycle's caps, and advances the clip -- which is what writes the leg +// joints. Returns the cycle distance covered this frame. +// +// Reconstructed from the RAW decomp rather than the BT411 donor: the donor +// carries port-era replicant accommodations and a relocated turn-in-place +// dispatcher that belong to ITS network model, not to the binary. In the +// binary the trn dispatcher lives in the master performance (mech4), and a +// replicant's mapper cell replicates -- so this function reads the mapper +// unconditionally, exactly as decompiled. +//############################################################################# +// +Scalar + Mech::AdvanceLegAnimation(Scalar time_slice) +{ + Check(this); + + // + // The binary reads the roster's slot 0 with no null check -- a mech + // always has its controls mapper by the time it ticks. + // + MechControlsMapper + *mapper = (MechControlsMapper *)subsystemArray[0]; + Check_Pointer(mapper); + Scalar + demand = mapper->GetSpeedDemand(), + distance = 0.0f; + + // + // One-shot: movement modes 5..8 are the falls/deaths; latch the matching + // crash clip exactly once. + // + if (!deathAnimationLatched) + { + switch (GetSimulationState()) + { + case 5: SetLegAnimation(0x1c); deathAnimationLatched = 1; break; + case 6: SetLegAnimation(0x1d); deathAnimationLatched = 1; break; + case 7: SetLegAnimation(0x1e); deathAnimationLatched = 1; break; + case 8: SetLegAnimation(0x1f); deathAnimationLatched = 1; break; + } + } + + // + // Wind-down: once the cycle speed has decayed to nothing during a + // walk-transition state, drop straight to standing. + // + { + int + state = (int)legStateAlarm.GetLevel(); + if ( + legCycleSpeed <= 0.0f && + (state == 6 || state == 7 || state == 8 || state == 9) + ) + { + legStateAlarm.SetLevel(0); + legResetLatch = 1; + } + } + + switch (legStateAlarm.GetLevel()) + { + case 0: + // + // Standing. A demand above standSpeed begins the walk; a NEGATIVE + // demand backs up; anything in between stays put. Arming a state + // FALLS THROUGH so the new clip advances this same frame. + // + if (standSpeed < demand) + { + SetLegAnimation(5); + } + else + { + distance = 0.0f; + if (demand >= 0.0f) + { + break; + } + SetLegAnimation(0x10); + } + // fall through + + case 2: case 3: case 5: case 8: case 9: case 10: case 0xb: + case 0xe: case 0xf: case 0x10: case 0x11: case 0x14: case 0x15: + case 0x1c: case 0x1d: case 0x1e: case 0x1f: case 0x20: + advance_clip: + // + // The plain-advance group: transitions, falls and deaths play at the + // global rate scaled by the idle/transition stride scale. The + // Standing guard is the binary's own (MECH2.CPP:0xd3) -- unreachable + // through the fall-through above (arming rewrote the level), it + // catches a DIRECT entry with the alarm still at 0. + // + if (legStateAlarm.GetLevel() == 0) + { + Fail("Standing Not Supported"); + } + distance = legAnimation.Advance( + time_slice * globalTimeScale * idleStrideScale, 1); + legCycleSpeed = distance / time_slice; + break; + + case 1: + distance = 0.0f; + break; + + case 4: + // + // Turn-in-place. A demand outside [0, standSpeed] abandons the turn + // -- drop to standing and request the leg-state update record -- + // otherwise the turn clip advances like any transition. (What ARMS + // state 4 is the master performance's dispatcher, mech4 -- not here.) + // + if (standSpeed < demand) + { + legStateAlarm.SetLevel(0); + ForceUpdate(8); + break; + } + distance = 0.0f; + if (demand < 0.0f) + { + legStateAlarm.SetLevel(0); + ForceUpdate(8); + break; + } + goto advance_clip; + + case 6: case 7: + // + // The walk cycle. Slew the cycle speed toward the demand at + // forwardCycleRate: upward capped by the demand then the walk + // stride, downward floored by the demand then standSpeed. The clip + // advances at (cycle / walkStride) of its authored rate -- a slow + // walk IS the walk clip played slow. + // + if (demand > legCycleSpeed) + { + legCycleSpeed += forwardCycleRate * time_slice; + if (legCycleSpeed > demand) + { + legCycleSpeed = demand; + } + if (legCycleSpeed > walkStrideLength) + { + legCycleSpeed = walkStrideLength; + } + } + else if (demand < legCycleSpeed) + { + legCycleSpeed -= forwardCycleRate * time_slice; + if (legCycleSpeed < demand) + { + legCycleSpeed = demand; + } + if (legCycleSpeed < standSpeed) + { + legCycleSpeed = standSpeed; + } + } + distance = legAnimation.Advance( + time_slice * (legCycleSpeed / walkStrideLength) * globalTimeScale, + 1); + break; + + case 0xc: case 0xd: + // + // The run cycle -- same slew, its own caps: up to runSpeedMax, down + // no further than reverseSpeedMax (the drop-out threshold the + // ClipFinished handler tests). + // + if (demand > legCycleSpeed) + { + legCycleSpeed += forwardCycleRate * time_slice; + if (legCycleSpeed > demand) + { + legCycleSpeed = demand; + } + if (legCycleSpeed > runSpeedMax) + { + legCycleSpeed = runSpeedMax; + } + } + else if (demand < legCycleSpeed) + { + legCycleSpeed -= forwardCycleRate * time_slice; + if (legCycleSpeed < demand) + { + legCycleSpeed = demand; + } + if (legCycleSpeed < reverseSpeedMax) + { + legCycleSpeed = reverseSpeedMax; + } + } + distance = legAnimation.Advance( + time_slice * (legCycleSpeed / reverseStrideLength) * globalTimeScale, + 1); + break; + + case 0x12: case 0x13: + // + // The reverse cycle. Everything is NEGATIVE here -- the demand, the + // cycle speed, and both caps (gimpSpeedMax ~ -4, gimpStrideLength + // ~ -20 on the Mad Cat), so "up" slews toward zero and "down" toward + // full reverse, at the reverse's own gimpCycleRate. The advance + // ratio is folded positive: a reverse clip is authored backward, it + // is not played backward. + // + if (demand > legCycleSpeed) + { + legCycleSpeed += gimpCycleRate * time_slice; + if (legCycleSpeed > demand) + { + legCycleSpeed = demand; + } + if (legCycleSpeed > gimpSpeedMax) + { + legCycleSpeed = gimpSpeedMax; + } + } + else if (demand < legCycleSpeed) + { + legCycleSpeed -= gimpCycleRate * time_slice; + if (legCycleSpeed < demand) + { + legCycleSpeed = demand; + } + if (legCycleSpeed < gimpStrideLength) + { + legCycleSpeed = gimpStrideLength; + } + } + { + Scalar + ratio = legCycleSpeed / gimpStrideLength; + if (ratio <= 0.0f) + { + ratio = -ratio; + } + distance = legAnimation.Advance( + ratio * time_slice * globalTimeScale, 1); + } + break; + + case 0x16: case 0x17: case 0x18: case 0x19: case 0x1a: case 0x1b: + // + // The reset group -- limp exits and the four falls. Clear the + // motion-event state and every one-shot, drop to standing, and put + // the skeleton back to its neutral pose. + // + motionEventName = ""; + motionEventArmed = 0; + legResetLatch = 0; + deathAnimationLatched = 0; + legStateAlarm.SetLevel(0); + legAnimation.Reset(1); + break; + + default: + Fail("Unsupported mech animation"); + } + + return distance; +} + +// +//############################################################################# +// @004a5678 -- the BODY channel's per-frame update (ground flavour). +// +// The displayed-motion twin. Differences from the leg version, all +// binary-verified: the demand is bodyTargetSpeed (the snapshot, no mapper +// access); there is NO wind-down block and NO turn-in-place case (state 4 +// sits in the plain group); move_joints arrives as a parameter and reaches +// every Advance AND the reset's Reset call -- so the caller decides whether +// this channel poses the skeleton or only measures the stride. +//############################################################################# +// +Scalar + Mech::AdvanceBodyAnimation(Scalar time_slice, int move_joints) +{ + Check(this); + + Scalar + demand = bodyTargetSpeed, + distance = 0.0f; + + if (!deathAnimationLatched) + { + switch (GetSimulationState()) + { + case 5: SetBodyAnimation(0x1c); deathAnimationLatched = 1; break; + case 6: SetBodyAnimation(0x1d); deathAnimationLatched = 1; break; + case 7: SetBodyAnimation(0x1e); deathAnimationLatched = 1; break; + case 8: SetBodyAnimation(0x1f); deathAnimationLatched = 1; break; + } + } + + switch (bodyStateAlarm.GetLevel()) + { + case 0: + distance = 0.0f; + if (standSpeed < demand) + { + SetBodyAnimation(5); + } + else + { + if (demand >= 0.0f) + { + break; + } + SetBodyAnimation(0x10); + } + // fall through + + case 2: case 3: case 4: case 5: case 8: case 9: case 10: case 0xb: + case 0xe: case 0xf: case 0x10: case 0x11: case 0x14: case 0x15: + case 0x1c: case 0x1d: case 0x1e: case 0x1f: case 0x20: + distance = bodyAnimation.Advance( + time_slice * globalTimeScale * idleStrideScale, move_joints); + bodyCycleSpeed = distance / time_slice; + break; + + case 1: + distance = 0.0f; + break; + + case 6: case 7: + if (demand > bodyCycleSpeed) + { + bodyCycleSpeed += forwardCycleRate * time_slice; + if (bodyCycleSpeed > demand) + { + bodyCycleSpeed = demand; + } + if (bodyCycleSpeed > walkStrideLength) + { + bodyCycleSpeed = walkStrideLength; + } + } + else if (demand < bodyCycleSpeed) + { + bodyCycleSpeed -= forwardCycleRate * time_slice; + if (bodyCycleSpeed < demand) + { + bodyCycleSpeed = demand; + } + if (bodyCycleSpeed < standSpeed) + { + bodyCycleSpeed = standSpeed; + } + } + distance = bodyAnimation.Advance( + time_slice * (bodyCycleSpeed / walkStrideLength) * globalTimeScale, + move_joints); + break; + + case 0xc: case 0xd: + if (demand > bodyCycleSpeed) + { + bodyCycleSpeed += forwardCycleRate * time_slice; + if (bodyCycleSpeed > demand) + { + bodyCycleSpeed = demand; + } + if (bodyCycleSpeed > runSpeedMax) + { + bodyCycleSpeed = runSpeedMax; + } + } + else if (demand < bodyCycleSpeed) + { + bodyCycleSpeed -= forwardCycleRate * time_slice; + if (bodyCycleSpeed < demand) + { + bodyCycleSpeed = demand; + } + if (bodyCycleSpeed < reverseSpeedMax) + { + bodyCycleSpeed = reverseSpeedMax; + } + } + distance = bodyAnimation.Advance( + time_slice * (bodyCycleSpeed / reverseStrideLength) * globalTimeScale, + move_joints); + break; + + case 0x12: case 0x13: + if (demand > bodyCycleSpeed) + { + bodyCycleSpeed += gimpCycleRate * time_slice; + if (bodyCycleSpeed > demand) + { + bodyCycleSpeed = demand; + } + if (bodyCycleSpeed > gimpSpeedMax) + { + bodyCycleSpeed = gimpSpeedMax; + } + } + else if (demand < bodyCycleSpeed) + { + bodyCycleSpeed -= gimpCycleRate * time_slice; + if (bodyCycleSpeed < demand) + { + bodyCycleSpeed = demand; + } + if (bodyCycleSpeed < gimpStrideLength) + { + bodyCycleSpeed = gimpStrideLength; + } + } + { + Scalar + ratio = bodyCycleSpeed / gimpStrideLength; + if (ratio <= 0.0f) + { + ratio = -ratio; + } + distance = bodyAnimation.Advance( + ratio * time_slice * globalTimeScale, move_joints); + } + break; + + case 0x16: case 0x17: case 0x18: case 0x19: case 0x1a: case 0x1b: + motionEventName = ""; + motionEventArmed = 0; + bodyResetLatch = 0; + deathAnimationLatched = 0; + bodyStateAlarm.SetLevel(0); + bodyAnimation.Reset(move_joints); + break; + + default: + Fail("Unsupported mech animation"); + } + + return distance; +} + // //############################################################################# // @004a7f50 -- prefix + suffix -> the clip's resource ID. diff --git a/restoration/source410/BT/MECH2.NOTES.md b/restoration/source410/BT/MECH2.NOTES.md index 159a26ef..b8208b14 100644 --- a/restoration/source410/BT/MECH2.NOTES.md +++ b/restoration/source410/BT/MECH2.NOTES.md @@ -1,8 +1,10 @@ # 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".** +**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: @@ -235,3 +237,55 @@ 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.