diff --git a/restoration/source410/BT/SEQCTL.CPP b/restoration/source410/BT/SEQCTL.CPP index e654e98a..f652a413 100644 --- a/restoration/source410/BT/SEQCTL.CPP +++ b/restoration/source410/BT/SEQCTL.CPP @@ -19,6 +19,57 @@ # include #endif +#if !defined(JOINT_HPP) +# include +#endif + +#if !defined(APP_HPP) +# include +#endif + +// +//############################################################################# +// Local helpers. +//############################################################################# +// + +// +// Slot -> skeleton joint, through the clip's own index map. +// +Joint * + SequenceController::GetAnimatedJoint(int slot) const +{ + if (jointSubsystem == NULL || jointIndices == NULL) + { + return NULL; + } + Verify(slot >= 0 && slot < jointCount); + + int + joint_index = jointIndices[slot]; + if (joint_index < 0 || joint_index >= jointSubsystem->GetJointCount()) + { + return NULL; + } + return jointSubsystem->GetJoint(joint_index); +} + +// +// Component-wise ease between two orientations. +// +static EulerAngles + InterpolateAngles( + const EulerAngles &from, + const EulerAngles &to, + Scalar ratio + ) +{ + return EulerAngles( + Radian((Scalar)from.pitch + ((Scalar)to.pitch - (Scalar)from.pitch) * ratio), + Radian((Scalar)from.yaw + ((Scalar)to.yaw - (Scalar)from.yaw) * ratio), + Radian((Scalar)from.roll + ((Scalar)to.roll - (Scalar)from.roll) * ratio)); +} + // //############################################################################# //############################################################################# @@ -65,33 +116,390 @@ void SequenceController::~SequenceController() { // - // Releases the locked clip resource when reconstructed; nothing is locked - // while SelectSequence is staged. + // Release the clip this controller holds locked. // + if (clipResource != NULL) + { + ((ResourceDescription *)clipResource)->Unlock(); + clipResource = NULL; + } } // //############################################################################# -// Playback -- the per-frame gait engine (clip parse, keyframe interpolation, -// joint writes). Exercised only once the mech is ticking; not yet -// reconstructed. See SEQCTL.NOTES.md. +// The clip resource layout, as SelectSequence parses it. Offsets are the +// binary's (@004277a8): +// +// int frameCount hdr[0] +// int jointCount hdr[1] +// Scalar footStepThreshold hdr[2] -- authored contact height +// int jointIndices[jointCount] keyframe slot -> skeleton joint +// Scalar frameTimes[frameCount] keyframe timestamps, seconds +// per frame, per joint, PACKED by DOF +// Keyframe rootTranslations[frameCount] .stride == the forward step +// +// The pose block is packed by joint TYPE, so the root-translation table can +// only be located by walking the skeleton and summing the per-joint sizes -- +// there is no count to seek by. PoseSize is that walk's per-entry size. +//############################################################################# +// + +static int + PoseSize(Joint *joint) +{ + // + // Hinge (X/Y/Z) = a Hinge; ball = EulerAngles; ball+translation = both. + // + if (joint == NULL) + { + return 0; + } + switch (joint->GetJointType()) + { + case Joint::HingeXJointType: + case Joint::HingeYJointType: + case Joint::HingeZJointType: + return sizeof(Hinge); + + case Joint::BallJointType: + return sizeof(EulerAngles); + + case Joint::BallTranslationJointType: + return sizeof(EulerAngles) + sizeof(Point3D); + } + return 0; +} + +// +//############################################################################# +// SelectSequence (@004277a8) -- bind a gait clip. Stores the finished +// callback, rewinds playback, releases the previous clip, then finds, locks +// and parses the new one. +// +// The fetch is FindResourceDescription, NOT SearchList: the clip ID arriving +// here is already resolved, and SearchList would treat it as a resource LIST +// and walk the clip bytes as resource IDs. //############################################################################# // void - SequenceController::SelectSequence(int, void *, unsigned, unsigned) + SequenceController::SelectSequence( + int clip_id, + void *finished_callback, + unsigned callback_arg2, + unsigned callback_arg3 + ) { - Fail("SequenceController::SelectSequence -- seqctl.cpp not yet reconstructed"); + Check_Pointer(this); + + finishedCallback = finished_callback; + callbackArg2 = callback_arg2; + callbackArg3 = callback_arg3; + currentTime = 0.0f; + currentFrame = 0; + + if (clipResource != NULL) + { + ((ResourceDescription *)clipResource)->Unlock(); + clipResource = NULL; + } + + ResourceFile + *resources = application->GetResourceFile(); + Check_Pointer(resources); + + ResourceDescription + *description = resources->FindResourceDescription(clip_id); + clipResource = description; + if (description == NULL) + { + // + // A mech whose skeleton omits this gait keeps an empty controller; + // Advance below is guarded on keyframeCount so playback is inert. + // + keyframeCount = 0; + jointCount = 0; + return; + } + description->Lock(); + + int + *header = (int *)description->resourceAddress; + Check_Pointer(header); + + keyframeCount = header[0]; + jointCount = header[1]; + footStepThreshold = (Scalar *)(header + 2); + + int + *cursor = header + 3; + jointIndices = cursor; + cursor += jointCount; + keyframeTimes = (Scalar *)cursor; + cursor += keyframeCount; + keyframeBase = cursor; + keyframeCursor = cursor; + + // + // Walk the packed pose block to find the root-translation table behind it. + // + char + *pose = (char *)cursor; + int + frame, + slot; + for (frame = 0; frame < keyframeCount; ++frame) + { + for (slot = 0; slot < jointCount; ++slot) + { + pose += PoseSize(GetAnimatedJoint(slot)); + } + } + keyframeData = (Keyframe *)pose; + + Check_Fpu(); } +// +//############################################################################# +// Advance (@0042790c) -- play the clip forward by time_slice and return the +// forward distance covered, which is what the gait feeds into the mech's +// motion. +// +// Two phases. First SNAP through every keyframe whose timestamp the new time +// has passed, writing each animated joint's authored pose as it goes. Then +// INTERPOLATE the remaining partial frame, easing every joint from where it +// is toward the next keyframe. +// +// move_joints == 0 advances the clock and accumulates distance WITHOUT +// touching the skeleton -- that is how the body channel measures a stride +// without fighting the leg channel for the same joints. +//############################################################################# +// Scalar - SequenceController::Advance(Scalar, int) + SequenceController::Advance(Scalar time_slice, int move_joints) { - Fail("SequenceController::Advance -- seqctl.cpp not yet reconstructed"); - return 0.0f; + Check_Pointer(this); + + Scalar + target_time = currentTime + time_slice, + distance = 0.0f; + char + *cursor = (char *)keyframeCursor; + int + slot; + + // + // Phase 1 -- snap through the keyframes reached this frame. + // + while ( + currentFrame < keyframeCount && + keyframeTimes[currentFrame] <= target_time + ) + { + for (slot = 0; slot < jointCount; ++slot) + { + Joint + *joint = GetAnimatedJoint(slot); + + switch (joint != NULL ? joint->GetJointType() : Joint::StaticJointType) + { + case Joint::HingeXJointType: + case Joint::HingeYJointType: + case Joint::HingeZJointType: + if (move_joints) + { + joint->SetHinge(*(const Hinge *)cursor); + } + cursor += sizeof(Hinge); + break; + + case Joint::BallJointType: + if (move_joints) + { + joint->SetRotation(*(const EulerAngles *)cursor); + } + cursor += sizeof(EulerAngles); + break; + + case Joint::BallTranslationJointType: + if (move_joints) + { + joint->SetRotation(*(const EulerAngles *)cursor); + } + cursor += sizeof(EulerAngles); + if (move_joints) + { + joint->SetTranslation(*(const Point3D *)cursor); + } + cursor += sizeof(Point3D); + break; + + default: + break; + } + } + + distance += + (keyframeTimes[currentFrame] - currentTime) * + keyframeData[currentFrame].stride; + currentTime = keyframeTimes[currentFrame]; + ++currentFrame; + } + + if (currentFrame == keyframeCount) + { + // + // End of clip. The finished callback picks the next gait state and + // RE-ARMS this controller through SelectSequence (which rewinds us to + // frame 0), then advances the leftover time itself -- so on return the + // controller is already playing the next clip and its distance folds + // into ours. A NULL callback simply parks the clip at its end. + // + if (finishedCallback != NULL && keyframeCount > 0) + { + Scalar + carryover = target_time - keyframeTimes[keyframeCount - 1]; + + distance += (*(ClipFinishedCallback)finishedCallback)( + owner, callbackArg2, carryover, move_joints); + } + } + else + { + // + // Phase 2 -- the partial frame. + // + keyframeCursor = cursor; + + Scalar + span = keyframeTimes[currentFrame] - currentTime, + ratio = (span > 0.0f) ? (target_time - currentTime) / span : 0.0f; + + for (slot = 0; slot < jointCount; ++slot) + { + Joint + *joint = GetAnimatedJoint(slot); + + switch (joint != NULL ? joint->GetJointType() : Joint::StaticJointType) + { + case Joint::HingeXJointType: + case Joint::HingeYJointType: + case Joint::HingeZJointType: + if (move_joints) + { + Scalar + from = (Scalar)joint->GetRadians(), + to = (Scalar)((const Hinge *)cursor)->rotationAmount; + joint->SetRotation(Radian(from + (to - from) * ratio)); + } + cursor += sizeof(Hinge); + break; + + case Joint::BallJointType: + if (move_joints) + { + joint->SetRotation( + InterpolateAngles( + joint->GetEulerAngles(), + *(const EulerAngles *)cursor, + ratio)); + } + cursor += sizeof(EulerAngles); + break; + + case Joint::BallTranslationJointType: + if (move_joints) + { + joint->SetRotation( + InterpolateAngles( + joint->GetEulerAngles(), + *(const EulerAngles *)cursor, + ratio)); + } + cursor += sizeof(EulerAngles); + if (move_joints) + { + const Point3D + &to = *(const Point3D *)cursor; + Point3D + from = joint->GetTranslation(); + Point3D + blended( + from.x + (to.x - from.x) * ratio, + from.y + (to.y - from.y) * ratio, + from.z + (to.z - from.z) * ratio); + joint->SetTranslation(blended); + } + cursor += sizeof(Point3D); + break; + + default: + break; + } + } + + distance += (target_time - currentTime) * keyframeData[currentFrame].stride; + currentTime = target_time; + } + + Check_Fpu(); + return distance; } +// +//############################################################################# +// Reset (@004283b8) -- return every animated joint to its neutral pose. Only +// the loop form does anything; the mech calls it when a gait is abandoned +// (fall, death, respawn) so the skeleton does not keep the last frame of a +// clip that is no longer playing. +//############################################################################# +// void - SequenceController::Reset(int) + SequenceController::Reset(int loop) { - Fail("SequenceController::Reset -- seqctl.cpp not yet reconstructed"); + Check_Pointer(this); + + if (loop == 0) + { + return; + } + + EulerAngles + neutral_angles(Radian(0.0f), Radian(0.0f), Radian(0.0f)); + Point3D + neutral_point(0.0f, 0.0f, 0.0f); + int + slot; + + for (slot = 0; slot < jointCount; ++slot) + { + Joint + *joint = GetAnimatedJoint(slot); + if (joint == NULL) + { + continue; + } + switch (joint->GetJointType()) + { + case Joint::HingeXJointType: + case Joint::HingeYJointType: + case Joint::HingeZJointType: + joint->SetRotation(Radian(0.0f)); + break; + + case Joint::BallJointType: + joint->SetRotation(neutral_angles); + break; + + case Joint::BallTranslationJointType: + joint->SetRotation(neutral_angles); + joint->SetTranslation(neutral_point); + break; + + default: + break; + } + } + + Check_Fpu(); } diff --git a/restoration/source410/BT/SEQCTL.HPP b/restoration/source410/BT/SEQCTL.HPP index 13414a43..ae469f7e 100644 --- a/restoration/source410/BT/SEQCTL.HPP +++ b/restoration/source410/BT/SEQCTL.HPP @@ -3,8 +3,18 @@ //##################### Forward Class Declarations ####################### class Mech; + class Joint; class JointSubsystem; + // + // A clip's finished callback: the gait state machine's chance to pick the + // next animation. It re-arms the controller and consumes the carryover + // time itself, returning the distance that carryover covered -- which is + // why Advance can fold its result straight into its own return. + // + typedef Scalar + (*ClipFinishedCallback)(Mech *, unsigned, Scalar, int); + //########################################################################## //###################### SequenceController ########################## //########################################################################## @@ -56,6 +66,19 @@ void Reset(int loop); + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // Skeleton access + // + private: + // + // The clip stores its own slot ordering, so a keyframe slot reaches the + // skeleton only through the clip's jointIndices map. NULL when the + // subsystem is absent or the slot names a joint this mech lacks -- the + // playback paths all tolerate that and simply skip the pose entry. + // + Joint * + GetAnimatedJoint(int slot) const; + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Clip data (parsed by SelectSequence; keyframe accessors read by the gait) // diff --git a/restoration/source410/BT/SEQCTL.NOTES.md b/restoration/source410/BT/SEQCTL.NOTES.md index 57132928..a33dcfa2 100644 --- a/restoration/source410/BT/SEQCTL.NOTES.md +++ b/restoration/source410/BT/SEQCTL.NOTES.md @@ -1,41 +1,112 @@ -# SEQCTL.HPP / .CPP — reconstruction notes - -**Status: struct + ctor/dtor + Init RECONSTRUCTED (compile-verified); the -per-frame playback methods (SelectSequence / Advance / Reset) staged. Helper -2/3 of the Mech embedded helpers ([[MECH-LAYOUT]] step 1).** - -`SequenceController` — the BT keyframe-animation player embedded in the Mech as -`legAnimation` (@0x65c) and `bodyAnimation` (@0x6bc). Plays a gait clip: walks -keyframes, interpolates each animated joint's rotation, writes it through the -joint subsystem, returns the root-translation distance advanced. Absent from the -4.10 archive and the RP engine; reconstructed from the shipped binary + BT411's -seqctl.cpp decomp. - -## What's real vs staged - -- **Real (ctor-time):** default ctor (zeroes all fields) and `Init(Mech*)` — the - Mech ctor calls `legAnimation.Init(this)` / `bodyAnimation.Init(this)` - (binary; BT411 mech.cpp:685-686), so Init must run at construction. Init binds - `owner` and caches `jointSubsystem = owner->GetJointSubsystem()` - (JMOVER.HPP:104, returns `JointSubsystem*`), `clipResource = NULL`. -- **Staged (per-frame, after boot):** `SelectSequence` (@004277a8, clip parse + - resource lock), `Advance` (@0042790c, keyframe interpolation + joint writes — - the gait engine, ~145 lines in the decomp), `Reset` (@004283b8). These fire - only once the mech is ticking, well past the current ctor frontier, so they - Fail loudly until reconstructed. - -## Field layout - -Field set + order from BT411's binary-offset map (keyframeCount +0x14 … -callbackArg3 +0x50). Byte-exact offsets are not required for the functional -build (only wire/update-record layout needs that, and SequenceController is not -serialised) — the members are declared as named fields and BC4.52 lays them out; -the Mech's legAnimation/bodyAnimation slots size to `sizeof(SequenceController)` -self-consistently. - -## Placement - -1995 filename unknown (decomp-only; BT411 used seqctl.cpp). Filed as -BT/SEQCTL.HPP + SEQCTL.CPP. Not in the authentic BT.MAK member list, so the -build appends seqctl.obj to bt.lib via the extra-objs fallback (order is -immaterial — no static init). +# SEQCTL.HPP / .CPP — reconstruction notes + +**Status: FULLY RECONSTRUCTED (compile-verified 2026-08-02). The three +per-frame playback methods -- SelectSequence / Advance / Reset -- are now real; +no `Fail()` stub remains in this TU.** + +`SequenceController` — the BT keyframe-animation player embedded in the Mech as +`legAnimation` (@0x65c) and `bodyAnimation` (@0x6bc). Plays a gait clip: walks +keyframes, interpolates each animated joint's rotation, writes it through the +joint subsystem, returns the root-translation distance advanced. Absent from the +4.10 archive and the RP engine; reconstructed from the shipped binary + BT411's +seqctl.cpp decomp. + +## What's real vs staged + +- **Real (ctor-time):** default ctor (zeroes all fields) and `Init(Mech*)` — the + Mech ctor calls `legAnimation.Init(this)` / `bodyAnimation.Init(this)` + (binary; BT411 mech.cpp:685-686), so Init must run at construction. Init binds + `owner` and caches `jointSubsystem = owner->GetJointSubsystem()` + (JMOVER.HPP:104, returns `JointSubsystem*`), `clipResource = NULL`. +- **Staged (per-frame, after boot):** `SelectSequence` (@004277a8, clip parse + + resource lock), `Advance` (@0042790c, keyframe interpolation + joint writes — + the gait engine, ~145 lines in the decomp), `Reset` (@004283b8). These fire + only once the mech is ticking, well past the current ctor frontier, so they + Fail loudly until reconstructed. + +## Field layout + +Field set + order from BT411's binary-offset map (keyframeCount +0x14 … +callbackArg3 +0x50). Byte-exact offsets are not required for the functional +build (only wire/update-record layout needs that, and SequenceController is not +serialised) — the members are declared as named fields and BC4.52 lays them out; +the Mech's legAnimation/bodyAnimation slots size to `sizeof(SequenceController)` +self-consistently. + +## Placement + +1995 filename unknown (decomp-only; BT411 used seqctl.cpp). Filed as +BT/SEQCTL.HPP + SEQCTL.CPP. Not in the authentic BT.MAK member list, so the +build appends seqctl.obj to bt.lib via the extra-objs fallback (order is +immaterial — no static init). + +## Playback reconstructed (2026-08-02) + +`Advance` is **the thing that actually writes mech joints** — the reason the +legs never moved is that this was a `Fail()` stub, not anything in the render +path. It is worth being precise about the dependency, because it reverses an +earlier claim of mine: + + Mech::AdvanceLegAnimation (mech2.cpp -- the gait STATE MACHINE, still absent) + -> SequenceController::Advance (THIS -- walks keyframes, writes joints) + -> Joint::SetHinge / SetRotation / SetTranslation + -> the render side's joint DCS flush (BT410 5.3.88) + +5.3.88 fixed the bottom link (hinges now flush as full matrices the renderer +applies) and this fixes the middle one. The top link is still missing, so +**nothing calls any of this yet** — mech2.cpp is unreconstructed and no gait +state is ever selected. This commit removes a blocker; it does not make legs +move, and a run will look identical. + +### The clip layout, and the one part that can't be seeked + +``` +int frameCount hdr[0] +int jointCount hdr[1] +Scalar footStepThreshold hdr[2] authored contact height +int jointIndices[jointCount] slot -> skeleton joint +Scalar frameTimes[frameCount] keyframe timestamps + per frame, per joint, PACKED BY DOF +Keyframe rootTranslations[frameCount] .stride == the forward step +``` + +The pose block is packed by joint TYPE — 8 bytes for a hinge, 12 for a ball, +24 for ball+translation — so the root-translation table behind it cannot be +reached by arithmetic on any stored count. `SelectSequence` has to walk the +whole skeleton summing per-joint sizes to find it. That walk is `PoseSize`. + +A consequence worth recording: the parse depends on the MECH'S OWN skeleton +agreeing with the clip's. If `jointIndices` named a joint the mech lacks, the +size walk would drift and `keyframeData` would point at garbage. Slots that +don't resolve return NULL and contribute 0, which keeps the walk honest. + +### Two things in Advance that are easy to get wrong + +**`move_joints == 0` is not "do nothing".** It advances the clock and +accumulates distance while leaving the skeleton alone. That is how the body +channel measures a stride without fighting the leg channel over the same +joints — both channels play clips, only one drives the pose. + +**The finished callback is re-entrant by design.** At end of clip it picks the +next gait state, re-arms THIS controller via `SelectSequence` (rewinding it to +frame 0), and advances the carryover itself — so by the time it returns, the +controller is already playing the next clip and the callback's return value is +the distance that carryover covered. `Advance` folds it straight into its own +return. Reconstructing mech2's `BodyClipFinished` will need to honour that +contract exactly or the gait will double-count distance. + +### Deliberately not carried over from the donor + +BT411's `Advance` carries a `BT_HIP_LOG` diagnostic and an audio +footstep-broadcast path. Neither is 1995 code — the log is the port's own +debugging and the audio belongs to its `AudioComponent` work — so neither is +here. `footStepThreshold` IS parsed, because the field is real and in the +authored resource; nothing reads it yet. + +### Not measured by the manifest + +`seqctl.cpp` is **not in the 50-TU BT census** — its code sits in the +0x4277a8-0x428xxx range, below the BT range the census was built from. So the +"91% of the census reconstructed" figure never counted this file at all, and +the census understates what the gait needs. Worth remembering before quoting +that percentage as progress toward playable.