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