acdream/src/AcDream.Core/Physics/AnimationSequencer.cs
Erik f961d70023 feat(physics): port retail complete object frame pipeline
Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates.

Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-20 09:10:31 +02:00

821 lines
37 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.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);
}
// ─────────────────────────────────────────────────────────────────────────────
// 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, turn-omega
// synthesis) survive at the adapter level per the gap map's
// "Invented behaviors NOT in the gap list" note. Locomotion-velocity
// synthesis was retired when remote physics began consuming the literal
// CSequence root-motion Frame (issue #41, 2026-07-17).
//
// 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). <c>CSequence::apply_physics</c>
/// contributes it directly to the complete root Frame; no separate body-
/// velocity reconstruction is performed from this accessor.
/// </para>
/// </summary>
public Vector3 CurrentVelocity => _core.Velocity;
/// <summary>
/// Sequence-wide omega. <c>CSequence::apply_physics</c> rotates the same
/// complete root Frame that carries PosFrames and sequence velocity.
/// </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();
// MP-Alloc (2026-07-05): reusable per-part transform buffer. _setup is
// assigned once in the constructor and never reassigned, so
// _setup.Parts.Count is fixed for this sequencer's lifetime — one
// allocation instead of one `new PartTransform[partCount]` per Advance()
// call (every animated entity, every tick, including idle NPCs on a
// breathe cycle). BuildBlendedFrame/BuildIdentityFrame overwrite every
// slot before returning this array, so the values are bit-identical to
// the old fresh-array version; callers (GameWindow.TickAnimations)
// consume the returned IReadOnlyList<PartTransform> synchronously within
// the same loop iteration and never cache it across Advance() calls.
private readonly PartTransform[] _partTransformScratch;
// 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. Production supplies the centralized retail-compatible
/// content reader; unit tests may inject a test double.
/// </param>
public AnimationSequencer(Setup setup, MotionTable motionTable, IAnimationLoader loader)
{
ArgumentNullException.ThrowIfNull(setup);
ArgumentNullException.ThrowIfNull(motionTable);
ArgumentNullException.ThrowIfNull(loader);
_setup = setup;
_mtable = motionTable;
_loader = loader;
_partTransformScratch = new PartTransform[_setup.Parts.Count];
_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));
// CPartArray::InitDefaults (0x00518980) runs for every setup-backed
// PartArray, before CPhysicsObj::InitDefaults installs its motion
// table. Static is only a later workset-membership decision; it does
// not gate installation of Setup.DefaultAnimation itself.
InitializeSetupDefaultAnimation((uint)setup.DefaultAnimation);
}
/// <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. CMotionTable/add_motion
// owns the literal sequence velocity/omega writes; command-derived
// body speed is not copied into CSequence.
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.
if (dispatchResult != MotionTableManagerError.Success)
return;
// Rotation stays DAT-authored. Retail MotionData::add_motion
// (0x005224B0) contributes the entry's literal omega to CSequence;
// CSequence::apply_physics emits it through the complete Frame.
}
/// <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>
/// Retail <c>CPartArray::InitDefaults</c> (0x00518980): a Setup
/// <c>DefaultAnimation</c> bypasses the MotionTable, clears the sequence,
/// and appends one direct animation over frames <c>0..-1</c> at 30 fps.
/// Installation is unconditional for every setup-backed PartArray.
/// <c>CPhysicsObj::InitDefaults</c> separately decides whether a Static
/// owner enters <c>CPhysics::static_animating_objects</c>; ordinary motion
/// table initialization may subsequently replace this direct sequence.
/// </summary>
public bool InitializeSetupDefaultAnimation(uint animationId)
{
if (animationId == 0)
return false;
// CPartArray::InitDefaults (0x00518980) calls only
// CSequence::clear_animations (0x00524DC0). Sequence velocity,
// omega, and placement state belong to the surrounding PartArray
// lifetime and survive replacement of the animation list.
_core.ClearAnimations();
_pendingHooks.Clear();
_core.AppendAnimation(new AnimData
{
AnimId = (QualifiedDataId<Animation>)animationId,
LowFrame = 0,
HighFrame = -1,
Framerate = 30f,
});
return _core.CurrAnim is not null;
}
/// <summary>
/// R2-Q5: the single dispatch entry — lazy initialize_state, then
/// <see cref="MotionTableManager.PerformMovement"/>. The resulting
/// <see cref="CurrentVelocity"/> remains the literal retail
/// <c>MotionData.Velocity * speed</c> written by <c>add_motion</c>;
/// command-derived body speed belongs to
/// <c>CMotionInterp::get_state_velocity</c>, not to CSequence.
/// </summary>
public uint PerformMovement(MotionTableMovement movement)
{
EnsureInitialized();
return _manager.PerformMovement(movement);
}
// 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 SampleCurrentPose();
_core.Update(dt, rootMotionFrame);
return _core.CurrAnim == null
? BuildIdentityFrame(partCount)
: BuildBlendedFrame();
}
/// <summary>
/// Samples the current sequence frame without advancing animation time or
/// dispatching hooks. This is retail's hidden-object
/// <c>CPhysicsObj::set_frame -&gt; CPartArray::SetFrame -&gt; UpdateParts</c>
/// path: <c>HandleEnterWorld</c> may move the sequence cursor from a completed
/// link to the cyclic tail, and the hidden object must recompose that new pose
/// while PartArray time remains frozen.
/// </summary>
public IReadOnlyList<PartTransform> SampleCurrentPose()
=> _core.CurrAnim == null
? BuildIdentityFrame(_setup.Parts.Count)
: 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;
// MP-Alloc: overwrite the reusable per-instance buffer in place
// instead of allocating a fresh PartTransform[partCount] every call.
// Sized once in the constructor to _setup.Parts.Count, which never
// changes, so partCount here always matches the buffer length.
var result = _partTransformScratch;
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 IReadOnlyList<PartTransform> BuildIdentityFrame(int partCount)
{
// MP-Alloc: same reusable buffer as BuildBlendedFrame (see
// _partTransformScratch) — overwritten in place, never reallocated.
var result = _partTransformScratch;
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);
}
}