diff --git a/restoration/source410/BT/MECH.CPP b/restoration/source410/BT/MECH.CPP index 7caf4ce3..6e4165ab 100644 --- a/restoration/source410/BT/MECH.CPP +++ b/restoration/source410/BT/MECH.CPP @@ -219,6 +219,37 @@ Mech::Mech( bodyTargetSpeed = 0.0f; currentBodySpeed = 0.0f; + // + // Gait channel state (mech2.cpp). The measured constants (standSpeed, + // gimpSpeedMax, gimpStrideLength and the four limp figures) get their + // real values in LoadLocomotionClips below; these defaults keep every + // divide in the transition machines finite if a clip set is missing. + // globalTimeScale MUST default to 1 -- zero would silence every clip + // advance. Unfilled clip slots hold NullResourceID, which SelectSequence + // resolves to an empty, inert controller. + // + legCycleSpeed = 0.0f; + bodyCycleSpeed = 0.0f; + forwardCycleRate = 1.0f; + gimpCycleRate = 1.0f; + standSpeed = 1.0f; + gimpSpeedMax = 1.0f; + gimpStrideLength = -1.0f; // the measured value is negative too + globalTimeScale = 1.0f; + hasGimpClips = 0; + gimpLeftSpeedMax = 1.0f; + gimpRightSpeedMax = 1.0f; + gimpLeftStrideLength = 1.0f; + gimpRightStrideLength = 1.0f; + gyroRumbleTimer = 0.0f; + { + int i; + for (i = 0; i < AnimationSlotCount; ++i) + { + animationClips[i] = ResourceDescription::NullResourceID; + } + } + eyepointRotation = EulerAngles::Identity; lookPitch = 0.0f; lookYaw = 0.0f; @@ -529,6 +560,42 @@ Mech::Mech( a = model->lookBackAngle; if (a > -360.0f && a < 360.0f) lookBackAngle = a * RAD_PER_DEG; } + + // + // The gait clip loader -- resolves every animation clip by + // the model's prefix and MEASURES the stride/speed constants + // from the clips themselves, replacing the bring-up defaults + // above. Guarded on the prefix looking like text because + // this ModelResource layout is only partially verified; a + // garbage prefix would just probe nonsense names (soft), but + // the log line makes a layout miss visible. + // + if ( + model->animationPrefix[0] >= 'a' && + model->animationPrefix[0] <= 'z' + ) + { + LoadLocomotionClips(model); + if (getenv("BT_MECH_LOG")) + { + DEBUG_STREAM << "[mech] clips '" + << model->animationPrefix << "': standSpeed=" + << standSpeed << " walkStride=" << walkStrideLength + << " revStride=" << reverseStrideLength + << " revSpeedMax=" << reverseSpeedMax + << " gimpSpeedMax=" << gimpSpeedMax + << " gimpStride=" << gimpStrideLength + << " limpSet=" << hasGimpClips + << endl << flush; + } + } + else if (getenv("BT_MECH_LOG")) + { + DEBUG_STREAM << "[mech] animationPrefix not text (" + << (int)(unsigned char)model->animationPrefix[0] + << ") -- clip loader SKIPPED, layout suspect" + << endl << flush; + } } modelDesc->Unlock(); } diff --git a/restoration/source410/BT/MECH.HPP b/restoration/source410/BT/MECH.HPP index 3369846b..c3083c54 100644 --- a/restoration/source410/BT/MECH.HPP +++ b/restoration/source410/BT/MECH.HPP @@ -385,6 +385,24 @@ Scalar carryover, int move_joints); + // + //-------------------------------------------------------------------- + // The gait clip loader (mech2.cpp). ResolveAnimationClip maps the + // model's animation prefix + a 3-char suffix to a clip resource; + // MeasureClipStride binds a loaded slot and integrates its keyframe + // strides; LoadLocomotionClips fills animationClips[] and measures + // every gait constant from the clips themselves. + //-------------------------------------------------------------------- + // + ResourceDescription::ResourceID * + ResolveAnimationClip(const char *prefix, const char *suffix); + void + MeasureClipStride(int slot, Scalar *total, Scalar *last_key); + void + LoadLocomotionClips(ModelResource *model); + int + LoadClipSlot(int slot, const char *prefix, const char *suffix); + // // The respawn heal-and-move (binary @0049fb74): reposition the SAME // entity at the drop-zone origin, kill all motion, clear the death @@ -620,6 +638,22 @@ // leaves that slot off the end. // int animationClips[AnimationSlotCount]; + + // + // The OPTIONAL limp clip set (slots 22-27). LoadLocomotionClips + // probes for the "wgl" clip; a model without it leaves hasGimpClips 0 + // and those slots unfilled, so the limp machine must never be entered + // for such a mech. (These four measured figures ARE the limp's -- + // unlike gimpSpeedMax/gimpStrideLength above, which despite the names + // are measured from the REVERSE clips. See MECH2.NOTES.md.) + // + int hasGimpClips; + Scalar gimpLeftSpeedMax; + Scalar gimpRightSpeedMax; + Scalar gimpLeftStrideLength; + Scalar gimpRightStrideLength; + Scalar gyroRumbleTimer; // binary mech+0x5c4; the + // clip loader zeroes it AverageOf telemetryFilter[5]; CString resourceNameA; CString resourceNameB; @@ -719,10 +753,11 @@ // reconstructed with named fields. // // - // 41 ints carved out for the gait channel above (2 AlarmIndicators = 4, - // 8 Scalars, animationClips[0x1d] = 29); 191 -> 150. + // 51 ints carved out for the gait channel above (2 AlarmIndicators = 4, + // 8 Scalars, animationClips[0x21] = 33, the optional limp set = 6); + // 191 -> 140. // - int reservedState[150]; + int reservedState[140]; }; #endif diff --git a/restoration/source410/BT/MECH2.CPP b/restoration/source410/BT/MECH2.CPP index abea1fb1..1e51e301 100644 --- a/restoration/source410/BT/MECH2.CPP +++ b/restoration/source410/BT/MECH2.CPP @@ -23,6 +23,10 @@ # include #endif +#if !defined(APP_HPP) +# include +#endif + // //############################################################################# // A mech walks on two parallel clip channels. @@ -478,3 +482,265 @@ Scalar return 0.0f; } + +// +//############################################################################# +// @004a7f50 -- prefix + suffix -> the clip's resource ID. +// +// The clip names are the model's 3-char animation prefix with a 3-char gait +// suffix appended ("mad" + "wwr" = madwwr), resolved by name over the +// animation resources. Returns a pointer to the found description's +// resourceID; NULL when the model has no such clip -- which is a REAL case +// (the limp set is optional), so callers must tolerate it. +//############################################################################# +// +ResourceDescription::ResourceID * + Mech::ResolveAnimationClip(const char *prefix, const char *suffix) +{ + Check(this); + Check_Pointer(prefix); + Check_Pointer(suffix); + + char + clip_name[12]; + + strcpy(clip_name, prefix); + strcat(clip_name, suffix); + + ResourceDescription + *description = application->GetResourceFile()->FindResourceDescription( + clip_name, + ResourceDescription::AnimationResourceType, + ResourceDescription::NullResourceID); + + return (description != NULL) ? &description->resourceID : NULL; +} + +// +//############################################################################# +// @004a8054 -- bind the clip at animationClips[slot] into the leg channel and +// integrate its keyframe strides. Returns (via the out parameters) the total +// cycle distance and the final keyframe time; the loader divides total by +// time to recover a cycle speed. +// +// The callback is NULL on purpose: measurement only ever PARSES the clip +// (SelectSequence), it never plays it, so the finished callback can never +// fire. The binary passes a live pointer here; NULL is behaviourally +// identical and avoids arming a transition machine mid-load. [T3] +//############################################################################# +// +void + Mech::MeasureClipStride(int slot, Scalar *total, Scalar *last_key) +{ + Check(this); + Verify(slot >= 0 && slot < AnimationSlotCount); + + legAnimation.SelectSequence(animationClips[slot], NULL, 0, 0); + + *total = 0.0f; + *last_key = 0.0f; + + int + frame; + for (frame = 0; frame < legAnimation.keyframeCount; ++frame) + { + Scalar + frame_time = legAnimation.keyframeTimes[frame]; + + *total += (frame_time - *last_key) * + legAnimation.keyframeData[frame].stride; + *last_key = frame_time; + } +} + +// +//############################################################################# +// @004a80d4 -- resolve and cache every gait clip, measuring the gait +// constants from the clips themselves as it goes. This is where standSpeed, +// walkStrideLength, reverseSpeedMax, reverseStrideLength, gimpSpeedMax and +// gimpStrideLength actually COME FROM -- they are properties of the authored +// animations, not authored numbers. +// +// Two binary behaviours reproduced deliberately; neither is a transcription +// slip. See the sidecar before "fixing" either: +// +// * The speed caps read keyframeData[keyframeCount] -- one entry PAST the +// last frame (the binary reads 0x690 + 8 + [0x670]*0xc). +// +// * The reverse-cycle stride divides the bbl measurement by STALE data: +// both bbr and bbl are measured into the same pair, so the divide takes +// its second terms from whatever the run cycle left behind. A 1995 +// copy-paste bug, shipped, and therefore reproduced -- the walk and run +// cycles above it show what was obviously intended. +// +// DIVERGENCE FROM THE BINARY, on purpose: the binary dereferences every +// ResolveAnimationClip result unguarded -- a mech whose model lacks a +// MANDATORY clip crashes on load. Here a miss stores NullResourceID (which +// SelectSequence resolves to an empty, inert controller) and the dependent +// measurement is skipped, leaving the bring-up default in place. [T3: keeps +// the current boot alive on models whose clip sets have not been verified; +// revisit once every fleet mech is known-good.] +//############################################################################# +// + +// +// Resolve one slot: store the clip ID or NullResourceID. Returns whether the +// clip exists, so dependent measurements can be skipped on a miss. +// +int + Mech::LoadClipSlot(int slot, const char *prefix, const char *suffix) +{ + ResourceDescription::ResourceID + *clip_ID = ResolveAnimationClip(prefix, suffix); + + animationClips[slot] = + (clip_ID != NULL) ? *clip_ID : ResourceDescription::NullResourceID; + + return clip_ID != NULL; +} + +void + Mech::LoadLocomotionClips(ModelResource *model) +{ + Check(this); + Check_Pointer(model); + + const char + *prefix = model->animationPrefix; + // + // Zero-initialized because the guarded skips below can reach the reverse + // divide with the run pair unmeasured -- a path the (unguarded) binary + // does not have, so the stale-pair reproduction must not become an + // uninitialized read on top of it. + // + Scalar + total_a = 0.0f, last_a = 0.0f, + total_b = 0.0f, last_b = 0.0f; + + gyroRumbleTimer = 0.0f; + + // + // Stand -> walk. standSpeed is the clip's final-entry stride. + // + if (LoadClipSlot(5, prefix, "swr")) + { + legAnimation.SelectSequence(animationClips[5], NULL, 0, 0); + standSpeed = + legAnimation.keyframeData[legAnimation.keyframeCount].stride; + } + + // + // The forward walk cycle: stride = (s6 + s7) / (d6 + d7). + // + if ( + LoadClipSlot(6, prefix, "wwr") && + LoadClipSlot(7, prefix, "wwl") + ) + { + MeasureClipStride(6, &total_a, &last_a); + MeasureClipStride(7, &total_b, &last_b); + walkStrideLength = (total_a + total_b) / (last_a + last_b); + } + + LoadClipSlot(8, prefix, "wsr"); + LoadClipSlot(9, prefix, "wsl"); + + // + // Walk -> run. reverseSpeedMax is measured from wrr the same way + // standSpeed is from swr. + // + if (LoadClipSlot(10, prefix, "wrr")) + { + legAnimation.SelectSequence(animationClips[10], NULL, 0, 0); + reverseSpeedMax = + legAnimation.keyframeData[legAnimation.keyframeCount].stride; + } + LoadClipSlot(11, prefix, "wrl"); + + // + // The run cycle. + // + if ( + LoadClipSlot(12, prefix, "rrr") && + LoadClipSlot(13, prefix, "rrl") + ) + { + MeasureClipStride(12, &total_a, &last_a); + MeasureClipStride(13, &total_b, &last_b); + reverseStrideLength = (total_a + total_b) / (last_a + last_b); + } + + LoadClipSlot(14, prefix, "rwr"); + LoadClipSlot(15, prefix, "rwl"); + + // + // The bump/crash stagger clip, slot 0x20 -- the reason the clip array is + // bigger than the state-name table. + // + LoadClipSlot(0x20, prefix, "bmp"); + + // + // The reverse set. gimpSpeedMax is measured from the entry clip; the + // cycle stride divide below reproduces the binary's stale-pair bug (see + // the header comment) and is negated exactly where the binary negates. + // + if (LoadClipSlot(16, prefix, "sbr")) + { + legAnimation.SelectSequence(animationClips[16], NULL, 0, 0); + gimpSpeedMax = + legAnimation.keyframeData[legAnimation.keyframeCount].stride; + } + LoadClipSlot(17, prefix, "sbl"); + LoadClipSlot(20, prefix, "bsr"); + LoadClipSlot(21, prefix, "bsl"); + + if ( + LoadClipSlot(18, prefix, "bbr") && + LoadClipSlot(19, prefix, "bbl") + ) + { + MeasureClipStride(18, &total_a, &last_a); + MeasureClipStride(19, &total_a, &last_a); // the binary's stale pair: + // total_b/last_b still hold + // the run-cycle figures + gimpStrideLength = (total_a + total_b) / (last_a + last_b); + gimpStrideLength = -gimpStrideLength; + } + + // + // The OPTIONAL limp set. Probe for wgl; a model without it has no limp + // clips at all, and the limp machine must never be entered for it. + // + hasGimpClips = 0; + if (ResolveAnimationClip(prefix, "wgl") != NULL) + { + hasGimpClips = 1; + + if (LoadClipSlot(22, prefix, "wgl")) + { + legAnimation.SelectSequence(animationClips[22], NULL, 0, 0); + gimpLeftSpeedMax = + legAnimation.keyframeData[legAnimation.keyframeCount].stride; + } + if (LoadClipSlot(23, prefix, "wgr")) + { + legAnimation.SelectSequence(animationClips[23], NULL, 0, 0); + gimpRightSpeedMax = + legAnimation.keyframeData[legAnimation.keyframeCount].stride; + } + if (LoadClipSlot(24, prefix, "ggr")) + { + MeasureClipStride(24, &total_a, &last_a); + gimpLeftStrideLength = total_a / last_a; + } + if (LoadClipSlot(25, prefix, "ggl")) + { + MeasureClipStride(25, &total_a, &last_a); + gimpRightStrideLength = total_a / last_a; + } + LoadClipSlot(26, prefix, "gsl"); + LoadClipSlot(27, prefix, "gsr"); + } + + Check_Fpu(); +} diff --git a/restoration/source410/BT/MECH2.NOTES.md b/restoration/source410/BT/MECH2.NOTES.md index c1b4e675..159a26ef 100644 --- a/restoration/source410/BT/MECH2.NOTES.md +++ b/restoration/source410/BT/MECH2.NOTES.md @@ -179,3 +179,59 @@ 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.