feat(R2-Q4): adapter cutover — SetCycle/PlayAction rehosted on PerformMovement; Fix B / fast-path / stop-anim fallback / G17 gate DELETED

AnimationSequencer becomes a thin shim over the verbatim R2 stack:
SetCycle dispatches the style change (Branch 1) then the motion (Branch
2/3/4) through MotionTableManager.PerformMovement; PlayAction is the
same dispatch (actions rebuild via Branch 3; modifiers are Branch 4
physics-only combine_motion — the AP-73 turn-blend mechanism now runs
for real). CurrentStyle/CurrentMotion/CurrentSpeedMod are read-only
mirrors of MotionState (post-adjust_motion, signed mod). Lazy
initialize_state on first drive (retail lazy-create analog) keeps
undriven sequencers do-nothing.

DELETED legacy inventions (net -648 lines): the adapter fast-path
(replaced by Branch-2 fast re-speed: change_cycle_speed +
subtract/combine_motion), Fix B + IsLocomotionCycleLowByte (replaced by
remove_redundant_links on the pending queue — the retail mechanism the
2026-05-03 cdb trace pointed at), the stop-anim low-byte fallback
(replaced by get_link's reversed-key double-hop — retail's actual
backward-walk settle plays the windup link REVERSED), GetLink/BuildNode/
EnqueueMotionData + the G17 HasVelocity/HasOmega gate (add_motion sets
unconditionally), MultiplyCyclicFramerate + the G13 composite,
PlayAction's insert-before-tail machinery, SCFAST/SCFULL diagnostics.
KEPT at the adapter (register rows): the boundary adjust_motion remap +
locomotion velocity/omega synthesis (AP-75, retire R3-W6/R6), K-fix18
skip-link as post-dispatch RemoveAllLinkAnimations (AP-74, retire
R3-W4 when LeaveGround fires it).

GameWindow queue-drain wiring (the §4 G6 seam): each drained AnimDone
hook → manager.AnimationDone(true) (retail AnimDoneHook::Execute
0x00526c20 → Hook_AnimDone 0x0050fda0 chain), UseTime once per tick
(0x00517d57/0x00517d67); IMotionDoneSink bound to an
ACDREAM_DUMP_MOTION recorder (AD-36 — R3 rebinds to
MotionInterpreter.MotionDone).

Conformance: the 11-scenario pre-cutover trace suite (a6235a36) replays
green with six EXPECTED-DIFF annotations (double-hop walk-run routing,
reversed settle links, Branch-4 physics-only modifiers, Branch-1
style-change links, post-adjust mirrors) — everything unannotated is
byte-identical. Legacy suites repaired by a dedicated agent (fixtures
gain the retail-mandatory StyleDefaults + class-bit-tagged ids;
reflection state-poking replaced with real dispatch; zero category-D
regressions — the suspected cursor bug was a fixture class-bit gap),
plus two vacuously-passing tests fixed. Register: stale IA-4 deleted
(R1-P1 ported the negative-factor swap).

Full suite green: 3,458 passed (374+425+713+1946).

Closes H6, H7, H8, H9, H10-adapter, H16-wiring (r2-port-plan.md §3 Q4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 21:41:27 +02:00
parent cd0289bea2
commit 3b9d9bb6be
6 changed files with 562 additions and 646 deletions

View file

@ -119,19 +119,28 @@ public sealed class AnimationSequencer
{
// ── Public state ─────────────────────────────────────────────────────────
/// <summary>Current style (stance) command.</summary>
public uint CurrentStyle { get; private set; }
/// <summary>Current cyclic motion command.</summary>
public uint CurrentMotion { get; private set; }
/// <summary>
/// Current style (stance) command — R2-Q4: read-only mirror of the
/// retail <see cref="MotionState.Style"/> (MotionState OWNS
/// style/substate/substate_mod; the adapter no longer keeps copies).
/// </summary>
public uint CurrentStyle => _state.Style;
/// <summary>
/// Speed multiplier currently applied to the cyclic tail. Starts at 1.0
/// and is updated by <see cref="SetCycle"/> when the same motion is
/// re-issued with a different speed (which triggers
/// <see cref="MultiplyCyclicFramerate"/> instead of a cycle restart).
/// Current cyclic motion command — mirror of
/// <see cref="MotionState.Substate"/>. NOTE (Q4): this is the
/// POST-adjust_motion substate (WalkBackward reads back as WalkForward
/// with a negative <see cref="CurrentSpeedMod"/>) — retail's interpreted
/// state is post-adjustment.
/// </summary>
public float CurrentSpeedMod { get; private set; } = 1f;
public uint CurrentMotion => _state.Substate;
/// <summary>
/// Speed multiplier of the current substate — mirror of
/// <see cref="MotionState.SubstateMod"/> (signed; see
/// <see cref="CurrentMotion"/>).
/// </summary>
public float CurrentSpeedMod => _state.SubstateMod;
/// <summary>
/// Sequence-wide velocity mirror of the core's <see cref="CSequence.Velocity"/>
@ -221,6 +230,23 @@ public sealed class AnimationSequencer
// accumulators.
private readonly CSequence _core;
// R2-Q4: the verbatim motion-selection stack. CMotionTable resolves
// (style, substate, speed) requests (Q2); MotionState owns
// style/substate/mod + the modifier/action chains (Q1);
// MotionTableManager owns the pending-animation queue + the
// tick-countdown completion machinery (Q3). SetCycle/PlayAction are
// thin shims over MotionTableManager.PerformMovement.
private readonly CMotionTable _table;
private readonly MotionState _state;
private readonly MotionTableManager _manager;
// Retail lazy-create analog (MovementManager::get_minterp →
// enter_default_state, r3-motioninterp-decomp §6g): the manager's
// initialize_state runs on the FIRST SetCycle/PlayAction, not at
// construction, so a sequencer that is never driven stays do-nothing
// (RenderBootstrap invariant).
private bool _initialized;
// Hooks pending dispatch. Accumulated during Advance (via the
// AdapterHookQueue seam below); drained via ConsumePendingHooks.
private readonly List<AnimationHook> _pendingHooks = new();
@ -229,14 +255,6 @@ public sealed class AnimationSequencer
// (retail CSequence::update(Frame*), 0x00525b80) — the old adapter
// accumulator fields are gone with ConsumeRootMotionDelta.
// ── Diagnostics (Commit A 2026-05-03) ───────────────────────────────────
// Throttle clock for the [SCFAST] / [SCFULL] / [SCNULLFALLBACK] log lines
// emitted from SetCycle. Gated on env var ACDREAM_REMOTE_VEL_DIAG=1; reads
// the env var inline rather than caching so a launch can be re-toggled
// without restarting. 0.5s per sequencer instance keeps logs readable
// while still capturing meaningful state changes.
private double _lastSetCycleDiagTime;
// ── Constructor ──────────────────────────────────────────────────────────
/// <summary>
@ -259,6 +277,41 @@ public sealed class AnimationSequencer
_loader = loader;
_core = new CSequence(loader);
_core.HookObj = new AdapterHookQueue(this);
_table = new CMotionTable(motionTable);
_state = new MotionState();
_manager = new MotionTableManager(_table, _state, _core, new ForwardingMotionDoneSink(this));
}
/// <summary>
/// R2-Q4: the entity's <see cref="MotionTableManager"/> — the caller's
/// per-tick drive surface (<see cref="MotionTableManager.AnimationDone"/>
/// per drained AnimDone hook, <see cref="MotionTableManager.UseTime"/>
/// once per tick).
/// </summary>
public MotionTableManager Manager => _manager;
/// <summary>
/// R2-Q4 seam: where <see cref="IMotionDoneSink.MotionDone"/> lands.
/// Null → dropped (diagnostic recorder bound by the host under
/// ACDREAM_DUMP_MOTION); R3 binds MotionInterpreter.MotionDone here
/// (register row: MotionDone observed-not-consumed until R3).
/// </summary>
public Action<uint, bool>? MotionDoneTarget { get; set; }
private sealed class ForwardingMotionDoneSink : IMotionDoneSink
{
private readonly AnimationSequencer _owner;
public ForwardingMotionDoneSink(AnimationSequencer owner) => _owner = owner;
public void MotionDone(uint motion, bool success)
=> _owner.MotionDoneTarget?.Invoke(motion, success);
}
private void EnsureInitialized()
{
if (_initialized)
return;
_initialized = true;
_manager.InitializeState();
}
// ── Public API ───────────────────────────────────────────────────────────
@ -315,10 +368,16 @@ public sealed class AnimationSequencer
/// preserve normal smooth transitions for everything else.</param>
public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false)
{
// ── adjust_motion: remap left→right / backward→forward variants ───
// ACE MotionInterp.cs:394-428. The MotionTable never stores TurnLeft,
// SideStepLeft, or WalkBackward cycles; the client plays the mirror
// animation with a negated speed so it runs backward.
EnsureInitialized();
// ── Q4 boundary normalization (adjust_motion cyclic subset) ───────
// Retail adjusts BEFORE the table sees the motion (CMotionInterp::
// adjust_motion, R3 scope) — the interpreted state on the wire is
// already post-adjustment, so remote-driven callers never pass these.
// GameWindow's LOCAL-player path (UpdatePlayerAnimation) still passes
// raw TurnLeft/SideStepLeft/WalkBackward, so the adapter normalizes
// ONCE at this boundary. DELETED in R3-W6 when the local player
// unifies onto MotionInterpreter (r3-port-plan.md J15).
uint adjustedMotion = motion;
float adjustedSpeed = speedMod;
switch (motion & 0xFFFFu)
@ -337,199 +396,35 @@ public sealed class AnimationSequencer
break;
}
// Fast-path: already playing this exact motion.
//
// Retail (ACE MotionTable.cs:132-139): when motion == current and
// sign(speedMod) matches, DON'T restart the cycle — just rescale the
// in-flight cyclic-tail's framerate via multiply_cyclic_animation_fr.
// This keeps the run/walk loop smooth when a new UpdateMotion arrives
// with a different ForwardSpeed (e.g. when the server broadcasts a
// player's updated RunRate mid-step).
//
// **Sign-flip case (2026-05-02):** when the server sends adjust_motion'd
// backward walk as `WalkForward + speed=-N`, motion stays 0x45000005
// but speedMod sign flips. We MUST do a full cycle restart in that case
// so the new (negative) framerate takes effect; otherwise the cycle
// keeps playing forward with the old positive framerate and the
// observer sees the player walking forward despite the negative speed.
if (CurrentStyle == style && CurrentMotion == motion
&& _core.FirstCyclic != null && _core.Count > 0
&& MathF.Sign(speedMod) == MathF.Sign(CurrentSpeedMod))
{
if (MathF.Abs(speedMod - CurrentSpeedMod) > 1e-4f
&& MathF.Abs(CurrentSpeedMod) > 1e-6f)
{
MultiplyCyclicFramerate(speedMod / CurrentSpeedMod);
CurrentSpeedMod = speedMod;
}
// ── R2-Q4: dispatch through the verbatim motion-selection stack ───
// Style change first (style-class ids route GetObjectSequence
// Branch 1 — exit link + entry hop + target style's default cycle),
// then the motion itself (Branch 2 cycle / 3 action / 4 modifier;
// the Branch-2 fast re-speed path replaces the old adapter
// fast-path, remove_redundant_links replaces Fix B, get_link's
// reversed-key double-hop replaces the stop-anim fallback).
if (style != 0 && style != _state.Style)
_manager.PerformMovement(MotionTableMovement.Interpreted(style, 1f));
// D3 (Commit A 2026-05-03): SCFAST — proves whether the fast-path
// is firing instead of the full rebuild. Throttled to 0.5s per
// instance (re-throttled after A.1 unthrottled experiment).
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
if (nowSec - _lastSetCycleDiagTime > 0.5)
{
System.Console.WriteLine(
$"[SCFAST] motion=0x{motion:X8} speedMod={speedMod:F3} "
+ $"oldSpeedMod={CurrentSpeedMod:F3} "
+ $"qCount={_core.Count} "
+ $"currNodeIsCyclic={ReferenceEquals(_core.CurrAnim, _core.FirstCyclic)}");
_lastSetCycleDiagTime = nowSec;
}
}
return;
}
uint dispatchResult = _manager.PerformMovement(
MotionTableMovement.Interpreted(adjustedMotion, adjustedSpeed));
// Resolve transition link (currentSubstate → adjustedMotion). Pass
// both speeds — GetLink switches lookup branches based on sign.
// CurrentSpeedMod defaults to 1.0 (positive) on a fresh sequencer,
// so a Ready → WalkBackward transition correctly enters GetLink's
// negative-speed (reversed-key) branch.
// K-fix18: when the caller asked to skip the transition link
// (instant-engage cases like Falling on jump start), force
// linkData to null so only the cycle gets enqueued.
MotionData? linkData = (skipTransitionLink || CurrentMotion == 0)
? null
: GetLink(style, CurrentMotion, CurrentSpeedMod, adjustedMotion, adjustedSpeed);
// Stop-anim fallback: dat-authored leg-settle / turn-stop links are
// keyed under the FORWARD/RIGHT variant only. Stopping from
// WalkBackward / SideStepLeft / TurnLeft hits a null linkData and
// would visibly snap to Ready. The settle anim is direction-agnostic
// (legs come to standing the same way regardless of which way you
// were walking), so retry GetLink with the substate's low-byte
// remapped to its forward/right peer.
if (linkData is null && !skipTransitionLink && CurrentMotion != 0)
{
uint substateLow = CurrentMotion & 0xFFu;
uint adjustedSubstate = substateLow switch
{
0x06u => (CurrentMotion & 0xFFFFFF00u) | 0x05u, // WalkBackward → WalkForward
0x10u => (CurrentMotion & 0xFFFFFF00u) | 0x0Fu, // SideStepLeft → SideStepRight
0x0Eu => (CurrentMotion & 0xFFFFFF00u) | 0x0Du, // TurnLeft → TurnRight
_ => CurrentMotion,
};
if (adjustedSubstate != CurrentMotion)
{
linkData = GetLink(style, adjustedSubstate, CurrentSpeedMod, adjustedMotion, adjustedSpeed);
}
}
// Resolve target cycle using the ADJUSTED motion (TurnRight not TurnLeft).
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
_mtable.Cycles.TryGetValue(cycleKey, out var cycleData);
// ── Rebuild path (R1-P5) ───────────────────────────────────────────
// remove_cyclic_anims (0x00524e40): drop the old cyclic tail; keep
// any non-cyclic head that hasn't been played yet — the core snaps
// curr_anim back to the preceding link node (or clears to empty).
_core.RemoveCyclicAnims();
// K-fix18: when the caller asked for instant-engage, ALSO drain
// any in-flight non-cyclic transition frames from the previous
// cycle. Without this, the old RunForward → ??? link would
// continue draining for ~100 ms before the new Falling cycle
// starts, defeating the "skip the link" intent.
// clear_animations (0x00524dc0): full wipe.
// K-fix18 (2026-04-26, register row survives → R3): instant-engage
// cases (Falling on jump start) strip the just-selected transition
// links so the new cycle starts immediately. Post-Q4 expression:
// the retail primitive CPhysicsObj::RemoveLinkAnimations
// (0x0050fe20 → CSequence::remove_all_link_animations) — exactly
// what R3-W4's LeaveGround will call, at which point this flag and
// its GameWindow call sites are deleted. The pending-queue entry's
// tick count intentionally stays (retail's RemoveLinkAnimations
// does not touch the manager queue; the countdown chain absorbs it).
if (skipTransitionLink)
{
_core.ClearAnimations();
}
// Clear sequence-wide physics before the rebuild. Retail's
// GetObjectSequence calls sequence.clear_physics() before each
// add_motion chain (MotionTable.cs L100-L101, L152-L153).
_core.ClearPhysics();
// Enqueue link frames (with adjusted speed for left→right remapping).
if (linkData is { Anims.Count: > 0 })
EnqueueMotionData(linkData, adjustedSpeed);
// Enqueue new cycle.
if (cycleData is { Anims.Count: > 0 })
{
EnqueueMotionData(cycleData, adjustedSpeed);
}
else if (_core.Count == 0)
{
// No cycle and no link — nothing to play; reset fully.
CurrentStyle = style;
CurrentMotion = motion;
return;
}
// Fix B (both old AND new locomotion low-byte, #39 2026-05-06): for
// direct cyclic-locomotion → cyclic-locomotion transitions
// (Walk↔Run on Shift toggle, W↔S direct flip, A↔D, Forward↔Strafe),
// land curr_anim on the new CYCLE, not on the link, and remove the
// just-enqueued link nodes from the list entirely.
//
// Why: the transition link's drain time (~100300 ms at
// Framerate 30 × link runSpeed) gets restarted before it can
// end if the user toggles Shift faster than that. curr_anim
// sits on a fresh link every UM and Advance never reaches
// the cycle. User observes "blips forward in walking
// animation" — what they're seeing is the link's
// interpolation pose, never the new cycle.
//
// Conditional on BOTH old AND new being locomotion cycles to
// avoid regressing the cases where the link IS the right
// animation:
// - Idle (Ready) → any cycle: link is the wind-up pose
// - Falling → Ready: landing animation
// - Ready → Sitting/Crouching: pose-change links
// - Combat substates (attack/parry/ready transitions)
// Commit c06b6c5 (reverted in a2ae2ae) demonstrated that
// unconditionally skipping the link breaks all of these.
//
// Retail reference: cdb live trace 2026-05-03 of a Walk→Run
// direct transition logged
// add_to_queue(45000005, looping=1) walk
// add_to_queue(44000007, looping=1) run
// with truncate_animation_list never firing — i.e. retail
// appends the new cycle directly without a separate link
// enqueue or visible link pose for cyclic→cyclic. Our
// structural mismatch was always enqueueing link+cycle and
// forcing curr onto the link; this fix matches retail's
// observed semantics for the locomotion subset.
//
// Retail mechanism = remove_redundant_links (0x0051bf20, R2 scope,
// gap map "Invented behaviors" note). Ported here as
// remove_all_link_animations (the R1 core's verbatim remove-family
// primitive) — this IS Fix B's outcome for the locomotion subset,
// now expressed through the core instead of hand-rolled queue
// surgery.
bool prevIsLocomotion = IsLocomotionCycleLowByte(CurrentMotion & 0xFFu);
bool newIsLocomotion = IsLocomotionCycleLowByte(motion & 0xFFu);
if (prevIsLocomotion && newIsLocomotion && _core.FirstCyclic is not null)
{
_core.RemoveAllLinkAnimations();
}
// D3 (Commit A 2026-05-03): SCFULL — counterpart to SCFAST. Fires on
// the full-rebuild SetCycle path. Throttled to 0.5s per instance.
// Logs prev CurrentMotion so the line shows the transition directly
// (e.g. "Run → Ready" = cycle just got reset).
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
if (nowSec - _lastSetCycleDiagTime > 0.5)
{
System.Console.WriteLine(
$"[SCFULL] prev=0x{CurrentMotion:X8} -> motion=0x{motion:X8} adjustedMotion=0x{adjustedMotion:X8} "
+ $"speedMod={speedMod:F3} "
+ $"qCount={_core.Count} "
+ $"currNodeIsCyclic={ReferenceEquals(_core.CurrAnim, _core.FirstCyclic)} "
+ $"firstCyclicNull={(_core.FirstCyclic is null)}");
_lastSetCycleDiagTime = nowSec;
}
}
CurrentStyle = style;
CurrentMotion = motion;
CurrentSpeedMod = speedMod;
// Failed dispatch (missing cycle for this style, is_allowed reject):
// retail leaves sequence AND state untouched — no synthesis either.
if (dispatchResult != MotionTableManagerError.Success)
return;
// ── Synthesize CurrentVelocity for locomotion cycles ──────────────
// The Humanoid motion table ships every locomotion MotionData with
@ -637,48 +532,11 @@ public sealed class AnimationSequencer
private const float RunAnimSpeed = 4.0f;
private const float SidestepAnimSpeed = 1.25f;
/// <summary>
/// Scale every cyclic node's framerate by <paramref name="factor"/>,
/// mirroring retail's <c>multiply_cyclic_animation_fr</c> (0x00524940):
/// walks <c>first_cyclic</c> through the tail of the list and scales
/// each node's framerate. The non-cyclic head (link frames) is
/// untouched — those drain at their original framerate, which matches
/// retail: the sequencer "catches up" the transition before applying
/// the new run speed.
///
/// <para>
/// Called from <see cref="SetCycle"/> when the same (style, motion) pair
/// is re-issued with a different speedMod — for instance, when a remote
/// player's ForwardSpeed changes mid-run. Does NOT restart the animation,
/// so footsteps keep planting where they are.
/// </para>
///
/// <para>
/// R1-P5 / gap-map G13: the core's <c>MultiplyCyclicAnimationFramerate</c>
/// touches ONLY node framerates (retail-verbatim). The velocity/omega
/// rescale below is the ADAPTER-level stand-in for retail's
/// <c>change_cycle_speed</c> composite (<c>subtract_motion(oldSpeed)</c>
/// + <c>combine_motion(newSpeed)</c>, algebraically equal to scaling by
/// newSpeed/oldSpeed) — an R2 mechanism this adapter keeps inline until
/// R2 routes speed changes through the real subtract/combine_motion
/// pair. See gap map G13 for the register-row rationale.
/// </para>
/// </summary>
/// <param name="factor">Framerate multiplier (newSpeed / oldSpeed).</param>
public void MultiplyCyclicFramerate(float factor)
{
if (_core.FirstCyclic == null) return;
if (factor < 0f || float.IsNaN(factor) || float.IsInfinity(factor))
return;
_core.MultiplyCyclicAnimationFramerate(factor);
// R2-composite stand-in (G13): retail's change_cycle_speed rescales
// velocity/omega via subtract_motion(old)+combine_motion(new), which
// is algebraically newSpeed/oldSpeed — exactly this factor.
_core.SetVelocity(_core.Velocity * factor);
_core.SetOmega(_core.Omega * factor);
}
// R2-Q4: MultiplyCyclicFramerate DELETED (zero external callers). The
// gap-map-G13 composite stand-in (framerate scale + velocity/omega
// rescale) is retired — same-motion re-speeds now route through the
// verbatim CMotionTable fast re-speed path (change_cycle_speed +
// subtract_motion(old) + combine_motion(new), decomp §5).
/// <summary>
/// Advance the animation by <paramref name="dt"/> seconds and return the
@ -751,150 +609,48 @@ public sealed class AnimationSequencer
// contract — for R6's per-tick wiring.
/// <summary>
/// Play a one-shot action/modifier motion (Jump, emote, attack, etc.)
/// on top of the current cycle. The action frames are inserted in the
/// list immediately before the looping cyclic tail; they drain once
/// and then the cycle resumes naturally.
/// Play a one-shot action / modifier motion (Jump, emote, attack, etc.)
/// on top of the current cycle.
///
/// <para>
/// Retail semantics: actions and modifiers live in
/// <see cref="MotionTable.Modifiers"/> (a separate dict from
/// <see cref="MotionTable.Cycles"/>) keyed by
/// <c>(style &lt;&lt; 16) | (motion &amp; 0xFFFFFF)</c>. A motion like
/// <c>Jump = 0x2500003b</c> is a Modifier (class byte 0x25) not a
/// SubState — feeding it to <see cref="SetCycle"/> silently fails the
/// cycle lookup. Routing through <c>PlayAction</c> instead resolves
/// from the Modifiers table and interleaves the action frames with
/// the ongoing cyclic motion.
/// </para>
///
/// <para>
/// If no entry is found in the Modifiers table for the requested
/// motion, this is a no-op.
/// </para>
///
/// <para>
/// R1-P5: inserts directly on the core's internal list via
/// <see cref="CSequence.AnimList"/> / <see cref="CSequence.FirstCyclicNode"/> /
/// <see cref="CSequence.CurrAnimNode"/> (internal members visible within
/// this assembly). Preserves the exact pre-cutover semantics: action
/// nodes insert ahead of the cyclic tail and play once; if the cursor
/// was sitting on the cyclic tail (or nothing was playing), it jumps to
/// the first inserted action node so the action plays immediately.
/// R2-Q4: a thin shim over
/// <see cref="MotionTableManager.PerformMovement"/> — action-class ids
/// (0x10000000) route <c>GetObjectSequence</c> Branch 3 (rebuild:
/// substate→action link + base cycle re-added, the action tracked on the
/// MotionState action FIFO and popped by the manager's countdown);
/// modifier-class ids (0x20000000) route Branch 4 (PHYSICS-ONLY
/// <c>combine_motion</c> — no animation frames; the pre-Q4
/// insert-before-tail modifier-anim mechanism was an acdream invention,
/// deleted). Unknown ids fail the dispatch and are a no-op, matching the
/// pre-Q4 contract.
/// </para>
/// </summary>
/// <param name="motionCommand">Raw MotionCommand (e.g. 0x2500003b for Jump).</param>
/// <param name="speedMod">Speed multiplier for the action's framerate.</param>
public void PlayAction(uint motionCommand, float speedMod = 1f)
{
// Resolve motion data. The lookup depends on the command's mask class:
//
// - Action (mask 0x10): stored in the Links dict as the transition
// FROM currentSubstate TO the action motion. Matches ACE
// MotionTable.GetObjectSequence @ line 189-207 (CommandMask.Action).
// - Modifier (mask 0x20): stored in the Modifiers dict, keyed by
// (style<<16) | (motion&0xFFFFFF) (or unstyled key). Matches ACE
// @ line 234-242 (CommandMask.Modifier).
//
// Jump (0x2500003B) has BOTH bits set (0x20|0x04|0x01) but ACE treats
// it via the Modifier path. FallDown (0x10000050) / Jumpup (0x1000004B)
// are pure Actions (mask 0x10) and live in Links.
//
// We try Links first (via GetLink, which reproduces ACE's get_link
// fallback chain). If that fails and the motion is a Modifier, fall
// through to the Modifiers dict.
const uint ActionMask = 0x10000000u;
const uint ModifierMask = 0x20000000u;
MotionData? data = null;
if ((motionCommand & ActionMask) != 0 && CurrentMotion != 0)
{
// Action: look up the transition link from current substate → action.
// Action overlays always play forward (positive speeds) — the
// action speed mod is the caller-supplied modifier, not part of
// the substate cycle's direction.
data = GetLink(CurrentStyle, CurrentMotion, /*substateSpeed:*/ 1f, motionCommand, /*speed:*/ 1f);
}
if (data is null && (motionCommand & ModifierMask) != 0)
{
uint styleKey = CurrentStyle << 16;
int keyStyled = (int)(styleKey | (motionCommand & 0xFFFFFFu));
int keyPlain = (int)(motionCommand & 0xFFFFFFu);
if (!_mtable.Modifiers.TryGetValue(keyStyled, out data))
_mtable.Modifiers.TryGetValue(keyPlain, out data);
}
if (data is null || data.Anims.Count == 0)
return;
// Build AnimSequenceNodes from the action's AnimData list. All
// non-looping — they drain once, then the list falls through to
// first_cyclic. Velocity/omega gate matches EnqueueMotionData (G17).
Vector3 vel = data.Flags.HasFlag(MotionDataFlags.HasVelocity)
? data.Velocity * speedMod : Vector3.Zero;
Vector3 omg = data.Flags.HasFlag(MotionDataFlags.HasOmega)
? data.Omega * speedMod : Vector3.Zero;
var newNodes = new List<AnimSequenceNode>(data.Anims.Count);
foreach (var ad in data.Anims)
{
var node = BuildNode(ad, speedMod);
if (node.HasAnim) newNodes.Add(node);
}
if (newNodes.Count == 0) return;
var animList = _core.AnimList;
var firstCyclicNode = _core.FirstCyclicNode;
// Insert before the cyclic tail (so the action plays, then cycle
// resumes). If there's no cyclic tail yet, append at the end.
LinkedListNode<AnimSequenceNode>? firstInserted = null;
if (firstCyclicNode != null)
{
foreach (var n in newNodes)
{
var inserted = animList.AddBefore(firstCyclicNode, n);
firstInserted ??= inserted;
}
}
else
{
foreach (var n in newNodes)
{
var inserted = animList.AddLast(n);
firstInserted ??= inserted;
}
}
if (data.Flags.HasFlag(MotionDataFlags.HasVelocity))
_core.SetVelocity(vel);
if (data.Flags.HasFlag(MotionDataFlags.HasOmega))
_core.SetOmega(omg);
// If we're currently on the cyclic tail (or nothing was playing),
// jump the cursor back to the first newly-inserted action node so
// the action plays immediately instead of after the next cycle wrap.
var currNode = _core.CurrAnimNode;
bool cursorOnCyclic = currNode != null && ReferenceEquals(currNode, firstCyclicNode);
if (cursorOnCyclic || currNode == null)
{
_core.CurrAnimNode = firstInserted;
if (firstInserted != null)
_core.FrameNumber = firstInserted.Value.GetStartingFrame();
}
EnsureInitialized();
_manager.PerformMovement(MotionTableMovement.Interpreted(motionCommand, speedMod));
}
/// <summary>
/// Reset the sequencer to an unplaying state without clearing the
/// motion table reference.
/// motion table reference. R2-Q4: drains the pending-animation queue
/// (exit-world semantics — each entry fires MotionDone(success:false))
/// and zeroes the MotionState so the next SetCycle re-runs
/// initialize_state.
/// </summary>
public void Reset()
{
_manager.HandleExitWorld();
_core.Clear();
_pendingHooks.Clear();
CurrentStyle = 0;
CurrentMotion = 0;
CurrentSpeedMod = 1f;
_state.Style = 0;
_state.Substate = 0;
_state.SubstateMod = 1f;
_state.ClearModifiers();
_state.ClearActions();
_initialized = false;
}
// ── Private helpers ──────────────────────────────────────────────────────
@ -925,150 +681,13 @@ public sealed class AnimationSequencer
private static readonly AnimationDoneHook AnimationDoneSentinel =
new() { Direction = AnimationHookDir.Both };
/// <summary>
/// Look up the transition MotionData for going from <paramref name="substate"/>
/// (current state, played at <paramref name="substateSpeed"/>) to
/// <paramref name="motion"/> (new state, played at <paramref name="speed"/>).
///
/// <para>
/// Port of ACE's MotionTable.get_link (MotionTable.cs:395-426). The lookup
/// path differs by sign of the speeds — the retail/ACE mechanism is two
/// distinct branches:
/// <list type="bullet">
/// <item><b>Both speeds positive</b> (forward → forward, normal case):
/// Look up Links[(style&lt;&lt;16) | substate][motion] — the link FROM
/// substate TO motion. Played forward.</item>
/// <item><b>Either speed negative</b> (any direction reversal —
/// WalkBackward, SideStepLeft, TurnLeft): Look up the REVERSED key
/// Links[(style&lt;&lt;16) | motion][substate] — the link FROM motion TO
/// substate. Played in reverse, this anim visually transitions
/// substate → motion's pose, then the cycle continues from where it
/// left off. Without this branch, Ready→WalkBackward would queue the
/// "start walking forward" link played in reverse, which strands the
/// cursor at the wrong cycle frame and causes the user-visible
/// "left leg twitches forward two times" glitch on the X key.</item>
/// </list>
/// </para>
///
/// DatReaderWriter encodes Links as Dictionary&lt;int, MotionCommandData&gt;
/// where MotionCommandData.MotionData is Dictionary&lt;int, MotionData&gt;.
/// </summary>
private MotionData? GetLink(uint style, uint substate, float substateSpeed, uint motion, float speed)
{
if (speed < 0f || substateSpeed < 0f)
{
// Reversed-direction path: link FROM motion TO substate.
int reversedKey = (int)((style << 16) | (motion & 0xFFFFFFu));
if (_mtable.Links.TryGetValue(reversedKey, out var revLink)
&& revLink.MotionData.TryGetValue((int)substate, out var revResult))
{
return revResult;
}
// R2-Q4: GetLink DELETED - re-homed verbatim as CMotionTable.GetLink
// (Q2, field-validated reversed-key branch preserved; Q0-pins A1).
// Style-defaults fallback per ACE MotionTable.cs:405-409.
if (_mtable.StyleDefaults.TryGetValue(
(DatReaderWriter.Enums.MotionCommand)style, out var defaultMotion))
{
int subKey = (int)((style << 16) | (substate & 0xFFFFFFu));
if (_mtable.Links.TryGetValue(subKey, out var subLink)
&& subLink.MotionData.TryGetValue((int)defaultMotion, out var subResult))
{
return subResult;
}
}
return null;
}
// Forward-direction path: link FROM substate TO motion (the original
// implementation pre-K-fix6).
int outerKey1 = (int)((style << 16) | (substate & 0xFFFFFFu));
if (_mtable.Links.TryGetValue(outerKey1, out var cmd1)
&& cmd1.MotionData.TryGetValue((int)motion, out var result1))
{
return result1;
}
// Fallback: style-level catch-all (ACE line 419-422).
int outerKey2 = (int)(style << 16);
if (_mtable.Links.TryGetValue(outerKey2, out var cmd2)
&& cmd2.MotionData.TryGetValue((int)motion, out var result2))
{
return result2;
}
return null;
}
/// <summary>
/// Build one <see cref="AnimSequenceNode"/> from an <see cref="AnimData"/>,
/// speed-scaled retail-style (only Framerate scales — retail
/// <c>AnimData::operator*</c>, 0x00525d00). Returns a node whose
/// <see cref="AnimSequenceNode.HasAnim"/> may be false (unresolved dat
/// id); callers filter those out.
/// </summary>
private AnimSequenceNode BuildNode(AnimData ad, float speedMod)
{
var scaled = new AnimData
{
AnimId = ad.AnimId,
LowFrame = ad.LowFrame,
HighFrame = ad.HighFrame,
Framerate = ad.Framerate * speedMod,
};
return new AnimSequenceNode(scaled, _loader);
}
/// <summary>
/// Append all AnimData entries from <paramref name="motionData"/> to the
/// core's animation list (retail free function <c>add_motion</c>,
/// 0x005224b0). Each AnimData becomes one <see cref="AnimSequenceNode"/>
/// via <see cref="CSequence.AppendAnimation"/>, which slides
/// <c>first_cyclic</c> to the just-appended node on EVERY call (G10) —
/// so a multi-anim MotionData's loop membership is naturally "only the
/// LAST AnimData loops" once a subsequent MotionData is appended, and a
/// single MotionData with N AnimData entries makes the WHOLE MotionData
/// the cyclic tail as of its last entry.
/// </summary>
/// <param name="motionData">The MotionData to enqueue.</param>
/// <param name="speedMod">Speed multiplier for AnimData framerate scaling.</param>
/// <remarks>
/// The <c>isLooping</c> parameter from the pre-cutover signature is
/// deleted — retail has no per-call loop flag (G10); loop membership is
/// purely a function of append order via <c>first_cyclic</c>.
/// </remarks>
private void EnqueueMotionData(MotionData motionData, float speedMod)
{
// Sequence-wide velocity/omega: retail add_motion (0x005224b0) calls
// set_velocity/set_omega UNCONDITIONALLY (G17 core semantics — a
// MotionData without HasVelocity carries a zero Velocity field, so
// "unconditional replace" and "replace with zero" are the same
// retail call). The adapter keeps today's HasVelocity/HasOmega GATE
// (G17 adapter split) so a transient zero-velocity modifier doesn't
// stomp a running cycle's velocity — this is the documented R2
// stand-in until modifiers route through combine_motion instead of
// add_motion. See gap map G17.
if (motionData.Flags.HasFlag(MotionDataFlags.HasVelocity))
_core.SetVelocity(motionData.Velocity * speedMod);
if (motionData.Flags.HasFlag(MotionDataFlags.HasOmega))
_core.SetOmega(motionData.Omega * speedMod);
foreach (var ad in motionData.Anims)
{
// Speed-scale (retail AnimData::operator*, 0x00525d00 — ONLY
// framerate scales) then hand the scaled AnimData straight to
// append_animation; the core resolves + clamps via
// AnimSequenceNode.SetAnimationId and silently discards nodes
// whose dat id doesn't resolve (mirrors BuildNode's HasAnim
// filter without constructing a throwaway node here).
_core.AppendAnimation(new AnimData
{
AnimId = ad.AnimId,
LowFrame = ad.LowFrame,
HighFrame = ad.HighFrame,
Framerate = ad.Framerate * speedMod,
});
}
}
// R2-Q4: BuildNode + EnqueueMotionData DELETED - MotionData appends are
// owned by CMotionTable.AddMotion (add_motion 0x005224b0, unconditional
// velocity/omega set = G17 core semantics; the adapter's HasVelocity/
// HasOmega gate is retired with them).
/// <summary>
/// Build the per-part blended transform from the current animation frame.
@ -1166,19 +785,10 @@ public sealed class AnimationSequencer
return result;
}
/// <summary>
/// True if the given motion-low-byte names a locomotion cycle —
/// WalkForward (0x05), WalkBackward (0x06), RunForward (0x07),
/// SideStepRight (0x0F), or SideStepLeft (0x10).
/// Used by <see cref="SetCycle"/> to recognise cyclic→cyclic
/// direct transitions and bypass the transition link in that case
/// (retail's observed add_to_queue semantics).
/// </summary>
private static bool IsLocomotionCycleLowByte(uint lowByte)
{
return lowByte == 0x05u || lowByte == 0x06u || lowByte == 0x07u
|| lowByte == 0x0Fu || lowByte == 0x10u;
}
// R2-Q4: IsLocomotionCycleLowByte DELETED with Fix B - the cyclic-to-
// cyclic link-skip is now retail's remove_redundant_links (0x0051bf20,
// MotionTableManager.RemoveRedundantLinks) operating on the pending
// queue, not a locomotion-subset special case in the adapter.
/// <summary>
/// Quaternion slerp matching the retail client's <c>FUN_005360d0</c>