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>
849 lines
38 KiB
C#
849 lines
38 KiB
C#
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 < 0, negate q2 (choose the shorter arc).</item>
|
||
/// <item>If 1 - dot <= 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);
|
||
}
|
||
}
|