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;
///
/// Minimal interface for resolving Animation objects by id.
/// Abstracted so the sequencer can be unit-tested without a real DatCollection.
///
public interface IAnimationLoader
{
/// Load an Animation by its dat id, or return null.
Animation? LoadAnimation(uint id);
}
///
/// Production implementation of backed by
/// a .
///
public sealed class DatCollectionLoader : IAnimationLoader
{
private readonly DatCollection _dats;
public DatCollectionLoader(DatCollection dats) => _dats = dats;
public Animation? LoadAnimation(uint id) => _dats.Get(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 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
// key = (style << 16) | (fromSubstate & 0xFFFFFF)
// MotionCommandData.MotionData : Dictionary
// key = target motion (int cast of MotionCommand)
// MotionData.Anims : List
// MotionData.Velocity / MotionData.Omega : Vector3 (world-space physics)
// MotionData.Flags : MotionDataFlags (HasVelocity=0x01, HasOmega=0x02)
// AnimData.AnimId : QualifiedDataId
// Animation.PartFrames : List
// Animation.PosFrames : List (root motion, present if Flags & PosFrames)
// Animation.Flags : AnimationFlags (PosFrames = 0x01)
// AnimationFrame.Frames : List
// AnimationFrame.Hooks : List
// Frame.Origin : Vector3, Frame.Orientation : Quaternion
// ─────────────────────────────────────────────────────────────────────────────
///
/// Per-part world-local transform produced by .
/// Caller (e.g. GameWindow.TickAnimations) consumes this to rebuild MeshRefs.
///
public readonly struct PartTransform
{
public readonly Vector3 Origin;
public readonly Quaternion Orientation;
public PartTransform(Vector3 origin, Quaternion orientation)
{
Origin = origin;
Orientation = orientation;
}
}
///
/// Full animation playback engine for one entity.
///
///
/// R1-P5: this is now a thin adapter over — the
/// verbatim retail port of the AC client's CSequence 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.
///
///
///
/// Usage pattern:
///
/// 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
///
///
///
public sealed class AnimationSequencer
{
// ── Public state ─────────────────────────────────────────────────────────
///
/// Current style (stance) command — R2-Q4: read-only mirror of the
/// retail (MotionState OWNS
/// style/substate/substate_mod; the adapter no longer keeps copies).
///
public uint CurrentStyle => _state.Style;
///
/// Current cyclic motion command — mirror of
/// . NOTE (Q4): this is the
/// POST-adjust_motion substate (WalkBackward reads back as WalkForward
/// with a negative ) — retail's interpreted
/// state is post-adjustment.
///
public uint CurrentMotion => _state.Substate;
///
/// Speed multiplier of the current substate — mirror of
/// (signed; see
/// ).
///
public float CurrentSpeedMod => _state.SubstateMod;
///
/// Sequence-wide velocity mirror of the core's
/// field (retail Sequence::Velocity). Updated each time a
/// MotionData is appended — reflects the MOST RECENT MotionData's
/// velocity × speedMod (retail set_velocity semantics,
/// MotionTable.add_motion L358-L370).
///
///
/// 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.
///
///
public Vector3 CurrentVelocity => _core.Velocity;
///
/// Sequence-wide omega, matching 's semantics.
///
public Vector3 CurrentOmega => _core.Omega;
// Diagnostics
public int QueueCount => _core.Count;
public bool HasCurrentNode => _core.CurrAnim != null;
/// Test seam (Q4 trace conformance): the verbatim CSequence core.
internal CSequence Core => _core;
///
/// Diagnostic snapshot of the core's curr_anim identity + frame
/// state, for the per-tick CURRNODE log line in
/// GameWindow.TickAnimations. 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.
///
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);
}
}
///
/// 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 's AnimRefHash to see
/// whether the core's curr_anim is actually pointing at the new
/// cycle or something stale.
///
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 _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 ──────────────────────────────────────────────────────────
///
/// Create a sequencer for one entity.
///
/// Entity's Setup dat (for part count / default scale).
/// Loaded MotionTable dat for this entity.
///
/// Animation loader. Use for production,
/// or inject a test double in unit tests.
///
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));
}
///
/// R2-Q4: the entity's — the caller's
/// per-tick drive surface (
/// per drained AnimDone hook,
/// once per tick).
///
public MotionTableManager Manager => _manager;
///
/// R2-Q4 seam: where 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).
///
public Action? 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.
///
/// 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.
///
///
/// Implements adjust_motion (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.
///
///
/// MotionCommand style / stance (e.g. NonCombat 0x003D0000).
/// Target motion command (e.g. WalkForward 0x45000005).
/// Speed multiplier applied to framerates (1.0 = normal).
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));
}
}
///
/// R3-W4: the retail link-strip primitive
/// (CPhysicsObj::RemoveLinkAnimations 0x0050fe20 →
/// CSequence::remove_all_link_animations 0x00524ca0). The App
/// binds the entity's MotionInterpreter.RemoveLinkAnimations 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).
///
public void RemoveAllLinkAnimations() => _core.RemoveAllLinkAnimations();
///
/// R2-Q5: run retail's enter_default_state 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).
///
public void InitializeState() => EnsureInitialized();
///
/// R2-Q5: the single dispatch entry — lazy initialize_state, then
/// , 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.
///
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;
}
///
/// 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
/// add_motion's unconditional set leaves the sequence at zero —
/// but retail's body physics uses CMotionInterp::get_state_velocity
/// (0x00528960: RunAnimSpeed × ForwardSpeed etc., independent of the
/// dat), so consumers of 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.
///
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).
///
/// Advance the animation by seconds and return the
/// per-part transforms for the current blended keyframe.
///
///
/// R1-P5: delegates all frame-advance math to
/// (the verbatim update/update_internal/
/// advance_to_next_animation port — gap map G3/G4/G5/G8/G9/G19).
/// The core queues hooks through into
/// ; this method only builds the blended
/// render-side output.
///
///
/// Elapsed time in seconds since the last call.
///
/// One per part in the Setup, in part order.
/// If no animation is loaded, all parts get identity transforms.
///
public IReadOnlyList Advance(float dt)
=> Advance(dt, rootMotionFrame: null);
///
/// R1-P6 (gap map G7): the root-motion overload. When
/// is supplied, the core's
/// update/update_internal apply BOTH root-motion sources
/// into it exactly as retail's CPartArray::Update path does —
/// the per-crossed-frame PosFrames combine/subtract AND the
/// sequence velocity/omega via apply_physics (0x00524ab0). This
/// is the seam R6's retail per-tick order consumes
/// (UpdatePositionInternal → CPartArray.Update → adjust_offset →
/// Frame.combine).
///
public IReadOnlyList 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();
}
///
/// 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.
///
public IReadOnlyList ConsumePendingHooks()
{
if (_pendingHooks.Count == 0)
return Array.Empty();
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.
///
/// Play a one-shot action / modifier motion (Jump, emote, attack, etc.)
/// on top of the current cycle.
///
///
/// R2-Q4: a thin shim over
/// — action-class ids
/// (0x10000000) route GetObjectSequence 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
/// combine_motion — 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.
///
///
/// Raw MotionCommand (e.g. 0x2500003b for Jump).
/// Speed multiplier for the action's framerate.
public void PlayAction(uint motionCommand, float speedMod = 1f)
{
EnsureInitialized();
_manager.PerformMovement(MotionTableMovement.Interpreted(motionCommand, speedMod));
}
///
/// 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.
///
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 ──────────────────────────────────────────────────────
///
/// Host seam wiring the core's into this
/// adapter's list. Mirrors retail's
/// CPhysicsObj.anim_hooks SmartArray queue-then-drain model (gap
/// map G6): the core QUEUES matched hooks here during
/// ;
/// drains them at the render-tick call site (GameWindow), same as
/// pre-cutover. The AnimDone hook maps to the existing
/// singleton so downstream sinks
/// (AnimationHookRouter) see the same instance type they always have.
///
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).
///
/// 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.
///
///
/// 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 (get_curr_animframe). The +1 index is clamped to
/// the node's /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).
///
///
/// Uses the retail-client slerp () for
/// quaternion interpolation and linear lerp for position.
///
private IReadOnlyList 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 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.
///
/// Quaternion slerp matching the retail client's FUN_005360d0
/// (chunk_00530000.c:4799-4846):
///
/// - Compute dot product of q1 and q2.
/// - If dot < 0, negate q2 (choose the shorter arc).
/// - If 1 - dot <= epsilon, fall back to (1-t)*q1 + t*q2 (linear).
/// - Otherwise slerp: omega = acos(dot), blend = sin(s*omega)/sin(omega).
/// - Validate result lies in [0,1]²; if not, fall back to linear.
///
/// 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.
///
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);
}
}