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