feat(R2-Q3): verbatim MotionTableManager — the pending-animation queue (closes H3, H15-core)

Ports retail's MotionTableManager (0x0051bxxx; r2-motiontable-decomp.md
§11) above the Q2 CMotionTable: add_to_queue 0x0051bfe0 (append +
immediate collapse), remove_redundant_links 0x0051bf20 (TAIL-ANCHORED
single scan, NOT ACE's restructured loop — cycle-tail block mask
0xb0000000 = STYLE|MODIFIER|ACTION, style-tail 0x70000000 =
CYCLE|MODIFIER|ACTION; the decomp's prose gloss of those masks was
imprecise, the literal constants are ported), truncate_animation_list
0x0051bca0 (zero NumAnims IN PLACE from the tail through the matched
node's successor + CSequence.RemoveLinkAnimations), AnimationDone
0x0051bce0 (counter-driven countdown chain: one call can pop MULTIPLE
entries; action-class pops MotionState's action FIFO; drained-list
counter reset), CheckForCompletedMotions 0x0051be00 / UseTime (zero-tick
sweep, success hardcoded true), initialize_state 0x0051c030 (0x41000003
sentinel), HandleEnterWorld/HandleExitWorld drains (MotionDone
success:false; enter also strips link anims), PerformMovement 0x0051c0b0
(error codes 7 / 0x43 / 0; StopCompletely queues the Ready sentinel
UNCONDITIONALLY).

IMotionDoneSink is the R2 seam standing in for CPhysicsObj::MotionDone —
R3 binds MotionInterpreter.MotionDone (r2-port-plan.md §4 contract).

This queue + remove_redundant_links IS the retail mechanism our old
'Fix B' rapid-motion collapse approximated — Q4 deletes Fix B and routes
SetCycle/PlayAction through PerformMovement.

47 conformance tests: countdown-chain tables, truncate blocked/allowed
matrices for both masks, zero-tick vs counter sweep, world drains, the
walk-run-walk-run collapse trace, the 2026-05-03 walk-to-run golden
(both nodes queued, truncate not firing), PerformMovement error matrix.

Register: AD-34 extended (pending_animations managed LinkedList);
AD-35 added (NotHandled sentinel vs retail's dead-code pointer-leak
default case).

Implemented by a dedicated agent against the committed Q3 spec; diff
scope, mask semantics, truncation range, counter reset, and
PerformMovement paths independently verified against the decomp raw
text before commit. Build + full suite green (3,447 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 21:01:50 +02:00
parent 8eff397801
commit aa65990a1d
3 changed files with 1528 additions and 1 deletions

View file

@ -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.
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// The R2 seam standing in for retail's <c>CPhysicsObj::MotionDone</c> — the
/// callback <see cref="MotionTableManager.AnimationDone"/> and
/// <see cref="MotionTableManager.CheckForCompletedMotions"/> 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 <c>MotionInterpreter.MotionDone</c>
/// (pops <c>CMotionInterp.pending_motions</c>, recomputes IsAnimating).
/// </summary>
public interface IMotionDoneSink
{
/// <summary>
/// <c>CPhysicsObj::MotionDone(motion, success)</c>. <paramref name="success"/>
/// is the CALLER's flag: true from the real per-tick animation-done hook
/// (<see cref="MotionTableManager.AnimationDone"/> invoked with
/// <c>success:true</c>), false from the enter/exit-world drains,
/// hardcoded true from <see cref="MotionTableManager.CheckForCompletedMotions"/>.
/// </summary>
void MotionDone(uint motion, bool success);
}
/// <summary>
/// One <c>pending_animations</c> queue node — retail's
/// <c>MotionTableManager::AnimNode</c> (acclient.h:57614; 16 bytes:
/// dllist_next/prev + motion + num_anims). <c>NumAnims</c> doubles as a
/// RELATIVE tick-duration countdown once queued (not an absolute deadline —
/// decomp §11 <c>AnimationDone</c> note), not an anim-array length.
///
/// Register note (reusing the R1 AD-34 wording): retail's intrusive DLList
/// is ported as a managed <see cref="LinkedList{T}"/> of <see cref="PendingMotion"/>;
/// node identity semantics (the tail-anchored backward scan in
/// <see cref="MotionTableManager.RemoveRedundantLinks"/>, the in-place
/// truncation in <see cref="MotionTableManager.TruncateAnimationList"/>) are
/// preserved via <see cref="LinkedListNode{T}"/> references rather than raw
/// prev/next pointers.
/// </summary>
public sealed class PendingMotion
{
public uint Motion;
public uint NumAnims;
public PendingMotion(uint motion, uint numAnims)
{
Motion = motion;
NumAnims = numAnims;
}
}
/// <summary>
/// Retail's <c>MotionTableManager</c> (0x0051bxxx region) — owns the
/// pending-animation queue and the tick-countdown completion machinery that
/// sits ABOVE <see cref="CMotionTable"/>'s pure selection logic.
/// <see cref="PerformMovement"/> is the single chokepoint between a wire-level
/// <see cref="MovementStruct"/> and the motion-table state machine (decomp
/// §11 <c>PerformMovement</c> 0x0051c0b0).
/// </summary>
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;
/// <summary>
/// Combined block-mask used by <see cref="RemoveRedundantLinks"/>'s
/// CYCLE-class tail scan (decomp §11/§15 literal "0xb0000000"). Bit
/// decomposition: <c>0xb0000000 == 0x80000000 (style) | 0x20000000
/// (modifier) | 0x10000000 (action)</c> — 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.
/// </summary>
private const uint CycleTailBlockMask = 0xb0000000u;
/// <summary>
/// Combined block-mask used by <see cref="RemoveRedundantLinks"/>'s
/// STYLE-class tail scan (decomp §11/§15 literal "0x70000000"). Bit
/// decomposition: <c>0x70000000 == 0x40000000 (cycle) | 0x20000000
/// (modifier) | 0x10000000 (action)</c> — 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).
/// </summary>
private const uint StyleTailBlockMask = 0x70000000u;
/// <summary>Sentinel "stop-completely / default-state-installed" motion id
/// (decomp §15 "0x41000003").</summary>
public const uint ReadySentinel = 0x41000003u;
private readonly CMotionTable? _table;
private readonly MotionState _state;
private readonly CSequence _sequence;
private readonly IMotionDoneSink _sink;
private readonly LinkedList<PendingMotion> _pendingAnimations = new(); // pending_animations
private int _animationCounter; // animation_counter (@0x20)
/// <summary>Current motion state — retail's embedded <c>state</c> member
/// (@0x8, 24 bytes). Exposed for callers that need to read style/substate.</summary>
public MotionState State => _state;
/// <summary>
/// <c>Create</c> 0x0051bc50 (@290510). Retail's static factory
/// zero-initializes <c>physics_obj</c>/<c>table</c>, placement-constructs
/// <c>state</c>, zeros <c>animation_counter</c>, and nulls
/// <c>pending_animations</c>'s head/tail — all of which the C# field
/// initializers below already give for free. <paramref name="table"/> may
/// be null (retail: "no motion table loaded" — <see cref="PerformMovement"/>
/// returns error 7 in that case, matching <c>SetMotionTableID</c> never
/// having been called).
/// </summary>
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;
}
/// <summary>Read-only inspection surface for tests: the pending queue in
/// head-to-tail order.</summary>
public IEnumerable<PendingMotion> PendingAnimations => _pendingAnimations;
/// <summary>Read-only inspection surface for tests: the current tick
/// countdown accumulator.</summary>
public int AnimationCounter => _animationCounter;
// ── add_to_queue / remove_redundant_links / truncate_animation_list ────
/// <summary>
/// <c>add_to_queue</c> 0x0051bfe0 (@290854): append a new node to the
/// tail of <c>pending_animations</c>, then opportunistically call
/// <see cref="RemoveRedundantLinks"/> to collapse any now-superseded
/// earlier entries.
/// </summary>
public void AddToQueue(uint motion, uint ticks)
{
_pendingAnimations.AddLast(new PendingMotion(motion, ticks));
RemoveRedundantLinks();
}
/// <summary>
/// <c>remove_redundant_links</c> 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-<c>NumAnims</c> 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
/// (<c>&amp;0x40000000 != 0 &amp;&amp; &amp;0x20000000 == 0</c>): scan backward for an
/// EARLIER node with the SAME motion id AND non-zero <c>NumAnims</c>.
/// 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
/// (<c>(int)motion &lt; 0</c>): 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, <see cref="TruncateAnimationList"/> from the matched
/// node's successor through the tail (matched node itself untouched).
/// </summary>
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<PendingMotion>? 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<PendingMotion>? 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).
}
/// <summary>
/// <c>truncate_animation_list</c> 0x0051bca0 (@290533): walk
/// <c>pending_animations.tail_</c> BACKWARD toward (but not including)
/// <paramref name="stopAtExclusive"/>, zeroing each node's
/// <c>NumAnims</c> 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
/// <see cref="CSequence"/> via <see cref="CSequence.RemoveLinkAnimations"/>.
/// </summary>
private void TruncateAnimationList(LinkedListNode<PendingMotion> 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 ─────────────────
/// <summary>
/// <c>AnimationDone</c> 0x0051bce0 (@290558): advance the animation clock
/// by one tick and fire <see cref="IMotionDoneSink.MotionDone"/> for every
/// queued motion whose relative-duration countdown has elapsed.
/// <c>NumAnims</c> 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 <c>AnimationDone</c> call
/// can pop MULTIPLE queued entries via counter rollover).
/// </summary>
/// <param name="success">Passed straight through to
/// <see cref="IMotionDoneSink.MotionDone"/> for every entry popped this
/// call.</param>
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;
}
/// <summary>
/// <c>CheckForCompletedMotions</c> 0x0051be00 (@290645): pop every head
/// node ALREADY at <c>NumAnims == 0</c> (zero-tick entries, or ones
/// neutered by <see cref="TruncateAnimationList"/>). Unlike
/// <see cref="AnimationDone"/>: no counter increment, no counter
/// decrement, success is hardcoded <c>true</c>.
/// </summary>
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;
}
}
/// <summary><c>UseTime</c> 0x0051bfd0 (@290845): per-frame entry point,
/// tailcalls <see cref="CheckForCompletedMotions"/>.</summary>
public void UseTime() => CheckForCompletedMotions();
// ── initialize_state / HandleEnterWorld / HandleExitWorld ──────────────
/// <summary>
/// <c>initialize_state</c> 0x0051c030 (@290875): install the motion
/// table's baseline state (<see cref="CMotionTable.SetDefaultState"/>) and
/// queue the <see cref="ReadySentinel"/> ("0x41000003" — initial
/// default-state-installed marker) with the resulting tick count, then
/// opportunistically collapse redundant links.
/// </summary>
public void InitializeState()
{
uint outTicks = 0;
if (_table is not null)
{
_table.SetDefaultState(_state, _sequence, out outTicks);
}
AddToQueue(ReadySentinel, outTicks);
}
/// <summary>
/// <c>HandleEnterWorld</c> 0x0051bdd0 (@290634): strip any stale link
/// animations from the live sequence
/// (<see cref="CSequence.RemoveAllLinkAnimations"/>), then fully drain
/// <c>pending_animations</c> exactly like <see cref="HandleExitWorld"/> —
/// each drained entry fires <see cref="IMotionDoneSink.MotionDone"/> with
/// <c>success:false</c> via repeated <see cref="AnimationDone"/> calls
/// (decomp: <c>while (head_ != 0) AnimationDone(this, 0)</c>).
/// </summary>
public void HandleEnterWorld()
{
_sequence.RemoveAllLinkAnimations();
DrainQueue();
}
/// <summary>
/// <c>HandleExitWorld</c> 0x0051bda0 (@290625): fully drain
/// <c>pending_animations</c>, signaling "failure/aborted"
/// (<c>success:false</c>) for every entry via repeated
/// <see cref="AnimationDone"/> calls.
/// </summary>
public void HandleExitWorld() => DrainQueue();
private void DrainQueue()
{
while (_pendingAnimations.First is not null)
AnimationDone(false);
}
// ── PerformMovement ──────────────────────────────────────────────────
/// <summary>
/// <c>PerformMovement</c> 0x0051c0b0 (@290906) — the single chokepoint
/// between a wire-level <see cref="MovementStruct"/> (interpreted
/// command / stop / stop-completely) and the motion-table state machine.
/// Error codes: <c>7</c> = no motion table loaded; <c>0x43</c> =
/// DoObjectMotion/StopObjectMotion returned failure; <c>0</c> = success.
/// Unhandled <see cref="MovementType"/>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 <see cref="MotionTableManagerError.NotHandled"/> instead
/// of leaking a pointer value, since no caller in this codebase depends on
/// that BN quirk.
/// </summary>
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;
}
}
}
/// <summary>
/// <c>PerformMovement</c> error/result codes (decomp §15 "PerformMovement
/// error codes"). Named constants standing in for retail's raw hex return
/// values, kept as plain <see cref="uint"/> to match
/// <see cref="MotionTableManager.PerformMovement"/>'s retail-verbatim return
/// type.
/// </summary>
public static class MotionTableManagerError
{
/// <summary>0 — success.</summary>
public const uint Success = 0u;
/// <summary>7 — no motion table loaded.</summary>
public const uint NoTable = 7u;
/// <summary>0x43 — DoObjectMotion/StopObjectMotion returned failure.</summary>
public const uint MotionFailed = 0x43u;
/// <summary>
/// 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.
/// </summary>
public const uint NotHandled = 0xFFFFFFFFu;
}
/// <summary>
/// Minimal retail-verbatim <c>MovementStruct</c> subset (acclient.h:38069)
/// needed by <see cref="MotionTableManager.PerformMovement"/>: just the
/// dispatch type, the motion id, and the speed scalar
/// (<c>arg2-&gt;params-&gt;speed</c>). Defined here rather than reusing
/// <c>AcDream.Core.Physics.MotionInterpreter.MovementStruct</c> because that
/// type serves a different (CMotionInterp-level) call site with fields
/// (<c>ObjectId</c>, <c>Position</c>, <c>Autonomous</c>,
/// <c>ModifyInterpretedState/RawState</c>) MotionTableManager never reads —
/// CLAUDE.md/plan instruction: do not modify MotionInterpreter.cs.
/// <see cref="AcDream.Core.Physics.MovementType"/> IS reused (its 5 values
/// match retail's <c>MovementTypes::Type</c> 1:1 for the cases
/// MotionTableManager handles).
/// </summary>
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);
}