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:
parent
8eff397801
commit
aa65990a1d
3 changed files with 1528 additions and 1 deletions
|
|
@ -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-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-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-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<AnimSequenceNode>`; 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<T>`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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
508
src/AcDream.Core/Physics/Motion/MotionTableManager.cs
Normal file
508
src/AcDream.Core/Physics/Motion/MotionTableManager.cs
Normal 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>&0x40000000 != 0 && &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 < 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->params->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);
|
||||||
|
}
|
||||||
1018
tests/AcDream.Core.Tests/Physics/Motion/MotionTableManagerTests.cs
Normal file
1018
tests/AcDream.Core.Tests/Physics/Motion/MotionTableManagerTests.cs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue