acdream/src/AcDream.Core/Physics/AnimationSequencer.cs
Erik e214acdf23 feat(R3-W4): ground transitions + lifecycle verbatim; K-fix18 DELETED (closes J8, J10, J11-shape, J12, J13, J18, J19)
Core (dedicated agent, independently reviewed): HitGround 0x00528ac0 /
LeaveGround 0x00528b00 verbatim (creature+gravity gates, the
RemoveLinkAnimations seam — K-fix18's retail mechanism — velocity via
GetLeaveGroundVelocity with the autonomous flag, jump-state resets,
apply_current_movement re-sync); enter_default_state 0x00528c80 per A8
(fresh states, InitializeMotionTables seam, sentinel APPENDED without
draining pending_motions — pinned, Initted=1, LeaveGround tail);
Initted gates; the A3 IsThePlayer dual dispatch in
apply_current_movement / ReportExhaustion / SetWeenieObject /
SetPhysicsObject (a remote player routes INTERPRETED — the
ACE-divergence pin); set_hold_run 0x00528b70 + SetHoldKey 0x00528bb0
(XOR guard, None-only-from-Run); adjust_motion creature guard wired
(TS-34 retired); PhysicsBody.LastMoveWasAutonomous +
set_local_velocity(autonomous). Port discovery: retail's
apply_raw_movement 0x005287e0 / apply_interpreted_movement 0x00528600
ARE the already-shipped D6.2a/funnel functions — the dual dispatch
composes them instead of duplicating.

App cutover (orchestrator): the skipTransitionLink flag + both K-fix18
call sites DELETED (AP-74 retired). MotionInterpreter.DefaultSink routes
apply_current_movement's interpreted branch through the REAL funnel
dispatch when a sink is bound — so a remote's LeaveGround engages
Falling via the contact-gated funnel, replacing the forced SetCycle
(J19); the per-remote MotionTableDispatchSink is now PERSISTENT
(EnsureRemoteMotionBindings: DefaultSink + RemoveLinkAnimations +
InitializeMotionTables seams, idempotent from both the UM and
VectorUpdate paths; wire velocity re-applied after LeaveGround so it
stays authoritative). Player: seams bound to the player sequencer; the
controller's grounded→airborne EDGE now fires LeaveGround (jump()
clears OnWalkable and the same frame's transition detection fires it —
retail's order; walk-off-a-ledge gets the momentum fallback + link
strip it never had); the manual jump-block LeaveGround deleted;
LastMoveWasAutonomous set at the controller chokepoint (W6 refines).

Trace S8 re-expressed as the retail mechanism (Falling dispatch +
RemoveAllLinkAnimations = same final state the flag produced). 43 new
lifecycle tests. Registers: TS-34 + AP-74 retired; TS-38, AP-77, AP-78
added. Full suite: 3,665 passed. Live smoke: in-world clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:01:31 +02:00

849 lines
38 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics.Motion;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
/// <summary>
/// Minimal interface for resolving Animation objects by id.
/// Abstracted so the sequencer can be unit-tested without a real DatCollection.
/// </summary>
public interface IAnimationLoader
{
/// <summary>Load an Animation by its dat id, or return null.</summary>
Animation? LoadAnimation(uint id);
}
/// <summary>
/// Production implementation of <see cref="IAnimationLoader"/> backed by
/// a <see cref="DatCollection"/>.
/// </summary>
public sealed class DatCollectionLoader : IAnimationLoader
{
private readonly DatCollection _dats;
public DatCollectionLoader(DatCollection dats) => _dats = dats;
public Animation? LoadAnimation(uint id) => _dats.Get<Animation>(id);
}
// ─────────────────────────────────────────────────────────────────────────────
// AnimationSequencer — adapter rehosted on the verbatim retail CSequence core
// (R1-P5, docs/research/2026-07-02-r1-csequence/r1-gap-map.md §3 P5).
//
// R1-P5 REHOST: the legacy AnimNode/LinkedList<AnimNode> queue machinery is
// gone. All frame-advance, list-surgery, and boundary-math state now lives in
// AcDream.Core.Physics.Motion.CSequence (R1-P1..P4, verbatim retail port —
// see CSequence.cs/AnimSequenceNode.cs/FrameOps.cs). This adapter keeps every
// public member's signature (the API-migration table in the gap map is the
// contract) and re-expresses each one over the core. Invented mechanisms that
// were R2/R3 scope (K-fix18 skipTransitionLink DELETED in R3-W4, Fix B locomotion
// link-skip, stop-anim fallback, GetLink's reversed branch, velocity/omega
// synthesis constants) SURVIVE unchanged at the adapter level per the gap
// map's "Invented behaviors NOT in the gap list" note.
//
// Primary references (docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md):
// append_animation 0x00525510 (§24)
// remove_cyclic_anims 0x00524e40 (§6)
// remove_all_link_animations 0x00524ca0 (§8)
// clear_animations 0x00524dc0 (§4)
// clear_physics 0x00524d50 (§9? see CSequence.cs)
// update / update_internal 0x00525b80 / 0x005255d0 (§21/§22)
// advance_to_next_animation 0x005252b0 (§23)
// multiply_cyclic_animation_fr 0x00524940 (§14)
// FUN_005360d0 — quaternion slerp with dot-product sign-flip (render-side,
// NOT CSequence scope — kept verbatim in this adapter)
// MotionInterp.cs:394-428 (ACE) — adjust_motion: left→right remapping
// (R2/R3 scope; kept verbatim in this adapter per the gap map)
//
// DatReaderWriter types used:
// MotionTable.Links : Dictionary<int, MotionCommandData>
// key = (style << 16) | (fromSubstate & 0xFFFFFF)
// MotionCommandData.MotionData : Dictionary<int, MotionData>
// key = target motion (int cast of MotionCommand)
// MotionData.Anims : List<AnimData>
// MotionData.Velocity / MotionData.Omega : Vector3 (world-space physics)
// MotionData.Flags : MotionDataFlags (HasVelocity=0x01, HasOmega=0x02)
// AnimData.AnimId : QualifiedDataId<Animation>
// Animation.PartFrames : List<AnimationFrame>
// Animation.PosFrames : List<Frame> (root motion, present if Flags & PosFrames)
// Animation.Flags : AnimationFlags (PosFrames = 0x01)
// AnimationFrame.Frames : List<Frame>
// AnimationFrame.Hooks : List<AnimationHook>
// Frame.Origin : Vector3, Frame.Orientation : Quaternion
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Per-part world-local transform produced by <see cref="AnimationSequencer.Advance"/>.
/// Caller (e.g. GameWindow.TickAnimations) consumes this to rebuild MeshRefs.
/// </summary>
public readonly struct PartTransform
{
public readonly Vector3 Origin;
public readonly Quaternion Orientation;
public PartTransform(Vector3 origin, Quaternion orientation)
{
Origin = origin;
Orientation = orientation;
}
}
/// <summary>
/// Full animation playback engine for one entity.
///
/// <para>
/// R1-P5: this is now a thin adapter over <see cref="CSequence"/> — the
/// verbatim retail port of the AC client's <c>CSequence</c> object. The
/// adapter owns caller-facing bookkeeping (CurrentStyle/CurrentMotion/
/// CurrentSpeedMod, dat lookups via Setup/MotionTable, the retail-faithful
/// but still-R2/R3-scope invented mechanisms) while all frame-advance,
/// list-surgery, and boundary math live in the core.
/// </para>
///
/// <para>
/// Usage pattern:
/// <code>
/// var seq = new AnimationSequencer(setup, motionTable, dats);
/// seq.SetCycle(style, motion, speedMod);
/// // each frame:
/// var transforms = seq.Advance(dt, rootMotionFrame); // root motion lands in the Frame
/// var hooks = seq.ConsumePendingHooks(); // fire audio / VFX / damage
/// </code>
/// </para>
/// </summary>
public sealed class AnimationSequencer
{
// ── Public state ─────────────────────────────────────────────────────────
/// <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>
/// 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 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"/>
/// field (retail <c>Sequence::Velocity</c>). Updated each time a
/// MotionData is appended — reflects the MOST RECENT MotionData's
/// velocity × speedMod (retail <c>set_velocity</c> semantics,
/// <c>MotionTable.add_motion</c> L358-L370).
///
/// <para>
/// Crucially this is **not** per-node: while a link animation plays, the
/// surfaced velocity is still the cycle's velocity (the cycle was added
/// last, so SetVelocity's latest call wins). Remote entity dead-reckoning
/// reads this to integrate position without gapping during stance
/// transitions.
/// </para>
/// </summary>
public Vector3 CurrentVelocity => _core.Velocity;
/// <summary>
/// Sequence-wide omega, matching <see cref="CurrentVelocity"/>'s semantics.
/// </summary>
public Vector3 CurrentOmega => _core.Omega;
// Diagnostics
public int QueueCount => _core.Count;
public bool HasCurrentNode => _core.CurrAnim != null;
/// <summary>Test seam (Q4 trace conformance): the verbatim CSequence core.</summary>
internal CSequence Core => _core;
/// <summary>
/// Diagnostic snapshot of the core's <c>curr_anim</c> identity + frame
/// state, for the per-tick CURRNODE log line in
/// <c>GameWindow.TickAnimations</c>. Lets the caller see whether the
/// actual node being read by Advance / BuildBlendedFrame is what
/// SetCycle was supposed to leave it on. AnimRefHash uses
/// object-identity hashing on the Animation reference so different Walk
/// vs Run anim resources can be distinguished even without exposing the
/// full Animation type.
///
/// IsLooping here means "this node IS the cyclic tail" (core structural
/// test: curr == first_cyclic) — retail nodes carry no per-node
/// IsLooping flag (G16); this diagnostic re-derives the same meaning
/// from list structure.
/// </summary>
public (int AnimRefHash, bool IsLooping, double Framerate, int StartFrame, int EndFrame, double FramePosition, int QueueCount) CurrentNodeDiag
{
get
{
var n = _core.CurrAnim;
if (n is null)
return (0, false, 0.0, 0, 0, 0.0, _core.Count);
int hash = n.Anim is null
? 0
: System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(n.Anim);
bool isLooping = ReferenceEquals(_core.CurrAnim, _core.FirstCyclic);
return (hash, isLooping, n.Framerate, n.LowFrame, n.HighFrame, _core.FrameNumber, _core.Count);
}
}
/// <summary>
/// Diagnostic: the AnimRefHash for the FIRST cyclic node in the queue
/// (i.e., what SetCycle is trying to land us on for a locomotion cycle).
/// Compare against <see cref="CurrentNodeDiag"/>'s AnimRefHash to see
/// whether the core's <c>curr_anim</c> is actually pointing at the new
/// cycle or something stale.
/// </summary>
public int FirstCyclicAnimRefHash
{
get
{
var fc = _core.FirstCyclic;
return fc?.Anim is null
? 0
: System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(fc.Anim);
}
}
// ── Private state ────────────────────────────────────────────────────────
private readonly Setup _setup;
private readonly MotionTable _mtable;
private readonly IAnimationLoader _loader;
// R1-P5: the verbatim retail CSequence core. Owns the animation list,
// curr_anim/first_cyclic cursors, frame_number, and Velocity/Omega
// 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();
// R1-P6: root motion flows through the Advance(dt, Frame) overload
// (retail CSequence::update(Frame*), 0x00525b80) — the old adapter
// accumulator fields are gone with ConsumeRootMotionDelta.
// ── Constructor ──────────────────────────────────────────────────────────
/// <summary>
/// Create a sequencer for one entity.
/// </summary>
/// <param name="setup">Entity's Setup dat (for part count / default scale).</param>
/// <param name="motionTable">Loaded MotionTable dat for this entity.</param>
/// <param name="loader">
/// Animation loader. Use <see cref="DatCollectionLoader"/> for production,
/// or inject a test double in unit tests.
/// </param>
public AnimationSequencer(Setup setup, MotionTable motionTable, IAnimationLoader loader)
{
ArgumentNullException.ThrowIfNull(setup);
ArgumentNullException.ThrowIfNull(motionTable);
ArgumentNullException.ThrowIfNull(loader);
_setup = setup;
_mtable = motionTable;
_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 ───────────────────────────────────────────────────────────
// R2-Q5: HasCycle DELETED (caller-free). The missing-cycle hazard it
// guarded ("torso on the ground": legacy SetCycle's unconditional
// cyclic-tail drop) is structurally gone — the verbatim
// GetObjectSequence checks the cycle BEFORE any list surgery and leaves
// the sequence untouched on a miss, so the spawn/UM fallback chains
// (Run→Walk→Ready) that probed it are retired with RemoteMotionSink.
/// <summary>
/// Switch to a new cyclic motion, prepending any transition link frames
/// so the switch is smooth. If the motion table has no link for the
/// (currentStyle, currentMotion) → newMotion transition, the cycle
/// switches immediately.
///
/// <para>
/// Implements <c>adjust_motion</c> (ACE MotionInterp.cs:394-428): the AC
/// MotionTable has NO cycles for TurnLeft, SideStepLeft, or WalkBackward.
/// These are played as their right-side / forward equivalents with a
/// negated framerate so the animation runs in reverse.
/// </para>
/// </summary>
/// <param name="style">MotionCommand style / stance (e.g. NonCombat 0x003D0000).</param>
/// <param name="motion">Target motion command (e.g. WalkForward 0x45000005).</param>
/// <param name="speedMod">Speed multiplier applied to framerates (1.0 = normal).</param>
public void SetCycle(uint style, uint motion, float speedMod = 1f)
{
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)
{
case 0x000E: // TurnLeft → TurnRight (negate speed)
adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du;
adjustedSpeed = -speedMod;
break;
case 0x0010: // SideStepLeft → SideStepRight (negate speed)
adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu;
adjustedSpeed = -speedMod;
break;
case 0x0006: // WalkBackward → WalkForward (negate + BackwardsFactor)
adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u;
adjustedSpeed = -speedMod * 0.65f; // BackwardsFactor from ACE
break;
}
// ── 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));
// Motion via the PerformMovement passthrough (velocity synthesis on
// success — same helper the R2-Q5 funnel sink path uses; the
// adjusted motion + adjusted speed produce the identical synthesis
// result as the original pair).
uint dispatchResult = PerformMovement(
MotionTableMovement.Interpreted(adjustedMotion, adjustedSpeed));
// R3-W4: the K-fix18 skipTransitionLink flag is DELETED — the
// instant-Falling engage is now retail's own mechanism: the entity's
// MotionInterpreter.LeaveGround (0x00528b00) fires the
// RemoveLinkAnimations seam (bound to this sequencer's
// RemoveAllLinkAnimations) when the body leaves the ground.
// Failed dispatch (missing cycle for this style, is_allowed reject):
// retail leaves sequence AND state untouched — no synthesis either.
// (Velocity synthesis already ran inside PerformMovement on success.)
if (dispatchResult != MotionTableManagerError.Success)
return;
// ── Synthesize CurrentOmega for turn cycles ───────────────────────
// Same story as velocity synthesis above: Humanoid turn MotionData
// often ships without HasOmega. Retail clients turn the body via
// the baked omega, but if the dat is silent we fall back to the
// retail turn-rate constant. Decompile references:
// FUN_00529210 apply_current_movement (writes Omega)
// chunk_00520000.c TurnRate globals (~π/2 rad/s for speed=1)
// The ACE port uses `omega.z = ±(π/2) × turnSpeed` for right/left
// turns (holtburger confirms the same via motion_resolution.rs).
if (_core.Omega.LengthSquared() < 1e-9f)
{
float zomega = 0f;
uint low = motion & 0xFFu;
switch (low)
{
case 0x0D: // TurnRight — clockwise from above = -Z in right-handed.
zomega = -(MathF.PI / 2f) * adjustedSpeed;
break;
case 0x0E: // TurnLeft — counter-clockwise = +Z.
// adjust_motion above ALREADY remapped 0x0E → 0x0D
// with adjustedSpeed = -speedMod, so the same
// formula as 0x0D applied to the negated speed
// produces the correct +Z (CCW) result. Using a
// different sign here would double-negate and
// animate a left turn as a right turn — that was
// the bug observed before this fix (commit follows).
zomega = -(MathF.PI / 2f) * adjustedSpeed;
break;
}
if (zomega != 0f)
_core.SetOmega(new Vector3(0f, 0f, zomega));
}
}
/// <summary>
/// R3-W4: the retail link-strip primitive
/// (<c>CPhysicsObj::RemoveLinkAnimations</c> 0x0050fe20 →
/// <c>CSequence::remove_all_link_animations</c> 0x00524ca0). The App
/// binds the entity's <c>MotionInterpreter.RemoveLinkAnimations</c> seam
/// here so HitGround/LeaveGround strip pending transition links exactly
/// where retail does. The pending-queue tick counts intentionally stay
/// (retail's primitive does not touch the manager queue; the countdown
/// chain absorbs it).
/// </summary>
public void RemoveAllLinkAnimations() => _core.RemoveAllLinkAnimations();
/// <summary>
/// R2-Q5: run retail's <c>enter_default_state</c> analog now if it
/// hasn't run yet (idempotent). Spawn paths call this so an entity with
/// no initial wire motion still plays the table default (retail: every
/// CPhysicsObj entering the world runs initialize_state).
/// </summary>
public void InitializeState() => EnsureInitialized();
/// <summary>
/// R2-Q5: the single dispatch entry — lazy initialize_state, then
/// <see cref="MotionTableManager.PerformMovement"/>, then (on a
/// successful InterpretedCommand) the locomotion velocity synthesis
/// (register AP-75; the consumers are remote body translation via
/// PositionManager.ComputeOffset and the local Option-B
/// get_state_velocity — retire in R6 when root motion drives the body).
/// Omega is deliberately NOT synthesized here: remote rotation is the
/// ObservedOmega seam (MotionTableDispatchSink callbacks, retire R6);
/// only the SetCycle path keeps the turn-omega fallback.
/// </summary>
public uint PerformMovement(MotionTableMovement movement)
{
EnsureInitialized();
uint result = _manager.PerformMovement(movement);
if (result == MotionTableManagerError.Success
&& movement.Type == MovementType.InterpretedCommand)
{
SynthesizeLocomotionVelocity(movement.Motion, movement.Speed);
}
return result;
}
/// <summary>
/// Overwrite sequence velocity with the retail locomotion constant when
/// the dispatched motion is a locomotion cycle. The Humanoid motion
/// table ships every locomotion MotionData with a zero Velocity, so
/// <c>add_motion</c>'s unconditional set leaves the sequence at zero —
/// but retail's body physics uses <c>CMotionInterp::get_state_velocity</c>
/// (0x00528960: RunAnimSpeed × ForwardSpeed etc., independent of the
/// dat), so consumers of <see cref="CurrentVelocity"/> need the
/// constant here. Velocity is body-local (+Y forward, +X right);
/// consumers rotate into world space via the entity orientation.
/// Constants decompiled from _DAT_007c96e0/e4/e8.
/// </summary>
private void SynthesizeLocomotionVelocity(uint motion, float speed)
{
float yvel = 0f;
float xvel = 0f;
bool isLocomotion = false;
switch (motion & 0xFFu)
{
case 0x05: // WalkForward
case 0x06: // WalkBackward (pre-adjust callers; adjusted = 0x05)
yvel = WalkAnimSpeed * speed;
isLocomotion = true;
break;
case 0x07: // RunForward
yvel = RunAnimSpeed * speed;
isLocomotion = true;
break;
case 0x0F: // SideStepRight
case 0x10: // SideStepLeft (pre-adjust callers; adjusted = 0x0F)
xvel = SidestepAnimSpeed * speed;
isLocomotion = true;
break;
}
if (isLocomotion)
_core.SetVelocity(new Vector3(xvel, yvel, 0f));
}
// Retail locomotion constants — mirror of MotionInterpreter.RunAnimSpeed
// etc. Kept here to keep AnimationSequencer self-contained for the
// synthesize-velocity path above. Values decompiled from _DAT_007c96e0/e4/e8.
private const float WalkAnimSpeed = 3.12f;
private const float RunAnimSpeed = 4.0f;
private const float SidestepAnimSpeed = 1.25f;
// 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
/// per-part transforms for the current blended keyframe.
///
/// <para>
/// R1-P5: delegates all frame-advance math to <see cref="CSequence.Update"/>
/// (the verbatim <c>update</c>/<c>update_internal</c>/
/// <c>advance_to_next_animation</c> port — gap map G3/G4/G5/G8/G9/G19).
/// The core queues hooks through <see cref="AdapterHookQueue"/> into
/// <see cref="_pendingHooks"/>; this method only builds the blended
/// render-side output.
/// </para>
/// </summary>
/// <param name="dt">Elapsed time in seconds since the last call.</param>
/// <returns>
/// One <see cref="PartTransform"/> per part in the Setup, in part order.
/// If no animation is loaded, all parts get identity transforms.
/// </returns>
public IReadOnlyList<PartTransform> Advance(float dt)
=> Advance(dt, rootMotionFrame: null);
/// <summary>
/// R1-P6 (gap map G7): the root-motion overload. When
/// <paramref name="rootMotionFrame"/> is supplied, the core's
/// <c>update</c>/<c>update_internal</c> apply BOTH root-motion sources
/// into it exactly as retail's <c>CPartArray::Update</c> path does —
/// the per-crossed-frame <c>PosFrames</c> combine/subtract AND the
/// sequence velocity/omega via <c>apply_physics</c> (0x00524ab0). This
/// is the seam R6's retail per-tick order consumes
/// (<c>UpdatePositionInternal → CPartArray.Update → adjust_offset →
/// Frame.combine</c>).
/// </summary>
public IReadOnlyList<PartTransform> Advance(float dt, Frame? rootMotionFrame)
{
int partCount = _setup.Parts.Count;
if (_core.CurrAnim == null && rootMotionFrame is null)
return BuildIdentityFrame(partCount);
if (dt <= 0f)
return _core.CurrAnim == null
? BuildIdentityFrame(partCount)
: BuildBlendedFrame();
_core.Update(dt, rootMotionFrame);
return _core.CurrAnim == null
? BuildIdentityFrame(partCount)
: BuildBlendedFrame();
}
/// <summary>
/// Retrieve and clear the list of hooks that fired since the last call.
/// Empty when no frame boundary was crossed. Safe to call multiple
/// times per frame; second and subsequent calls return an empty list.
/// </summary>
public IReadOnlyList<AnimationHook> ConsumePendingHooks()
{
if (_pendingHooks.Count == 0)
return Array.Empty<AnimationHook>();
var result = _pendingHooks.ToArray();
_pendingHooks.Clear();
return result;
}
// R1-P6: ConsumeRootMotionDelta DELETED (zero external callers; gap map
// API-migration table). Root motion flows through the
// Advance(dt, Frame) overload — retail's CSequence::update(Frame*)
// 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.
///
/// <para>
/// 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)
{
EnsureInitialized();
_manager.PerformMovement(MotionTableMovement.Interpreted(motionCommand, speedMod));
}
/// <summary>
/// Reset the sequencer to an unplaying state without clearing the
/// 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();
_state.Style = 0;
_state.Substate = 0;
_state.SubstateMod = 1f;
_state.ClearModifiers();
_state.ClearActions();
_initialized = false;
}
// ── Private helpers ──────────────────────────────────────────────────────
/// <summary>
/// Host seam wiring the core's <see cref="IAnimHookQueue"/> into this
/// adapter's <see cref="_pendingHooks"/> list. Mirrors retail's
/// <c>CPhysicsObj.anim_hooks</c> SmartArray queue-then-drain model (gap
/// map G6): the core QUEUES matched hooks here during
/// <see cref="CSequence.Update"/>; <see cref="ConsumePendingHooks"/>
/// drains them at the render-tick call site (GameWindow), same as
/// pre-cutover. The AnimDone hook maps to the existing
/// <see cref="AnimationDoneSentinel"/> singleton so downstream sinks
/// (AnimationHookRouter) see the same instance type they always have.
/// </summary>
private sealed class AdapterHookQueue : IAnimHookQueue
{
private readonly AnimationSequencer _owner;
public AdapterHookQueue(AnimationSequencer owner) => _owner = owner;
public void AddAnimHook(AnimationHook hook) => _owner._pendingHooks.Add(hook);
public void AddAnimDoneHook() => _owner._pendingHooks.Add(AnimationDoneSentinel);
}
// Sentinel hook fired when a non-cyclic link node drains naturally.
// Mirrors ACE's PhysicsObj.add_anim_hook(AnimationHook.AnimDoneHook).
private static readonly AnimationDoneHook AnimationDoneSentinel =
new() { Direction = AnimationHookDir.Both };
// R2-Q4: GetLink DELETED - re-homed verbatim as CMotionTable.GetLink
// (Q2, field-validated reversed-key branch preserved; Q0-pins A1).
// 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.
/// Blends between floor(FrameNumber) and floor(FrameNumber)+1 within the
/// CURRENT core node, using the fractional part of FrameNumber.
///
/// <para>
/// R1-P5: this is render-side interpolation, NOT CSequence scope (gap map
/// G19's "blend seam MED" note) — the core only tracks the floored
/// current frame (<c>get_curr_animframe</c>). The +1 index is clamped to
/// the node's <see cref="AnimSequenceNode.HighFrame"/>/LowFrame window
/// (direction-aware) to preserve the pre-cutover #61 fix (link-tail
/// holds its end pose instead of blending into frame 0 of the next
/// node).
/// </para>
///
/// Uses the retail-client slerp (<see cref="SlerpRetailClient"/>) for
/// quaternion interpolation and linear lerp for position.
/// </summary>
private IReadOnlyList<PartTransform> BuildBlendedFrame()
{
int partCount = _setup.Parts.Count;
var curr = _core.CurrAnim;
if (curr is null || curr.Anim is null)
return BuildIdentityFrame(partCount);
int numPartFrames = curr.Anim.PartFrames.Count;
// Clamp frameIndex to the node's valid window.
int rangeLo = Math.Min(curr.LowFrame, curr.HighFrame);
int rangeHi = Math.Max(curr.LowFrame, curr.HighFrame);
rangeHi = Math.Min(rangeHi, numPartFrames - 1);
rangeLo = Math.Max(rangeLo, 0);
int frameIdx = (int)Math.Floor(_core.FrameNumber);
frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi);
// Next frame for interpolation: step in the playback direction.
// Wrap to the opposite end ONLY when this node IS the cyclic tail
// (curr == first_cyclic). For one-shot nodes (link transitions,
// action overlays), hold the boundary frame instead — otherwise the
// fractional tail of the anim blends frame[end] with frame[0],
// producing a brief flash through the anim's starting pose at the
// link→cycle boundary (issue #61: door swing-open flap; run-stop
// twitch). This is render-side clamping, not a CSequence semantic.
bool nodeIsCyclic = ReferenceEquals(_core.CurrAnim, _core.FirstCyclic);
int nextIdx;
if (curr.Framerate >= 0f)
{
nextIdx = frameIdx + 1;
if (nextIdx > rangeHi || nextIdx >= numPartFrames)
nextIdx = nodeIsCyclic ? rangeLo : frameIdx;
}
else
{
nextIdx = frameIdx - 1;
if (nextIdx < rangeLo)
nextIdx = nodeIsCyclic ? rangeHi : frameIdx;
}
// Fractional blend weight (always in [0, 1]).
double rawT = _core.FrameNumber - Math.Floor(_core.FrameNumber);
float t = (float)Math.Clamp(rawT, 0.0, 1.0);
var f0Parts = curr.Anim.PartFrames[frameIdx].Frames;
var f1Parts = curr.Anim.PartFrames[nextIdx].Frames;
var result = new PartTransform[partCount];
for (int i = 0; i < partCount; i++)
{
if (i < f0Parts.Count)
{
var p0 = f0Parts[i];
var p1 = i < f1Parts.Count ? f1Parts[i] : p0;
result[i] = new PartTransform(
Vector3.Lerp(p0.Origin, p1.Origin, t),
SlerpRetailClient(p0.Orientation, p1.Orientation, t));
}
else
{
result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity);
}
}
return result;
}
private static IReadOnlyList<PartTransform> BuildIdentityFrame(int partCount)
{
var result = new PartTransform[partCount];
for (int i = 0; i < partCount; i++)
result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity);
return result;
}
// 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>
/// (chunk_00530000.c:4799-4846):
/// <list type="number">
/// <item>Compute dot product of q1 and q2.</item>
/// <item>If dot &lt; 0, negate q2 (choose the shorter arc).</item>
/// <item>If 1 - dot &lt;= epsilon, fall back to (1-t)*q1 + t*q2 (linear).</item>
/// <item>Otherwise slerp: omega = acos(dot), blend = sin(s*omega)/sin(omega).</item>
/// <item>Validate result lies in [0,1]²; if not, fall back to linear.</item>
/// </list>
/// The only difference from the standard formula is step 5: the retail
/// client validates that both blend weights are in [0,1] before using the
/// sin-based result; this handles degenerate inputs gracefully.
/// </summary>
public static Quaternion SlerpRetailClient(Quaternion q1, Quaternion q2, float t)
{
float dot = q1.W * q2.W + q1.X * q2.X + q1.Y * q2.Y + q1.Z * q2.Z;
// Step 2: choose the shorter arc.
Quaternion q2s;
if (dot < 0f)
{
dot = -dot;
q2s = new Quaternion(-q2.X, -q2.Y, -q2.Z, -q2.W);
}
else
{
q2s = q2;
}
const float SlerpEpsilon = 1e-4f;
float w1, w2;
if (1f - dot <= SlerpEpsilon)
{
// Near-parallel: linear fallback (matches retail client's path).
w1 = 1f - t;
w2 = t;
}
else
{
float omega = MathF.Acos(dot);
float sinOmega = MathF.Sin(omega);
float invSin = 1f / sinOmega;
float candidate1 = MathF.Sin((1f - t) * omega) * invSin;
float candidate2 = MathF.Sin(t * omega) * invSin;
// Step 5: validate (retail client check: both weights in [0,1]).
if (candidate1 >= 0f && candidate1 <= 1f
&& candidate2 >= 0f && candidate2 <= 1f)
{
w1 = candidate1;
w2 = candidate2;
}
else
{
w1 = 1f - t;
w2 = t;
}
}
return new Quaternion(
w1 * q1.X + w2 * q2s.X,
w1 * q1.Y + w2 * q2s.Y,
w1 * q1.Z + w2 * q2s.Z,
w1 * q1.W + w2 * q2s.W);
}
}