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); } }