diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 5ad67d46..6b16b69b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -97,7 +97,8 @@ accepted-divergence entries (#96, #49, #50). | AD-31 | Teleport transit covered by a full-screen black FADE (`FadeOverlay` + `TeleportAnimSequencer`) instead of retail's 3D portal-tunnel swirl (2026-06-22, spec C). Opaque black holds through the tunnel states; the world ramps back in on `WorldFadeIn`. | `src/AcDream.App/Rendering/FadeOverlay.cs` + `GameWindow.cs` (TAS transit tick; `_teleportFadeAlpha = ShowTunnel ? 1 : FadeAlpha`) | The fade is a functional cover that hides the (now-fast) destination load + the post-materialization object flood; the TAS state machine + golden timing constants are retail-verbatim — only the tunnel *graphic* is approximated. Sibling to AP-49 (fade-curve). | Visual-only: the transit shows a black cover, not the animated swirl. Retire by porting the `gmSmartBoxUI` 3D tunnel render. | `gmSmartBoxUI::UseTime` 0x004d6e30 (tunnel render, unported); `TELEPORT_ANIM_*` golden constants (spec §2.1) | | AD-32 | Movement-event staleness gate ADOPTS a newer-incarnation instance stamp and applies the event immediately; retail queues the blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it once that incarnation exists (L.2g S1, 2026-07-02) | `src/AcDream.Core/Physics/MotionSequenceGate.cs:105` | acdream has no per-object blob queue; a newer instance seq means the new incarnation's CreateObject is in flight, and that CreateObject re-seeds the same gate (advance-only), so old-incarnation stragglers still drop | A motion event for a new incarnation applies to the OLD body for up to one CreateObject round-trip — brief wrong-cycle flicker on respawn/re-instance if the new incarnation's motion table differs | 0xF74C dispatch pc:357214-357239 (`is_newer(update_times[8], seq)` + `QueueBlobForObject`) | | AD-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) | -| AD-34 | Retail's intrusive `DLListBase`/`DLListData` anim-node list is a managed `LinkedList`; node identity via `LinkedListNode<>` references (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`) | Same topology + cursor semantics (curr_anim/first_cyclic are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the −4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0 | +| AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`) | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the −4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11 | +| AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) | --- diff --git a/src/AcDream.Core/Physics/Motion/MotionTableManager.cs b/src/AcDream.Core/Physics/Motion/MotionTableManager.cs new file mode 100644 index 00000000..271c3ce8 --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/MotionTableManager.cs @@ -0,0 +1,508 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.Core.Physics; + +namespace AcDream.Core.Physics.Motion; + +// ───────────────────────────────────────────────────────────────────────────── +// R2-Q3 — verbatim port of retail's MotionTableManager (the pending-animation +// queue + tick-countdown completion machinery sitting ABOVE CMotionTable). +// Oracle: docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md §11 +// (all addresses/line numbers below point there); ambiguity pins: +// docs/research/2026-07-02-r2-motiontable/Q0-pins.md; plan: +// docs/research/2026-07-02-r2-motiontable/r2-port-plan.md §3 Q3 + §4 (the +// MotionDone -> R3 boundary contract). +// +// Struct layout (acclient.h:57614/31092/31097; decomp §11 offset map): +// physics_obj @0x0, table @0x4, state @0x8 (24 bytes), animation_counter +// @0x20, pending_animations {head_,tail_} @0x24/0x28. sizeof == 0x2c. +// C# has no physics_obj field — R2 leaves the CPhysicsObj::MotionDone target +// as an injectable seam (IMotionDoneSink, part A of this file) since R3 binds +// the real MotionInterpreter/MovementManager consumer. +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// The R2 seam standing in for retail's CPhysicsObj::MotionDone — the +/// callback and +/// fire for every +/// pending-queue entry whose tick countdown reaches zero. R2 leaves this +/// consumer-injectable (register row: MotionDone observed-not-consumed, +/// r2-port-plan.md §4); R3 binds it to MotionInterpreter.MotionDone +/// (pops CMotionInterp.pending_motions, recomputes IsAnimating). +/// +public interface IMotionDoneSink +{ + /// + /// CPhysicsObj::MotionDone(motion, success). + /// is the CALLER's flag: true from the real per-tick animation-done hook + /// ( invoked with + /// success:true), false from the enter/exit-world drains, + /// hardcoded true from . + /// + void MotionDone(uint motion, bool success); +} + +/// +/// One pending_animations queue node — retail's +/// MotionTableManager::AnimNode (acclient.h:57614; 16 bytes: +/// dllist_next/prev + motion + num_anims). NumAnims doubles as a +/// RELATIVE tick-duration countdown once queued (not an absolute deadline — +/// decomp §11 AnimationDone note), not an anim-array length. +/// +/// Register note (reusing the R1 AD-34 wording): retail's intrusive DLList +/// is ported as a managed of ; +/// node identity semantics (the tail-anchored backward scan in +/// , the in-place +/// truncation in ) are +/// preserved via references rather than raw +/// prev/next pointers. +/// +public sealed class PendingMotion +{ + public uint Motion; + public uint NumAnims; + + public PendingMotion(uint motion, uint numAnims) + { + Motion = motion; + NumAnims = numAnims; + } +} + +/// +/// Retail's MotionTableManager (0x0051bxxx region) — owns the +/// pending-animation queue and the tick-countdown completion machinery that +/// sits ABOVE 's pure selection logic. +/// is the single chokepoint between a wire-level +/// and the motion-table state machine (decomp +/// §11 PerformMovement 0x0051c0b0). +/// +public sealed class MotionTableManager +{ + // Motion-id class bits (decomp §1 / §15). + private const uint CycleClassBit = 0x40000000u; + private const uint ModifierClassBit = 0x20000000u; + private const uint ActionClassBit = 0x10000000u; + + /// + /// Combined block-mask used by 's + /// CYCLE-class tail scan (decomp §11/§15 literal "0xb0000000"). Bit + /// decomposition: 0xb0000000 == 0x80000000 (style) | 0x20000000 + /// (modifier) | 0x10000000 (action) — i.e. STYLE|MODIFIER|ACTION, + /// deliberately EXCLUDING the cycle-class bit (0x40000000) itself. The + /// decomp's own prose gloss ("cycle|action|..." mask) is imprecise; the + /// literal hex constant (ported verbatim below) is the ground truth — + /// an intervening node of a DIFFERENT cycle does not block a cycle-tail + /// collapse (two cycles naturally supersede via the base-cycle rebuild + /// mechanism), but an intervening style-change/modifier/action does. + /// + private const uint CycleTailBlockMask = 0xb0000000u; + + /// + /// Combined block-mask used by 's + /// STYLE-class tail scan (decomp §11/§15 literal "0x70000000"). Bit + /// decomposition: 0x70000000 == 0x40000000 (cycle) | 0x20000000 + /// (modifier) | 0x10000000 (action) — CYCLE|MODIFIER|ACTION, + /// deliberately EXCLUDING the style-class top bit (0x80000000) itself + /// (the match test is already exact-equality on the full style id, so a + /// DIFFERENT style in between doesn't need a separate block check here). + /// + private const uint StyleTailBlockMask = 0x70000000u; + + /// Sentinel "stop-completely / default-state-installed" motion id + /// (decomp §15 "0x41000003"). + public const uint ReadySentinel = 0x41000003u; + + private readonly CMotionTable? _table; + private readonly MotionState _state; + private readonly CSequence _sequence; + private readonly IMotionDoneSink _sink; + private readonly LinkedList _pendingAnimations = new(); // pending_animations + private int _animationCounter; // animation_counter (@0x20) + + /// Current motion state — retail's embedded state member + /// (@0x8, 24 bytes). Exposed for callers that need to read style/substate. + public MotionState State => _state; + + /// + /// Create 0x0051bc50 (@290510). Retail's static factory + /// zero-initializes physics_obj/table, placement-constructs + /// state, zeros animation_counter, and nulls + /// pending_animations's head/tail — all of which the C# field + /// initializers below already give for free. may + /// be null (retail: "no motion table loaded" — + /// returns error 7 in that case, matching SetMotionTableID never + /// having been called). + /// + public MotionTableManager(CMotionTable? table, MotionState state, CSequence sequence, IMotionDoneSink sink) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(sequence); + ArgumentNullException.ThrowIfNull(sink); + + _table = table; + _state = state; + _sequence = sequence; + _sink = sink; + } + + /// Read-only inspection surface for tests: the pending queue in + /// head-to-tail order. + public IEnumerable PendingAnimations => _pendingAnimations; + + /// Read-only inspection surface for tests: the current tick + /// countdown accumulator. + public int AnimationCounter => _animationCounter; + + // ── add_to_queue / remove_redundant_links / truncate_animation_list ──── + + /// + /// add_to_queue 0x0051bfe0 (@290854): append a new node to the + /// tail of pending_animations, then opportunistically call + /// to collapse any now-superseded + /// earlier entries. + /// + public void AddToQueue(uint motion, uint ticks) + { + _pendingAnimations.AddLast(new PendingMotion(motion, ticks)); + RemoveRedundantLinks(); + } + + /// + /// remove_redundant_links 0x0051bf20 (@290771): retail's + /// TAIL-ANCHORED SINGLE SCAN (ported verbatim — NOT ACE's restructured + /// outer loop, per r2-port-plan.md §3 Q3). + /// + /// 1. Skip backward over trailing zero-NumAnims nodes (already + /// neutered / instant entries). If the list bottoms out, return. + /// 2. If the effective tail's motion is CYCLE-class-and-not-modifier-class + /// (&0x40000000 != 0 && &0x20000000 == 0): scan backward for an + /// EARLIER node with the SAME motion id AND non-zero NumAnims. + /// Blocked (abort, no truncation) by any intervening non-zero node + /// whose motion matches the 0xb0000000 class mask. + /// 3. Else if the effective tail's motion is STYLE-class + /// ((int)motion < 0): same backward scan, EXACT match (no + /// additional class requirement on the match itself), blocked by any + /// intervening non-zero node matching the WIDER 0x70000000 class mask. + /// 4. On a match, from the matched + /// node's successor through the tail (matched node itself untouched). + /// + public void RemoveRedundantLinks() + { + var tail = _pendingAnimations.Last; + if (tail is null) + return; + + // Step 1: skip trailing zero-tick nodes. + while (tail is not null && tail.Value.NumAnims == 0) + { + tail = tail.Previous; + } + if (tail is null) + return; + + uint motion = tail.Value.Motion; + + if ((motion & CycleClassBit) != 0 && (motion & ModifierClassBit) == 0) + { + // CYCLE-class (not modifier-class) tail: match = same motion AND + // non-zero NumAnims; block = non-zero AND matches 0xb0000000. + var scan = tail.Previous; + LinkedListNode? matched = null; + while (scan is not null) + { + if (scan.Value.Motion == motion && scan.Value.NumAnims != 0) + { + matched = scan; + break; + } + if (scan.Value.NumAnims != 0 && (scan.Value.Motion & CycleTailBlockMask) != 0) + return; // blocked by an intervening "important" non-zero node + scan = scan.Previous; + } + if (matched is not null) + TruncateAnimationList(matched); + } + else if ((int)motion < 0) + { + // STYLE-class tail: exact-equality match; block mask is wider + // (0x70000000) with no additional match-side class requirement. + var scan = tail.Previous; + LinkedListNode? matched = null; + while (scan is not null) + { + if (scan.Value.Motion == motion) + { + matched = scan; + break; + } + if (scan.Value.NumAnims != 0 && (scan.Value.Motion & StyleTailBlockMask) != 0) + return; + scan = scan.Previous; + } + if (matched is not null) + TruncateAnimationList(matched); + } + // else: modifier-class or action-class tail -> no redundancy scan (retail: neither branch taken). + } + + /// + /// truncate_animation_list 0x0051bca0 (@290533): walk + /// pending_animations.tail_ BACKWARD toward (but not including) + /// , zeroing each node's + /// NumAnims tick countdown IN PLACE (nodes stay queued — retail + /// does NOT unlink them here) and accumulating the total ticks removed, + /// then strips that many ticks' worth of link animations from the live + /// via . + /// + private void TruncateAnimationList(LinkedListNode stopAtExclusive) + { + uint removedTicks = 0; + var node = _pendingAnimations.Last; + while (!ReferenceEquals(node, stopAtExclusive)) + { + if (node is null) + return; // stopAtExclusive wasn't actually in the list -> abort quietly + + removedTicks += node.Value.NumAnims; + node.Value.NumAnims = 0; + node = node.Previous; + } + + _sequence.RemoveLinkAnimations((int)removedTicks); + } + + // ── AnimationDone / CheckForCompletedMotions / UseTime ───────────────── + + /// + /// AnimationDone 0x0051bce0 (@290558): advance the animation clock + /// by one tick and fire for every + /// queued motion whose relative-duration countdown has elapsed. + /// NumAnims on a queue node is a RELATIVE tick-duration (not an + /// absolute deadline) — subtracted from the running counter after firing, + /// forming a decrementing countdown chain (one AnimationDone call + /// can pop MULTIPLE queued entries via counter rollover). + /// + /// Passed straight through to + /// for every entry popped this + /// call. + public void AnimationDone(bool success) + { + var head = _pendingAnimations.First; + if (head is null) + return; + + _animationCounter += 1; + + while (head is not null && head.Value.NumAnims <= _animationCounter) + { + if ((head.Value.Motion & ActionClassBit) != 0) + _state.RemoveActionHead(); + + _sink.MotionDone(head.Value.Motion, success); + _animationCounter -= (int)head.Value.NumAnims; + + _pendingAnimations.RemoveFirst(); + head = _pendingAnimations.First; + } + + // Drained-list counter reset: avoid drift once the queue is empty. + if (_animationCounter != 0 && head is null) + _animationCounter = 0; + } + + /// + /// CheckForCompletedMotions 0x0051be00 (@290645): pop every head + /// node ALREADY at NumAnims == 0 (zero-tick entries, or ones + /// neutered by ). Unlike + /// : no counter increment, no counter + /// decrement, success is hardcoded true. + /// + public void CheckForCompletedMotions() + { + var head = _pendingAnimations.First; + if (head is null) + return; + + while (head is not null && head.Value.NumAnims == 0) + { + if ((head.Value.Motion & ActionClassBit) != 0) + _state.RemoveActionHead(); + + _sink.MotionDone(head.Value.Motion, true); + + _pendingAnimations.RemoveFirst(); + head = _pendingAnimations.First; + } + } + + /// UseTime 0x0051bfd0 (@290845): per-frame entry point, + /// tailcalls . + public void UseTime() => CheckForCompletedMotions(); + + // ── initialize_state / HandleEnterWorld / HandleExitWorld ────────────── + + /// + /// initialize_state 0x0051c030 (@290875): install the motion + /// table's baseline state () and + /// queue the ("0x41000003" — initial + /// default-state-installed marker) with the resulting tick count, then + /// opportunistically collapse redundant links. + /// + public void InitializeState() + { + uint outTicks = 0; + if (_table is not null) + { + _table.SetDefaultState(_state, _sequence, out outTicks); + } + + AddToQueue(ReadySentinel, outTicks); + } + + /// + /// HandleEnterWorld 0x0051bdd0 (@290634): strip any stale link + /// animations from the live sequence + /// (), then fully drain + /// pending_animations exactly like — + /// each drained entry fires with + /// success:false via repeated calls + /// (decomp: while (head_ != 0) AnimationDone(this, 0)). + /// + public void HandleEnterWorld() + { + _sequence.RemoveAllLinkAnimations(); + DrainQueue(); + } + + /// + /// HandleExitWorld 0x0051bda0 (@290625): fully drain + /// pending_animations, signaling "failure/aborted" + /// (success:false) for every entry via repeated + /// calls. + /// + public void HandleExitWorld() => DrainQueue(); + + private void DrainQueue() + { + while (_pendingAnimations.First is not null) + AnimationDone(false); + } + + // ── PerformMovement ────────────────────────────────────────────────── + + /// + /// PerformMovement 0x0051c0b0 (@290906) — the single chokepoint + /// between a wire-level (interpreted + /// command / stop / stop-completely) and the motion-table state machine. + /// Error codes: 7 = no motion table loaded; 0x43 = + /// DoObjectMotion/StopObjectMotion returned failure; 0 = success. + /// Unhandled s (RawCommand, StopRawCommand, + /// MoveToObject/Position, TurnToObject/Heading) are NOT MotionTableManager's + /// job — decomp's BN artifact returns the CSequence pointer reinterpreted + /// as a code (§11 note: "likely dead/unreachable... never consulted"); the + /// C# port returns instead + /// of leaking a pointer value, since no caller in this codebase depends on + /// that BN quirk. + /// + public uint PerformMovement(MotionTableMovement movement) + { + if (_table is null) + return MotionTableManagerError.NoTable; // 7 + + uint outTicks; + + switch (movement.Type) + { + case MovementType.InterpretedCommand: + if (_table.DoObjectMotion(movement.Motion, _state, _sequence, movement.Speed, out outTicks)) + { + AddToQueue(movement.Motion, outTicks); + return MotionTableManagerError.Success; // 0 + } + return MotionTableManagerError.MotionFailed; // 0x43 + + case MovementType.StopInterpretedCommand: + if (_table.StopObjectMotion(movement.Motion, movement.Speed, _state, _sequence, out outTicks)) + { + AddToQueue(ReadySentinel, outTicks); + return MotionTableManagerError.Success; + } + return MotionTableManagerError.MotionFailed; + + case MovementType.StopCompletely: + _table.StopObjectCompletely(_state, _sequence, out outTicks); + AddToQueue(ReadySentinel, outTicks); // UNCONDITIONAL — queued regardless of return value. + return MotionTableManagerError.Success; + + default: + // RawCommand, StopRawCommand, and the MoveTo*/TurnTo* types are + // not MotionTableManager's job (decomp §11 note). + return MotionTableManagerError.NotHandled; + } + } +} + +/// +/// PerformMovement error/result codes (decomp §15 "PerformMovement +/// error codes"). Named constants standing in for retail's raw hex return +/// values, kept as plain to match +/// 's retail-verbatim return +/// type. +/// +public static class MotionTableManagerError +{ + /// 0 — success. + public const uint Success = 0u; + /// 7 — no motion table loaded. + public const uint NoTable = 7u; + /// 0x43 — DoObjectMotion/StopObjectMotion returned failure. + public const uint MotionFailed = 0x43u; + /// + /// C#-port-only sentinel for the "unhandled MovementType" default case. + /// Retail's BN decompile shows this leaking the CSequence pointer + /// reinterpreted as a return code (decomp §11 note, "likely dead/ + /// unreachable in practice"); no known caller depends on that value, so + /// the port returns this distinguishable constant instead of fabricating + /// a pointer-shaped number. + /// + public const uint NotHandled = 0xFFFFFFFFu; +} + +/// +/// Minimal retail-verbatim MovementStruct subset (acclient.h:38069) +/// needed by : just the +/// dispatch type, the motion id, and the speed scalar +/// (arg2->params->speed). Defined here rather than reusing +/// AcDream.Core.Physics.MotionInterpreter.MovementStruct because that +/// type serves a different (CMotionInterp-level) call site with fields +/// (ObjectId, Position, Autonomous, +/// ModifyInterpretedState/RawState) MotionTableManager never reads — +/// CLAUDE.md/plan instruction: do not modify MotionInterpreter.cs. +/// IS reused (its 5 values +/// match retail's MovementTypes::Type 1:1 for the cases +/// MotionTableManager handles). +/// +public readonly struct MotionTableMovement +{ + public readonly MovementType Type; + public readonly uint Motion; + public readonly float Speed; + + public MotionTableMovement(MovementType type, uint motion, float speed) + { + Type = type; + Motion = motion; + Speed = speed; + } + + public static MotionTableMovement Interpreted(uint motion, float speed) => + new(MovementType.InterpretedCommand, motion, speed); + + public static MotionTableMovement StopInterpreted(uint motion, float speed) => + new(MovementType.StopInterpretedCommand, motion, speed); + + public static MotionTableMovement StopCompletely() => + new(MovementType.StopCompletely, 0u, 1f); +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MotionTableManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MotionTableManagerTests.cs new file mode 100644 index 00000000..ab4f44d5 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MotionTableManagerTests.cs @@ -0,0 +1,1018 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; + +using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; + +namespace AcDream.Core.Tests.Physics.Motion; + +// ───────────────────────────────────────────────────────────────────────────── +// MotionTableManagerTests — R2-Q3 conformance harness for the verbatim +// MotionTableManager pending-animation queue + tick-countdown completion +// machinery (r2-port-plan.md §3 Q3; oracle +// docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md §11). +// +// Fixture convention matches CMotionTableTests.cs's file-local Fixtures +// class. Most tests exercise MotionTableManager directly via its +// PendingAnimations/AnimationCounter inspection surface + a RecordingSink, +// bypassing GetObjectSequence entirely so the queue mechanics are pinned in +// isolation from the selection dispatcher (already covered by +// CMotionTableTests.cs). +// ───────────────────────────────────────────────────────────────────────────── + +file static class Fixtures +{ + public static Animation MakeAnim(int numFrames, int numParts, Vector3 origin, Quaternion orientation) + { + var anim = new Animation(); + for (int f = 0; f < numFrames; f++) + { + var pf = new AnimationFrame((uint)numParts); + for (int p = 0; p < numParts; p++) + pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation }); + anim.PartFrames.Add(pf); + } + return anim; + } + + public static AnimData MakeAnimData(uint animId, float framerate) + => new() + { + AnimId = (QualifiedDataId)animId, + LowFrame = 0, + HighFrame = -1, + Framerate = framerate, + }; + + public static MotionData MakeMotionData(uint animId, float framerate, byte bitfield = 0) + { + var md = new MotionData { Bitfield = bitfield }; + md.Anims.Add(MakeAnimData(animId, framerate)); + return md; + } + + public static void AddLink(MotionTable mt, uint style, uint fromSubstate, uint toSubstate, MotionData data) + { + int outerKey = (int)((style << 16) | (fromSubstate & 0xFFFFFFu)); + if (!mt.Links.TryGetValue(outerKey, out var cmd)) + { + cmd = new MotionCommandData(); + mt.Links[outerKey] = cmd; + } + cmd.MotionData[(int)toSubstate] = data; + } + + public static void AddCycle(MotionTable mt, uint style, uint substate, MotionData data) + { + int key = (int)((style << 16) | (substate & 0xFFFFFFu)); + mt.Cycles[key] = data; + } +} + +/// Records every call in order. +internal sealed class RecordingSink : IMotionDoneSink +{ + public readonly List<(uint Motion, bool Success)> Calls = new(); + public void MotionDone(uint motion, bool success) => Calls.Add((motion, success)); +} + +/// In-memory that never resolves. +file sealed class NullLoader : IAnimationLoader +{ + public Animation? LoadAnimation(uint id) => null; +} + +/// In-memory test double. +file sealed class FakeLoader : IAnimationLoader +{ + private readonly Dictionary _anims = new(); + public void Register(uint id, Animation anim) => _anims[id] = anim; + public Animation? LoadAnimation(uint id) => _anims.TryGetValue(id, out var a) ? a : null; +} + +public sealed class MotionTableManagerTests +{ + // Real retail command words (DatReaderWriter.Enums.MotionCommand), + // matching CMotionTableTests.cs's fixture conventions. + private const uint Ready = 0x41000003u; // cycle, == ReadySentinel + private const uint WalkForward = 0x45000005u; // cycle + private const uint RunForward = 0x44000007u; // cycle + private const uint ThrustMed = 0x10000058u; // action only + private const uint NonCombatStyle = 0x8000003Du; // style, top bit set + private const uint HandCombatStyle = 0x8000003Cu; // style, top bit set + + private static (MotionTableManager mgr, RecordingSink sink, MotionState state, CSequence seq) MakeManager(CMotionTable? table = null) + { + var sink = new RecordingSink(); + var state = new MotionState(); + var seq = new CSequence(new NullLoader()); + var mgr = new MotionTableManager(table, state, seq, sink); + return (mgr, sink, state, seq); + } + + // ── add_to_queue / basic queueing ─────────────────────────────────── + + [Fact] + public void AddToQueue_AppendsNode_InOrder() + { + var (mgr, _, _, _) = MakeManager(); + + mgr.AddToQueue(WalkForward, 3); + mgr.AddToQueue(RunForward, 5); + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(2, list.Count); + Assert.Equal(WalkForward, list[0].Motion); + Assert.Equal(3u, list[0].NumAnims); + Assert.Equal(RunForward, list[1].Motion); + Assert.Equal(5u, list[1].NumAnims); + } + + // ── countdown-chain: ONE AnimationDone completing MULTIPLE entries ── + + [Fact] + public void AnimationDone_CounterRollover_CompletesMultipleQueuedEntries_InOneCall() + { + // Two zero-duration-relative-to-each-other entries stacked so a + // single tick (counter=1) pops BOTH: first has NumAnims=1 (<=1 on + // the first increment), and after popping it counter -= 1 => back to + // 0... but decomp's while-loop re-tests the NEW head against the + // SAME counter value within the same AnimationDone call (no re- + // increment between pops) — so a second entry with NumAnims<=0 + // (i.e. 0) also pops in the same call. + var (mgr, sink, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 1); // pops when counter reaches 1 + mgr.AddToQueue(RunForward, 0); // zero-duration -> pops same call once counter>=0 + + mgr.AnimationDone(true); + + Assert.Equal(2, sink.Calls.Count); + Assert.Equal((WalkForward, true), sink.Calls[0]); + Assert.Equal((RunForward, true), sink.Calls[1]); + Assert.Empty(mgr.PendingAnimations); + // Drained-list counter reset. + Assert.Equal(0, mgr.AnimationCounter); + } + + [Fact] + public void AnimationDone_MultiTickCountdown_PopsOnlyWhenCounterReachesDuration() + { + var (mgr, sink, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 3); // needs counter >= 3 to pop + + mgr.AnimationDone(true); // counter=1 + Assert.Empty(sink.Calls); + Assert.Single(mgr.PendingAnimations); + Assert.Equal(1, mgr.AnimationCounter); + + mgr.AnimationDone(true); // counter=2 + Assert.Empty(sink.Calls); + Assert.Equal(2, mgr.AnimationCounter); + + mgr.AnimationDone(true); // counter=3 -> pops, counter -= 3 -> 0, list drains -> reset stays 0 + Assert.Single(sink.Calls); + Assert.Equal((WalkForward, true), sink.Calls[0]); + Assert.Empty(mgr.PendingAnimations); + Assert.Equal(0, mgr.AnimationCounter); + } + + [Fact] + public void AnimationDone_LeftoverCounter_ResetOnlyWhenListFullyDrains() + { + // Two entries: first pops on tick 1 (duration 1), second needs + // duration 5. After the first pop, counter = 1 - 1 = 0, but the + // list is NOT empty (second entry remains) -> counter must NOT be + // force-reset to something else; it stays at the algebraic value (0). + var (mgr, sink, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 1); + mgr.AddToQueue(RunForward, 5); + + mgr.AnimationDone(true); // counter=1 -> pops Walk (1<=1), counter -= 1 = 0; Run(5) remains, 0<=5? no. + + Assert.Single(sink.Calls); + Assert.Equal((WalkForward, true), sink.Calls[0]); + Assert.Single(mgr.PendingAnimations); + Assert.Equal(RunForward, mgr.PendingAnimations.First().Motion); + Assert.Equal(0, mgr.AnimationCounter); + } + + [Fact] + public void AnimationDone_EmptyQueue_IsNoOp() + { + var (mgr, sink, _, _) = MakeManager(); + + mgr.AnimationDone(true); + + Assert.Empty(sink.Calls); + Assert.Equal(0, mgr.AnimationCounter); + } + + // ── zero-tick sweep (CheckForCompletedMotions) vs counter sweep (AnimationDone) ── + + [Fact] + public void CheckForCompletedMotions_PopsOnlyZeroTickHeads_NeverIncrementsCounter() + { + var (mgr, sink, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 0); // zero-tick: pops immediately + mgr.AddToQueue(RunForward, 2); // non-zero: stays + + mgr.CheckForCompletedMotions(); + + Assert.Single(sink.Calls); + Assert.Equal((WalkForward, true), sink.Calls[0]); // success hardcoded true + Assert.Single(mgr.PendingAnimations); + Assert.Equal(RunForward, mgr.PendingAnimations.First().Motion); + Assert.Equal(0, mgr.AnimationCounter); // untouched + } + + [Fact] + public void CheckForCompletedMotions_NonZeroHead_DoesNotPop() + { + var (mgr, sink, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 4); + + mgr.CheckForCompletedMotions(); + + Assert.Empty(sink.Calls); + Assert.Single(mgr.PendingAnimations); + } + + [Fact] + public void UseTime_IsAliasFor_CheckForCompletedMotions() + { + var (mgr, sink, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 0); + + mgr.UseTime(); + + Assert.Single(sink.Calls); + Assert.Equal((WalkForward, true), sink.Calls[0]); + } + + [Fact] + public void AnimationDone_Distinguished_FromCheckForCompletedMotions_ByCounterUse() + { + // A node with NumAnims=1 does NOT pop via CheckForCompletedMotions + // (which only pops EXACTLY zero), but DOES pop via one AnimationDone + // tick (counter reaches 1). + var (mgrA, sinkA, _, _) = MakeManager(); + mgrA.AddToQueue(WalkForward, 1); + mgrA.CheckForCompletedMotions(); + Assert.Empty(sinkA.Calls); + Assert.Single(mgrA.PendingAnimations); + + var (mgrB, sinkB, _, _) = MakeManager(); + mgrB.AddToQueue(WalkForward, 1); + mgrB.AnimationDone(true); + Assert.Single(sinkB.Calls); + Assert.Empty(mgrB.PendingAnimations); + } + + // ── action-class completion pops MotionState's action FIFO ────────── + + [Fact] + public void AnimationDone_ActionClassMotion_PopsActionHead() + { + var (mgr, sink, state, _) = MakeManager(); + state.AddAction(ThrustMed, 1f); + Assert.Single(state.Actions); + + mgr.AddToQueue(ThrustMed, 0); // zero-duration -> pops on first tick + + mgr.AnimationDone(true); + + Assert.Empty(state.Actions); // popped + Assert.Single(sink.Calls); + Assert.Equal((ThrustMed, true), sink.Calls[0]); + } + + [Fact] + public void CheckForCompletedMotions_ActionClassMotion_PopsActionHead() + { + var (mgr, sink, state, _) = MakeManager(); + state.AddAction(ThrustMed, 1f); + + mgr.AddToQueue(ThrustMed, 0); + mgr.CheckForCompletedMotions(); + + Assert.Empty(state.Actions); + Assert.Single(sink.Calls); + } + + [Fact] + public void AnimationDone_NonActionClassMotion_DoesNotTouchActionFifo() + { + var (mgr, _, state, _) = MakeManager(); + state.AddAction(ThrustMed, 1f); + + mgr.AddToQueue(WalkForward, 0); // cycle-class, not action-class + mgr.AnimationDone(true); + + Assert.Single(state.Actions); // untouched + } + + // ── enter/exit-world drains fire MotionDone(success=false) ────────── + + [Fact] + public void HandleExitWorld_DrainsEntireQueue_FiringMotionDoneFalse_ForEveryEntry() + { + var (mgr, sink, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 0); + mgr.AddToQueue(RunForward, 0); + mgr.AddToQueue(ThrustMed, 0); + + mgr.HandleExitWorld(); + + Assert.Equal(3, sink.Calls.Count); + Assert.All(sink.Calls, c => Assert.False(c.Success)); + Assert.Empty(mgr.PendingAnimations); + } + + [Fact] + public void HandleExitWorld_NonZeroDurationEntries_StillFullyDrained() + { + // Drain loops AnimationDone(false) until head_==0, incrementing the + // counter each call — even non-zero-duration entries eventually pop. + var (mgr, sink, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 3); + + mgr.HandleExitWorld(); + + Assert.Single(sink.Calls); + Assert.Equal((WalkForward, false), sink.Calls[0]); + Assert.Empty(mgr.PendingAnimations); + } + + [Fact] + public void HandleEnterWorld_DrainsQueue_AndStripsLinkAnimations() + { + var loader = new FakeLoader(); + uint linkAnim = 0x03000200u, cycleAnim = 0x03000201u; + loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); + loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); + + var sink = new RecordingSink(); + var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; + var seq = new CSequence(loader); + // Build a link + cyclic tail directly (bypassing GetObjectSequence). + CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(linkAnim, 30f), 1f); + CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(cycleAnim, 30f), 1f); + Assert.Equal(2, seq.Count); + + var mgr = new MotionTableManager(null, state, seq, sink); + mgr.AddToQueue(WalkForward, 0); + + mgr.HandleEnterWorld(); + + // Queue drained, MotionDone(false) fired. + Assert.Single(sink.Calls); + Assert.False(sink.Calls[0].Success); + Assert.Empty(mgr.PendingAnimations); + // Link animation stripped — only the cyclic tail remains. + Assert.Equal(1, seq.Count); + } + + [Fact] + public void HandleEnterWorld_EmptyQueue_StillStripsLinkAnimations_NoOpOnDrain() + { + var loader = new FakeLoader(); + uint linkAnim = 0x03000210u, cycleAnim = 0x03000211u; + loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); + loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); + + var sink = new RecordingSink(); + var state = new MotionState(); + var seq = new CSequence(loader); + CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(linkAnim, 30f), 1f); + CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(cycleAnim, 30f), 1f); + + var mgr = new MotionTableManager(null, state, seq, sink); + + mgr.HandleEnterWorld(); + + Assert.Empty(sink.Calls); + Assert.Equal(1, seq.Count); // link stripped, cyclic tail remains + } + + // ── truncate blocked/allowed matrices for BOTH class masks ────────── + + [Fact] + public void RemoveRedundantLinks_CycleClassTail_EarlierSameMotion_NoBlocker_Truncates() + { + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 5); // earlier same-motion, non-zero + mgr.AddToQueue(WalkForward, 3); // new tail, same motion -> matches the + // earlier node immediately (no + // intervening node to block) -> + // truncate walks tail_..matched + // EXCLUSIVE, so the TAIL node itself + // (this new entry) is in the walked + // range and gets zeroed; the earlier + // matched node (the stop boundary) + // is untouched. + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(2, list.Count); + Assert.Equal(5u, list[0].NumAnims); // matched node (stop boundary) untouched + Assert.Equal(0u, list[1].NumAnims); // the redundant re-issue itself, zeroed + } + + [Fact] + public void RemoveRedundantLinks_CycleClassTail_WithInterveningZeroTickNode_Truncates() + { + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 5); // earlier match + mgr.AddToQueue(ThrustMed, 0); // intervening, ZERO ticks -> does not block (only non-zero blocks) + mgr.AddToQueue(WalkForward, 3); // new tail, same motion + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(3, list.Count); + Assert.Equal(5u, list[0].NumAnims); // matched node (stop boundary) untouched + Assert.Equal(0u, list[1].NumAnims); // already zero (unaffected either way) + Assert.Equal(0u, list[2].NumAnims); // the new tail — ALSO in the truncation range + // (truncate walks tail..matched EXCLUSIVE, so + // the tail itself IS zeroed too) + } + + [Fact] + public void RemoveRedundantLinks_CycleClassTail_InterveningDifferentCycle_NotBlocked_Truncates() + { + // Bit decomposition (verified): 0xb0000000 == STYLE(0x80000000) | + // MODIFIER(0x20000000) | ACTION(0x10000000) — it deliberately + // EXCLUDES the cycle-class bit (0x40000000) itself. So a DIFFERENT + // intervening CYCLE (RunForward) does NOT block a WalkForward + // cycle-tail scan; two cycles naturally supersede each other via the + // base-cycle rebuild mechanism. + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 5); // earlier match candidate + mgr.AddToQueue(RunForward, 4); // intervening, non-zero, CYCLE-class but not in 0xb0000000 -> does not block + mgr.AddToQueue(WalkForward, 3); // new tail, same motion as the earlier node + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(3, list.Count); + Assert.Equal(5u, list[0].NumAnims); // matched (stop boundary), untouched + Assert.Equal(0u, list[1].NumAnims); // truncated — RunForward zeroed + Assert.Equal(0u, list[2].NumAnims); // truncated — the new tail itself zeroed + } + + [Fact] + public void RemoveRedundantLinks_CycleClassTail_BlockedByInterveningNonZeroModifierClassNode() + { + // 0xb0000000 DOES include the modifier-class bit (0x20000000) — a + // pure modifier-class node with non-zero ticks blocks a cycle-tail + // collapse (can't safely erase an active modifier's queue entry). + var (mgr, _, _, _) = MakeManager(); + const uint pureModifier = 0x20000099u; // modifier-class only, no cycle/action/style bits + mgr.AddToQueue(WalkForward, 5); + mgr.AddToQueue(pureModifier, 2); // non-zero, matches 0xb0000000 -> BLOCKS + mgr.AddToQueue(WalkForward, 3); + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(3, list.Count); + // Blocked -> nothing truncated, all NumAnims preserved as queued. + Assert.Equal(5u, list[0].NumAnims); + Assert.Equal(2u, list[1].NumAnims); + Assert.Equal(3u, list[2].NumAnims); + } + + [Fact] + public void RemoveRedundantLinks_CycleClassTail_BlockedByInterveningNonZeroActionClassNode() + { + // 0xb0000000 also includes the action-class bit (0x10000000). + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 5); + mgr.AddToQueue(ThrustMed, 2); // non-zero, action-class -> matches 0xb0000000 -> BLOCKS + mgr.AddToQueue(WalkForward, 3); + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(3, list.Count); + Assert.Equal(5u, list[0].NumAnims); + Assert.Equal(2u, list[1].NumAnims); // untouched — blocked + Assert.Equal(3u, list[2].NumAnims); // untouched — blocked + } + + [Fact] + public void RemoveRedundantLinks_CycleClassTail_BlockedByInterveningNonZeroStyleClassNode() + { + // 0xb0000000 also includes the style-class top bit (0x80000000). + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 5); + mgr.AddToQueue(HandCombatStyle, 2); // non-zero, style-class -> matches 0xb0000000 -> BLOCKS + mgr.AddToQueue(WalkForward, 3); + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(3, list.Count); + Assert.Equal(5u, list[0].NumAnims); + Assert.Equal(2u, list[1].NumAnims); + Assert.Equal(3u, list[2].NumAnims); + } + + [Fact] + public void RemoveRedundantLinks_StyleClassTail_EarlierSameMotion_Truncates() + { + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(HandCombatStyle, 5); // style-class (top bit set), earlier match + mgr.AddToQueue(ThrustMed, 0); // zero-tick intervening, no block + mgr.AddToQueue(HandCombatStyle, 3); // new tail, same style motion + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(3, list.Count); + Assert.Equal(5u, list[0].NumAnims); + Assert.Equal(0u, list[1].NumAnims); + Assert.Equal(0u, list[2].NumAnims); + } + + [Fact] + public void RemoveRedundantLinks_StyleClassTail_BlockedByInterveningNonZeroModifierClassNode() + { + // Style-class tail scan blocks on the WIDER 0x70000000 mask + // (cycle|action|modifier) -- a pure modifier-class node DOES block + // here, unlike the cycle-class tail scan above. + var (mgr, _, _, _) = MakeManager(); + const uint pureModifier = 0x20000099u; + mgr.AddToQueue(HandCombatStyle, 5); + mgr.AddToQueue(pureModifier, 2); // non-zero, matches 0x70000000 -> BLOCKS + mgr.AddToQueue(HandCombatStyle, 3); + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(3, list.Count); + Assert.Equal(5u, list[0].NumAnims); + Assert.Equal(2u, list[1].NumAnims); // untouched — blocked + Assert.Equal(3u, list[2].NumAnims); // untouched — blocked + } + + [Fact] + public void RemoveRedundantLinks_StyleClassTail_BlockedByInterveningNonZeroCycleClassNode() + { + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(HandCombatStyle, 5); + mgr.AddToQueue(WalkForward, 4); // non-zero, cycle-class -> matches 0x70000000 too -> BLOCKS + mgr.AddToQueue(HandCombatStyle, 3); + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(3, list.Count); + Assert.Equal(5u, list[0].NumAnims); + Assert.Equal(4u, list[1].NumAnims); + Assert.Equal(3u, list[2].NumAnims); + } + + [Fact] + public void RemoveRedundantLinks_NoEarlierMatch_ScanExhausts_NoTruncation() + { + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(RunForward, 4); + mgr.AddToQueue(WalkForward, 3); // no earlier WalkForward anywhere + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(2, list.Count); + Assert.Equal(4u, list[0].NumAnims); + Assert.Equal(3u, list[1].NumAnims); + } + + [Fact] + public void RemoveRedundantLinks_TrailingZeroTickNodesSkipped_BeforeScanning() + { + // Tail itself is zero-tick -> skip backward past it to find the + // "effective tail" for the class dispatch. + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 5); + mgr.AddToQueue(WalkForward, 0); // trailing zero-tick — skipped as "effective tail" search + + var list = mgr.PendingAnimations.ToList(); + // Effective tail (after skip) = list[0] (WalkForward, 5) itself — + // scanning backward from it finds nothing earlier -> no truncation. + Assert.Equal(2, list.Count); + Assert.Equal(5u, list[0].NumAnims); + Assert.Equal(0u, list[1].NumAnims); + } + + [Fact] + public void RemoveRedundantLinks_AllZeroTickNodes_ScanBottomsOut_ReturnsQuietly() + { + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 0); + mgr.AddToQueue(RunForward, 0); + + // Must not throw; nothing to truncate. + mgr.RemoveRedundantLinks(); + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(2, list.Count); + Assert.Equal(0u, list[0].NumAnims); + Assert.Equal(0u, list[1].NumAnims); + } + + [Fact] + public void RemoveRedundantLinks_EmptyQueue_IsNoOp() + { + var (mgr, _, _, _) = MakeManager(); + mgr.RemoveRedundantLinks(); // must not throw + Assert.Empty(mgr.PendingAnimations); + } + + [Fact] + public void RemoveRedundantLinks_ModifierOrActionClassTail_NoScanPerformed() + { + // A pure modifier-class or action-class tail motion takes NEITHER + // the cycle-tail nor the style-tail branch in retail -> no scan, no + // truncation, regardless of what precedes it. + var (mgr, _, _, _) = MakeManager(); + const uint pureModifier = 0x20000099u; + mgr.AddToQueue(pureModifier, 5); + mgr.AddToQueue(pureModifier, 3); // same motion id repeated, but tail is modifier-class -> no truncation path + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(2, list.Count); + Assert.Equal(5u, list[0].NumAnims); + Assert.Equal(3u, list[1].NumAnims); + } + + // ── truncate_animation_list: zeroes in place, nodes stay queued ───── + + [Fact] + public void Truncate_ViaRedundantCollapse_NodesStayQueued_OnlyNumAnimsZeroed() + { + // RunForward is a DIFFERENT cycle-class motion — not in the + // 0xb0000000 cycle-tail block mask (which excludes the cycle bit) — + // so it does not block WalkForward's re-match scan and gets + // truncated (zeroed in place, still queued) along with the tail. + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 5); + mgr.AddToQueue(RunForward, 2); // will get zeroed but not removed + mgr.AddToQueue(WalkForward, 3); + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(3, list.Count); // node count UNCHANGED — truncate never unlinks + Assert.Equal(RunForward, list[1].Motion); // motion id preserved, only NumAnims zeroed + Assert.Equal(0u, list[1].NumAnims); + } + + [Fact] + public void Truncate_StripsLinkAnimationsFromSequence_ByTotalRemovedTicks() + { + var loader = new FakeLoader(); + uint link1 = 0x03000300u, link2 = 0x03000301u, cyc = 0x03000302u; + loader.Register(link1, Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity)); + loader.Register(link2, Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity)); + loader.Register(cyc, Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity)); + + var sink = new RecordingSink(); + var state = new MotionState(); + var seq = new CSequence(loader); + // Build a 3-node sequence: link1, link2 (both before first_cyclic-ish), + // cyc (the cyclic tail — append_animation slides first_cyclic to it). + CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(link1, 30f), 1f); + CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(link2, 30f), 1f); + CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(cyc, 30f), 1f); + Assert.Equal(3, seq.Count); + + var mgr = new MotionTableManager(null, state, seq, sink); + mgr.AddToQueue(WalkForward, 2); // earlier match, non-zero + mgr.AddToQueue(WalkForward, 2); // new tail, same motion -> truncates the earlier + // one's successor range (which is just this + // node itself, ticks=2) -> RemoveLinkAnimations(2) + + // RemoveLinkAnimations(2) removes up to 2 predecessors of first_cyclic + // (link2, then link1) from the live sequence. + Assert.Equal(1, seq.Count); // only the cyclic tail (cyc) remains + } + + // ── rapid same-motion re-issue -> collapse (Fix B replacement proof) ── + + [Fact] + public void RapidSameMotionReissue_ThroughAddToQueue_CollapsesRedundantMiddleNodes() + { + // Enqueue walk, run, walk, run quickly. Bit decomposition of the + // cycle-tail block mask (0xb0000000 = STYLE|MODIFIER|ACTION, + // deliberately EXCLUDING the cycle bit 0x40000000): a DIFFERENT + // real cycle in between does NOT block a cycle-tail scan, so: + // 1. add W(5) -> [W5] + // 2. add R(4): tail=R, scan W5 -> no match (diff motion), no + // block (W not in 0xb0000000) -> scan exhausts -> [W5,R4] + // 3. add W(3): tail=W, scan R4 -> no match, no block -> scan W5 + // -> MATCH (same motion, non-zero) -> truncate(W5..tail + // exclusive) zeroes R4 and this new W3 -> [W5, R0, W0] + // 4. add R(2): tail=R, scan W0(NumAnims=0, fails the non-zero + // match requirement) -> no block (0 ticks) -> scan R0 + // (NumAnims=0, fails non-zero match requirement too) -> no + // block -> scan W5 -> no match (diff motion), no block -> + // scan exhausts -> nothing new truncated -> [W5, R0, W0, R2] + // This is the Fix-B-replacement proof: the FIRST redundant + // walk->run->walk sequence collapses down to just the original + // WalkForward(5) plus the freshly-added RunForward(2) — matching + // "rapid re-issue of the same locomotion collapses" without + // inventing a bespoke locomotion-low-byte special case (H6). + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 5); + mgr.AddToQueue(RunForward, 4); + mgr.AddToQueue(WalkForward, 3); + mgr.AddToQueue(RunForward, 2); + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(4, list.Count); + Assert.Equal(5u, list[0].NumAnims); // original WalkForward — the surviving anchor + Assert.Equal(0u, list[1].NumAnims); // RunForward — truncated by step 3's collapse + Assert.Equal(0u, list[2].NumAnims); // WalkForward (re-issue) — truncated by step 3's collapse + Assert.Equal(2u, list[3].NumAnims); // newest RunForward — survives (its own scan found no match) + } + + [Fact] + public void RapidSameMotionReissue_ZeroTickIntervening_DoesCollapse() + { + // When the intervening entries are ZERO-tick (already-instant / a + // fast-path re-speed that queued 0 outTicks), the scan is NOT + // blocked (only non-zero nodes block) and the collapse proceeds — + // this IS the realistic Fix-B-replacement shape: rapid re-speeds of + // the SAME cycle produce zero-outTicks entries between the real + // queue events. + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 5); + mgr.AddToQueue(WalkForward, 0); // e.g. a fast-path re-speed, zero outTicks + mgr.AddToQueue(WalkForward, 3); // new tail, same motion + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(3, list.Count); + Assert.Equal(5u, list[0].NumAnims); // matched node preserved + Assert.Equal(0u, list[1].NumAnims); // already zero + Assert.Equal(0u, list[2].NumAnims); // truncated + } + + // ── the 2026-05-03 golden: cyclic->cyclic walk->run, truncate NOT firing ── + + [Fact] + public void Golden_CyclicToCyclicWalkThenRun_BothNodesQueued_TruncateNotFiring() + { + // Quoted golden (Fix B comment block, r2-port-plan.md H6): cyclic-> + // cyclic transitions produce add_to_queue(0x45000005) [WalkForward] + // followed by add_to_queue(0x44000007) [RunForward], with truncate + // NOT firing (different motion ids -> remove_redundant_links' match + // test never succeeds). + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 3); + mgr.AddToQueue(RunForward, 4); + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(2, list.Count); + Assert.Equal(WalkForward, list[0].Motion); + Assert.Equal(3u, list[0].NumAnims); // untouched — no truncation + Assert.Equal(RunForward, list[1].Motion); + Assert.Equal(4u, list[1].NumAnims); // untouched + } + + // ── initialize_state ────────────────────────────────────────────── + + [Fact] + public void InitializeState_QueuesReadySentinel_WithSetDefaultStateOutTicks() + { + var loader = new FakeLoader(); + uint readyAnim = 0x03000400u; + loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); + + var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; + mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; + Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); + + var cmt = new CMotionTable(mt); + var sink = new RecordingSink(); + var state = new MotionState(); + var seq = new CSequence(loader); + var mgr = new MotionTableManager(cmt, state, seq, sink); + + mgr.InitializeState(); + + Assert.Equal(NonCombatStyle, state.Style); + Assert.Equal(Ready, state.Substate); + Assert.Single(mgr.PendingAnimations); + var node = mgr.PendingAnimations.First(); + Assert.Equal(MotionTableManager.ReadySentinel, node.Motion); + // SetDefaultState outTicks = cyclic.num_anims - 1 = 1 - 1 = 0. + Assert.Equal(0u, node.NumAnims); + } + + [Fact] + public void InitializeState_NoTableLoaded_QueuesSentinelWithZeroTicks_NoThrow() + { + var sink = new RecordingSink(); + var state = new MotionState(); + var seq = new CSequence(new NullLoader()); + var mgr = new MotionTableManager(null, state, seq, sink); + + mgr.InitializeState(); + + Assert.Single(mgr.PendingAnimations); + Assert.Equal(MotionTableManager.ReadySentinel, mgr.PendingAnimations.First().Motion); + Assert.Equal(0u, mgr.PendingAnimations.First().NumAnims); + } + + // ── PerformMovement error-code matrix ──────────────────────────────── + + [Fact] + public void PerformMovement_NoTableLoaded_ReturnsError7() + { + var (mgr, _, _, _) = MakeManager(table: null); + + uint result = mgr.PerformMovement(MotionTableMovement.Interpreted(WalkForward, 1f)); + + Assert.Equal(MotionTableManagerError.NoTable, result); + Assert.Equal(7u, result); + } + + [Fact] + public void PerformMovement_InterpretedCommand_Success_QueuesMotionWithOutTicks_ReturnsZero() + { + var loader = new FakeLoader(); + uint readyAnim = 0x03000500u, walkAnim = 0x03000501u, linkAnim = 0x03000502u; + loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); + loader.Register(walkAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); + loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); + + var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; + mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; + Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); + Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(walkAnim, 30f)); + Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, Fixtures.MakeMotionData(linkAnim, 30f)); + + var cmt = new CMotionTable(mt); + var sink = new RecordingSink(); + var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; + var seq = new CSequence(loader); + var mgr = new MotionTableManager(cmt, state, seq, sink); + + uint result = mgr.PerformMovement(MotionTableMovement.Interpreted(WalkForward, 1f)); + + Assert.Equal(MotionTableManagerError.Success, result); + Assert.Equal(WalkForward, state.Substate); + Assert.Single(mgr.PendingAnimations); + Assert.Equal(WalkForward, mgr.PendingAnimations.First().Motion); + // outTicks = link(1) + cycle(1) - 1 = 1 + Assert.Equal(1u, mgr.PendingAnimations.First().NumAnims); + } + + [Fact] + public void PerformMovement_InterpretedCommand_Failure_ReturnsError0x43_NoQueue() + { + var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; + // No cycles registered at all -> DoObjectMotion fails. + var cmt = new CMotionTable(mt); + var (mgr, _, state, _) = MakeManager(cmt); + state.Style = NonCombatStyle; + state.Substate = Ready; + state.SubstateMod = 1f; + + uint result = mgr.PerformMovement(MotionTableMovement.Interpreted(WalkForward, 1f)); + + Assert.Equal(MotionTableManagerError.MotionFailed, result); + Assert.Equal(0x43u, result); + Assert.Empty(mgr.PendingAnimations); + } + + [Fact] + public void PerformMovement_StopInterpretedCommand_Success_QueuesReadySentinel() + { + var loader = new FakeLoader(); + uint runAnim = 0x03000510u, readyAnim = 0x03000511u, exitLinkAnim = 0x03000512u; + loader.Register(runAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); + loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); + loader.Register(exitLinkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); + + var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; + mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; + Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(runAnim, 30f)); + Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); + Fixtures.AddLink(mt, NonCombatStyle, RunForward, Ready, Fixtures.MakeMotionData(exitLinkAnim, 30f)); + + var cmt = new CMotionTable(mt); + var sink = new RecordingSink(); + var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f }; + var seq = new CSequence(loader); + var mgr = new MotionTableManager(cmt, state, seq, sink); + Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _)); + + uint result = mgr.PerformMovement(MotionTableMovement.StopInterpreted(RunForward, 1f)); + + Assert.Equal(MotionTableManagerError.Success, result); + Assert.Equal(Ready, state.Substate); // re-driven to style default + Assert.Single(mgr.PendingAnimations); + Assert.Equal(MotionTableManager.ReadySentinel, mgr.PendingAnimations.First().Motion); + } + + [Fact] + public void PerformMovement_StopInterpretedCommand_Failure_ReturnsError0x43_NoQueue() + { + var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; + var cmt = new CMotionTable(mt); + var (mgr, _, state, _) = MakeManager(cmt); + // No modifier and no matching current substate -> StopObjectMotion fails. + state.Style = NonCombatStyle; + state.Substate = Ready; + state.SubstateMod = 1f; + const uint unrelatedModifier = 0x20000099u; + + uint result = mgr.PerformMovement(MotionTableMovement.StopInterpreted(unrelatedModifier, 1f)); + + Assert.Equal(MotionTableManagerError.MotionFailed, result); + Assert.Empty(mgr.PendingAnimations); + } + + [Fact] + public void PerformMovement_StopCompletely_UnconditionallyQueuesReadySentinel_EvenOnNoOpStop() + { + // Even when StopObjectCompletely finds nothing to stop (returns + // false), PerformMovement STILL queues the Ready sentinel + // unconditionally (decomp §11 note) and returns success. + var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; + // No StyleDefaults entry -> the final StopSequenceMotion(substate=0,...) + // call inside StopObjectCompletely can't resolve a style default either, + // but that's fine: Case A requires substate==0x40000000-class AND == + // state.Substate; with Substate=0 (default MotionState ctor) neither + // stop mechanism matches anything -> both return false -> overall false. + var cmt = new CMotionTable(mt); + var (mgr, _, state, _) = MakeManager(cmt); + // state.Substate stays 0 (default) -> StopObjectCompletely no-ops. + + uint result = mgr.PerformMovement(MotionTableMovement.StopCompletely()); + + Assert.Equal(MotionTableManagerError.Success, result); + Assert.Single(mgr.PendingAnimations); + Assert.Equal(MotionTableManager.ReadySentinel, mgr.PendingAnimations.First().Motion); + } + + [Fact] + public void PerformMovement_StopCompletely_DrainsModifiers_QueuesReadySentinel() + { + var loader = new FakeLoader(); + uint runAnim = 0x03000520u, readyAnim = 0x03000521u, exitLinkAnim = 0x03000522u; + loader.Register(runAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); + loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); + loader.Register(exitLinkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); + + var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; + mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; + Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(runAnim, 30f)); + Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); + Fixtures.AddLink(mt, NonCombatStyle, RunForward, Ready, Fixtures.MakeMotionData(exitLinkAnim, 30f)); + + var cmt = new CMotionTable(mt); + var sink = new RecordingSink(); + var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f }; + var seq = new CSequence(loader); + var mgr = new MotionTableManager(cmt, state, seq, sink); + Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _)); + + uint result = mgr.PerformMovement(MotionTableMovement.StopCompletely()); + + Assert.Equal(MotionTableManagerError.Success, result); + Assert.Equal(Ready, state.Substate); + Assert.Single(mgr.PendingAnimations); + Assert.Equal(MotionTableManager.ReadySentinel, mgr.PendingAnimations.First().Motion); + } + + [Fact] + public void PerformMovement_UnhandledType_ReturnsNotHandled_NoQueue() + { + var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; + var cmt = new CMotionTable(mt); + var (mgr, _, _, _) = MakeManager(cmt); + + uint result = mgr.PerformMovement(new MotionTableMovement(MovementType.RawCommand, WalkForward, 1f)); + + Assert.Equal(MotionTableManagerError.NotHandled, result); + Assert.Empty(mgr.PendingAnimations); + } + + [Fact] + public void PerformMovement_StopRawCommand_ReturnsNotHandled() + { + var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; + var cmt = new CMotionTable(mt); + var (mgr, _, _, _) = MakeManager(cmt); + + uint result = mgr.PerformMovement(new MotionTableMovement(MovementType.StopRawCommand, WalkForward, 1f)); + + Assert.Equal(MotionTableManagerError.NotHandled, result); + } + + // ── AddToQueue calls RemoveRedundantLinks immediately (opportunistic) ── + + [Fact] + public void AddToQueue_CallsRemoveRedundantLinks_Immediately_NotDeferred() + { + var (mgr, _, _, _) = MakeManager(); + mgr.AddToQueue(WalkForward, 5); + mgr.AddToQueue(ThrustMed, 0); + + // Adding a third same-motion WalkForward should IMMEDIATELY trigger + // the collapse as part of THIS AddToQueue call (not deferred to a + // later explicit RemoveRedundantLinks() call). + mgr.AddToQueue(WalkForward, 3); + + var list = mgr.PendingAnimations.ToList(); + Assert.Equal(0u, list[1].NumAnims); // already truncated by the time AddToQueue returns + Assert.Equal(0u, list[2].NumAnims); + } +}