feat(R2-Q4): adapter cutover — SetCycle/PlayAction rehosted on PerformMovement; Fix B / fast-path / stop-anim fallback / G17 gate DELETED

AnimationSequencer becomes a thin shim over the verbatim R2 stack:
SetCycle dispatches the style change (Branch 1) then the motion (Branch
2/3/4) through MotionTableManager.PerformMovement; PlayAction is the
same dispatch (actions rebuild via Branch 3; modifiers are Branch 4
physics-only combine_motion — the AP-73 turn-blend mechanism now runs
for real). CurrentStyle/CurrentMotion/CurrentSpeedMod are read-only
mirrors of MotionState (post-adjust_motion, signed mod). Lazy
initialize_state on first drive (retail lazy-create analog) keeps
undriven sequencers do-nothing.

DELETED legacy inventions (net -648 lines): the adapter fast-path
(replaced by Branch-2 fast re-speed: change_cycle_speed +
subtract/combine_motion), Fix B + IsLocomotionCycleLowByte (replaced by
remove_redundant_links on the pending queue — the retail mechanism the
2026-05-03 cdb trace pointed at), the stop-anim low-byte fallback
(replaced by get_link's reversed-key double-hop — retail's actual
backward-walk settle plays the windup link REVERSED), GetLink/BuildNode/
EnqueueMotionData + the G17 HasVelocity/HasOmega gate (add_motion sets
unconditionally), MultiplyCyclicFramerate + the G13 composite,
PlayAction's insert-before-tail machinery, SCFAST/SCFULL diagnostics.
KEPT at the adapter (register rows): the boundary adjust_motion remap +
locomotion velocity/omega synthesis (AP-75, retire R3-W6/R6), K-fix18
skip-link as post-dispatch RemoveAllLinkAnimations (AP-74, retire
R3-W4 when LeaveGround fires it).

GameWindow queue-drain wiring (the §4 G6 seam): each drained AnimDone
hook → manager.AnimationDone(true) (retail AnimDoneHook::Execute
0x00526c20 → Hook_AnimDone 0x0050fda0 chain), UseTime once per tick
(0x00517d57/0x00517d67); IMotionDoneSink bound to an
ACDREAM_DUMP_MOTION recorder (AD-36 — R3 rebinds to
MotionInterpreter.MotionDone).

Conformance: the 11-scenario pre-cutover trace suite (a6235a36) replays
green with six EXPECTED-DIFF annotations (double-hop walk-run routing,
reversed settle links, Branch-4 physics-only modifiers, Branch-1
style-change links, post-adjust mirrors) — everything unannotated is
byte-identical. Legacy suites repaired by a dedicated agent (fixtures
gain the retail-mandatory StyleDefaults + class-bit-tagged ids;
reflection state-poking replaced with real dispatch; zero category-D
regressions — the suspected cursor bug was a fixture class-bit gap),
plus two vacuously-passing tests fixed. Register: stale IA-4 deleted
(R1-P1 ported the negative-factor swap).

Full suite green: 3,458 passed (374+425+713+1946).

Closes H6, H7, H8, H9, H10-adapter, H16-wiring (r2-port-plan.md §3 Q4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 21:41:27 +02:00
parent cd0289bea2
commit 3b9d9bb6be
6 changed files with 562 additions and 646 deletions

View file

@ -37,14 +37,13 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 1. Intentional architecture (IA) — 17 rows ## 1. Intentional architecture (IA) — 16 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| IA-1 | Contact-plane pre-seed on grounded movers (**#96 ACCEPTED** per ISSUES.md) — retail's `CTransition::init` clears `contact_plane_valid`; we seed from the body's previous-frame plane | `src/AcDream.Core/Physics/PhysicsEngine.cs:919` | Removing it broke last-step stair `step_up` (`892019b`, reverted); seed propagates the body's *real current* plane, behavior matched retail in the A6.P3 gates | A stale pre-seeded plane lets `AdjustOffset` project sub-step 1 onto a plane retail wouldn't have yet — wrong slope motion / step-up acceptance right after leaving a surface | `CTransition::init`, pc:272547 family | | IA-1 | Contact-plane pre-seed on grounded movers (**#96 ACCEPTED** per ISSUES.md) — retail's `CTransition::init` clears `contact_plane_valid`; we seed from the body's previous-frame plane | `src/AcDream.Core/Physics/PhysicsEngine.cs:919` | Removing it broke last-step stair `step_up` (`892019b`, reverted); seed propagates the body's *real current* plane, behavior matched retail in the A6.P3 gates | A stale pre-seeded plane lets `AdjustOffset` project sub-step 1 onto a plane retail wouldn't have yet — wrong slope motion / step-up acceptance right after leaving a surface | `CTransition::init`, pc:272547 family |
| IA-2 | Lateral self-heal beyond retail's keep-curr: when no candidate contains the sphere, try `FindVisibleChildCell` over the claim's stab-list before keeping the claim | `src/AcDream.Core/Physics/CellTransit.cs:912` | Reuses the recovery retail's own `AdjustPosition` performs (:280028 stab-list mode), applied at the `find_cell_list` site to heal near-miss claims without a doorway crossing | In containment-gap geometry, membership flips to a neighbouring room where retail keeps curr — wrong render root / collision cell at gap positions | `find_cell_list` keep-curr pc:308788-308825; `find_visible_child_cell` :311444 | | IA-2 | Lateral self-heal beyond retail's keep-curr: when no candidate contains the sphere, try `FindVisibleChildCell` over the claim's stab-list before keeping the claim | `src/AcDream.Core/Physics/CellTransit.cs:912` | Reuses the recovery retail's own `AdjustPosition` performs (:280028 stab-list mode), applied at the `find_cell_list` site to heal near-miss claims without a doorway crossing | In containment-gap geometry, membership flips to a neighbouring room where retail keeps curr — wrong render root / collision cell at gap positions | `find_cell_list` keep-curr pc:308788-308825; `find_visible_child_cell` :311444 |
| IA-3 | `get_state_velocity` prefers dat cycle velocity (`MotionData.Velocity × speedMod`) over the decompiled constant; constant kept only as max-speed clamp | `src/AcDream.Core/Physics/MotionInterpreter.cs:315` | Retail's constant equals the Humanoid RunForward `MotionData.Velocity`, so both paths agree on retail dats; dat is ground truth for other MotionTables (r03 §1.3) | Where dat velocity ≠ constant, body speed differs from the retail binary — DR / observer drift on exotic creatures or modded dats | `FUN_00528960`; `_DAT_007c96e0` RunAnimSpeed | | IA-3 | `get_state_velocity` prefers dat cycle velocity (`MotionData.Velocity × speedMod`) over the decompiled constant; constant kept only as max-speed clamp | `src/AcDream.Core/Physics/MotionInterpreter.cs:315` | Retail's constant equals the Humanoid RunForward `MotionData.Velocity`, so both paths agree on retail dats; dat is ground truth for other MotionTables (r03 §1.3) | Where dat velocity ≠ constant, body speed differs from the retail binary — DR / observer drift on exotic creatures or modded dats | `FUN_00528960`; `_DAT_007c96e0` RunAnimSpeed |
| IA-4 | `MultiplyFramerate` omits retail's negative-factor StartFrame↔EndFrame swap (direction encoded in Framerate sign instead) | `src/AcDream.Core/Physics/AnimationSequencer.cs:129` | Our callers (ForwardSpeed updates) only pass positive factors; Advance loop handles negative framerates against StartFrame as lower bound | A future negative-factor caller (reverse playback) scales without swapping bounds — wrong frame range traversal instead of clean reversal | `FUN_005267E0`; ACE Sequence.cs L277-287 |
| IA-5 | Per-ENTITY vertex-derived AABB culling (+5 m animated-drift margin; animated entities bypass cull) vs retail per-PART dat drawing spheres | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:693` (bounds at `src/AcDream.Core/World/WorldEntity.cs:153`, `src/AcDream.Core/Meshing/GfxObjBounds.cs:14`; dead `PerEntityCullRadius=5.0f` at dispatcher :210) | Batched MDI rendering can't cheaply cull per part; bounds derive from the SAME dat vertex data that gets drawn (containment by construction — the **#119** fix, `6a9b529`; memory: feedback_culling_bounds_from_drawn_data) | Geometry escaping bounds+margin (pose drift >5 m, a hydration path skipping `SetLocalBounds`) makes the whole entity vanish on-screen — the #119 vanishing-staircase class | `CGfxObj.drawing_sphere` / viewconeCheck 0x005a09a4 | | IA-5 | Per-ENTITY vertex-derived AABB culling (+5 m animated-drift margin; animated entities bypass cull) vs retail per-PART dat drawing spheres | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:693` (bounds at `src/AcDream.Core/World/WorldEntity.cs:153`, `src/AcDream.Core/Meshing/GfxObjBounds.cs:14`; dead `PerEntityCullRadius=5.0f` at dispatcher :210) | Batched MDI rendering can't cheaply cull per part; bounds derive from the SAME dat vertex data that gets drawn (containment by construction — the **#119** fix, `6a9b529`; memory: feedback_culling_bounds_from_drawn_data) | Geometry escaping bounds+margin (pose drift >5 m, a hydration path skipping `SetLocalBounds`) makes the whole entity vanish on-screen — the #119 vanishing-staircase class | `CGfxObj.drawing_sphere` / viewconeCheck 0x005a09a4 |
| IA-6 | Chat scrollback 500 lines vs retail ~200 (configurable) | `src/AcDream.Core/Chat/ChatLog.cs:19` | Strictly more useful for a dev client + plugins; deliberate default | Negligible — only if a plugin/UI behavior is ever specified against retail's exact retention cap | retail chat scrollback (~200) | | IA-6 | Chat scrollback 500 lines vs retail ~200 (configurable) | `src/AcDream.Core/Chat/ChatLog.cs:19` | Strictly more useful for a dev client + plugins; deliberate default | Negligible — only if a plugin/UI behavior is ever specified against retail's exact retention cap | retail chat scrollback (~200) |
| IA-7 | PhysicsScript replay keyed by (scriptId, entityId) replaces the prior instance; retail's ScriptManager linked list could hold duplicates | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs:51` | Prevents duplicate-stacking on server retriggers; flat keyed list simpler than retail's linked schedule; hedged to retail's common path | A server intentionally layering the same script on the same object shows ONE effect where retail shows several (overlapping casts/impacts) | `ScriptManager::Start` FUN_0051be40 / tick FUN_0051bfb0 | | IA-7 | PhysicsScript replay keyed by (scriptId, entityId) replaces the prior instance; retail's ScriptManager linked list could hold duplicates | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs:51` | Prevents duplicate-stacking on server retriggers; flat keyed list simpler than retail's linked schedule; hedged to retail's common path | A server intentionally layering the same script on the same object shows ONE effect where retail shows several (overlapping casts/impacts) | `ScriptManager::Start` FUN_0051be40 / tick FUN_0051bfb0 |
@ -61,7 +60,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 2. Adaptation (AD) — 34 rows ## 2. Adaptation (AD) — 35 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -99,10 +98,11 @@ accepted-divergence entries (#96, #49, #50).
| 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` 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-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) | | 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) |
| AD-36 | `IMotionDoneSink.MotionDone` is OBSERVED, not consumed: R2-Q4 binds the seam to an ACDREAM_DUMP_MOTION diagnostic recorder (`GameWindow` TickAnimations bind site); retail's chain continues `CPhysicsObj::MotionDone``MovementManager::MotionDone``CMotionInterp::MotionDone` popping `pending_motions` (R2-Q4, 2026-07-02) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`MotionDoneTarget`) | Stage boundary: `CMotionInterp.pending_motions` does not exist until R3-W2, which rebinds the seam to `MotionInterpreter.MotionDone` (r3-port-plan.md §4) | Until R3, nothing tracks queued-motion completion on the interp side — jump_is_allowed's pending-head peek and action RemoveAction mirrors are inert (they don't exist yet either) | `CPhysicsObj::MotionDone` 0x0050fdb0; r2-port-plan.md §4 |
--- ---
## 3. Documented approximation (AP) — 69 rows ## 3. Documented approximation (AP) — 72 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -180,6 +180,8 @@ accepted-divergence entries (#96, #49, #50).
--- ---
| AP-73 | **Remote motion sink maps retail's substate+modifier animation model onto ONE cycle** — the L.2g S2 funnel dispatches retail-verbatim (style/forward/sidestep/turn per `apply_interpreted_movement` order + action-stamp gate), but `RemoteMotionSink.Commit` resolves the axes into a single `SetCycle` (forward locomotion > sidestep > turn priority + Run→Walk→Ready missing-cycle fallback); retail BLENDS modifiers over the substate cycle via `CMotionTable::re_modify` (DEV-9) | `src/AcDream.App/Rendering/RemoteMotionSink.cs` (`Commit`) | The pick + fallback are the pre-S2 behavior moved verbatim (user-verified across L.1/L.3); the STATE side is now fully retail through the funnel, so only the visible-animation composition approximates. Retire with the S3/S6 modifier port (`re_modify` + `subtract_motion` omega/velocity combine) | Run-while-turning shows pure run legs (no lean/turn blend); sidestep-while-walking shows one cycle only — animation-composition mismatch vs retail observers, position unaffected | `CMotionTable::re_modify` (GetObjectSequence tail, pc:298636); `StopSequenceMotion` modifier branch pc:298954 | | AP-73 | **Remote motion sink maps retail's substate+modifier animation model onto ONE cycle** — the L.2g S2 funnel dispatches retail-verbatim (style/forward/sidestep/turn per `apply_interpreted_movement` order + action-stamp gate), but `RemoteMotionSink.Commit` resolves the axes into a single `SetCycle` (forward locomotion > sidestep > turn priority + Run→Walk→Ready missing-cycle fallback); retail BLENDS modifiers over the substate cycle via `CMotionTable::re_modify` (DEV-9) | `src/AcDream.App/Rendering/RemoteMotionSink.cs` (`Commit`) | The pick + fallback are the pre-S2 behavior moved verbatim (user-verified across L.1/L.3); the STATE side is now fully retail through the funnel, so only the visible-animation composition approximates. Retire with the S3/S6 modifier port (`re_modify` + `subtract_motion` omega/velocity combine) | Run-while-turning shows pure run legs (no lean/turn blend); sidestep-while-walking shows one cycle only — animation-composition mismatch vs retail observers, position unaffected | `CMotionTable::re_modify` (GetObjectSequence tail, pc:298636); `StopSequenceMotion` modifier branch pc:298954 |
| AP-74 | **K-fix18 `skipTransitionLink`** post-Q4 expression: `SetCycle(..., skipTransitionLink:true)` strips the just-dispatched transition links via `CSequence.RemoveAllLinkAnimations` so Falling engages instantly on jump start; retail gets the same strip from `LeaveGround``CPhysicsObj::RemoveLinkAnimations` (0x0050fe20) fired by the physics layer detecting ground departure (R2-Q4 rehost of the 2026-04-26 K-fix18) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` skip branch); GameWindow :4830/:10201 callers | Same primitive retail uses, fired from the caller instead of the ground-transition event; pending-queue tick counts intentionally not zeroed (retail's RemoveLinkAnimations doesn't touch the manager queue — the countdown chain absorbs the stall) | If a code path sets the flag outside a genuine ground-departure, links retail would play get eaten; queue completion for the stripped entry reports late (next sentinel) | `LeaveGround` 0x00528b00 → `RemoveLinkAnimations` 0x0050fe20; retire in R3-W4 (flag + call sites deleted, LeaveGround fires it) |
| AP-75 | **Adapter-boundary `adjust_motion` + locomotion velocity/omega synthesis**: `SetCycle` still (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp` — R3 scope; GameWindow's local-player path passes raw ids), and (b) post-dispatch overwrites sequence velocity (low-bytes 05/06/07/0F/10) and omega (0D/0E, only when dat-silent) with the retail locomotion constants — retail drives BODY velocity from `get_state_velocity`, not the sequence accumulators (R2-Q4 carry-over of the pre-Q4 adapter tail) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + tail synthesis) | (a) preserves unadjusted GameWindow callers until R3-W6 unifies the local player onto MotionInterpreter; (b) preserves the remote-DR and local Option-B velocity consumers until R6 root motion drives the body (sibling of IA-3) | (a) none while callers stay in the known set; (b) a dat whose locomotion MotionData carries a REAL velocity different from the constants gets overwritten — exotic-creature speed skew (same class as IA-3's risk) | `CMotionInterp::adjust_motion` @305343; `get_state_velocity` 0x00528960; retire (a) R3-W6, (b) R6 |
## 4. Temporary stopgap (TS) — 33 rows ## 4. Temporary stopgap (TS) — 33 rows
@ -221,7 +223,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 5. Unclear (UN) — 4 rows ## 5. Unclear (UN) — 5 rows
These rows have a missing, contradictory, or never-argued justification. These rows have a missing, contradictory, or never-argued justification.
They are the highest-priority audits: each needs either a recorded They are the highest-priority audits: each needs either a recorded

View file

@ -9875,10 +9875,33 @@ public sealed class GameWindow : IDisposable
} }
seqFrames = ae.Sequencer.Advance(dt); seqFrames = ae.Sequencer.Advance(dt);
// R2-Q4: bind the MotionDone diagnostic recorder once per
// sequencer (the IMotionDoneSink seam's interim consumer —
// R3 binds MotionInterpreter.MotionDone here instead).
if (ae.Sequencer.MotionDoneTarget is null)
{
uint mdGuid = serverGuid;
ae.Sequencer.MotionDoneTarget = (m, ok) =>
{
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
System.Console.WriteLine(
$"[MOTIONDONE] guid={mdGuid:X8} motion=0x{m:X8} success={ok}");
};
}
// Phase E.1: drain animation hooks (footstep sounds, attack // Phase E.1: drain animation hooks (footstep sounds, attack
// damage frames, particle spawns, part swaps, etc.) and fan // damage frames, particle spawns, part swaps, etc.) and fan
// them out to registered subsystems via the hook router. // them out to registered subsystems via the hook router.
// Mirrors ACE's PhysicsObj.add_anim_hook dispatch path. // Mirrors ACE's PhysicsObj.add_anim_hook dispatch path.
//
// R2-Q4 queue-drain wiring (r2-port-plan.md §4, the G6 seam;
// per-tick PLACEMENT provisional until R6 installs retail's
// UpdateObjectInternal order): each drained AnimDone hook
// advances the entity's MotionTableManager countdown
// (retail AnimDoneHook::Execute 0x00526c20 → Hook_AnimDone
// 0x0050fda0 → CPartArray::AnimationDone(1)), and UseTime
// runs once per tick to sweep zero-tick queue entries
// (retail call sites 0x00517d57/0x00517d67).
var hooks = ae.Sequencer.ConsumePendingHooks(); var hooks = ae.Sequencer.ConsumePendingHooks();
if (hooks.Count > 0) if (hooks.Count > 0)
{ {
@ -9888,8 +9911,11 @@ public sealed class GameWindow : IDisposable
var hook = hooks[hi]; var hook = hooks[hi];
if (hook is null) continue; if (hook is null) continue;
_hookRouter.OnHook(ae.Entity.Id, worldPos, hook); _hookRouter.OnHook(ae.Entity.Id, worldPos, hook);
if (hook is DatReaderWriter.Types.AnimationDoneHook)
ae.Sequencer.Manager.AnimationDone(success: true);
} }
} }
ae.Sequencer.Manager.UseTime();
} }
else else
{ {

View file

@ -119,19 +119,28 @@ public sealed class AnimationSequencer
{ {
// ── Public state ───────────────────────────────────────────────────────── // ── Public state ─────────────────────────────────────────────────────────
/// <summary>Current style (stance) command.</summary> /// <summary>
public uint CurrentStyle { get; private set; } /// Current style (stance) command — R2-Q4: read-only mirror of the
/// retail <see cref="MotionState.Style"/> (MotionState OWNS
/// <summary>Current cyclic motion command.</summary> /// style/substate/substate_mod; the adapter no longer keeps copies).
public uint CurrentMotion { get; private set; } /// </summary>
public uint CurrentStyle => _state.Style;
/// <summary> /// <summary>
/// Speed multiplier currently applied to the cyclic tail. Starts at 1.0 /// Current cyclic motion command — mirror of
/// and is updated by <see cref="SetCycle"/> when the same motion is /// <see cref="MotionState.Substate"/>. NOTE (Q4): this is the
/// re-issued with a different speed (which triggers /// POST-adjust_motion substate (WalkBackward reads back as WalkForward
/// <see cref="MultiplyCyclicFramerate"/> instead of a cycle restart). /// with a negative <see cref="CurrentSpeedMod"/>) — retail's interpreted
/// state is post-adjustment.
/// </summary> /// </summary>
public float CurrentSpeedMod { get; private set; } = 1f; public uint CurrentMotion => _state.Substate;
/// <summary>
/// Speed multiplier of the current substate — mirror of
/// <see cref="MotionState.SubstateMod"/> (signed; see
/// <see cref="CurrentMotion"/>).
/// </summary>
public float CurrentSpeedMod => _state.SubstateMod;
/// <summary> /// <summary>
/// Sequence-wide velocity mirror of the core's <see cref="CSequence.Velocity"/> /// Sequence-wide velocity mirror of the core's <see cref="CSequence.Velocity"/>
@ -221,6 +230,23 @@ public sealed class AnimationSequencer
// accumulators. // accumulators.
private readonly CSequence _core; private readonly CSequence _core;
// R2-Q4: the verbatim motion-selection stack. CMotionTable resolves
// (style, substate, speed) requests (Q2); MotionState owns
// style/substate/mod + the modifier/action chains (Q1);
// MotionTableManager owns the pending-animation queue + the
// tick-countdown completion machinery (Q3). SetCycle/PlayAction are
// thin shims over MotionTableManager.PerformMovement.
private readonly CMotionTable _table;
private readonly MotionState _state;
private readonly MotionTableManager _manager;
// Retail lazy-create analog (MovementManager::get_minterp →
// enter_default_state, r3-motioninterp-decomp §6g): the manager's
// initialize_state runs on the FIRST SetCycle/PlayAction, not at
// construction, so a sequencer that is never driven stays do-nothing
// (RenderBootstrap invariant).
private bool _initialized;
// Hooks pending dispatch. Accumulated during Advance (via the // Hooks pending dispatch. Accumulated during Advance (via the
// AdapterHookQueue seam below); drained via ConsumePendingHooks. // AdapterHookQueue seam below); drained via ConsumePendingHooks.
private readonly List<AnimationHook> _pendingHooks = new(); private readonly List<AnimationHook> _pendingHooks = new();
@ -229,14 +255,6 @@ public sealed class AnimationSequencer
// (retail CSequence::update(Frame*), 0x00525b80) — the old adapter // (retail CSequence::update(Frame*), 0x00525b80) — the old adapter
// accumulator fields are gone with ConsumeRootMotionDelta. // accumulator fields are gone with ConsumeRootMotionDelta.
// ── Diagnostics (Commit A 2026-05-03) ───────────────────────────────────
// Throttle clock for the [SCFAST] / [SCFULL] / [SCNULLFALLBACK] log lines
// emitted from SetCycle. Gated on env var ACDREAM_REMOTE_VEL_DIAG=1; reads
// the env var inline rather than caching so a launch can be re-toggled
// without restarting. 0.5s per sequencer instance keeps logs readable
// while still capturing meaningful state changes.
private double _lastSetCycleDiagTime;
// ── Constructor ────────────────────────────────────────────────────────── // ── Constructor ──────────────────────────────────────────────────────────
/// <summary> /// <summary>
@ -259,6 +277,41 @@ public sealed class AnimationSequencer
_loader = loader; _loader = loader;
_core = new CSequence(loader); _core = new CSequence(loader);
_core.HookObj = new AdapterHookQueue(this); _core.HookObj = new AdapterHookQueue(this);
_table = new CMotionTable(motionTable);
_state = new MotionState();
_manager = new MotionTableManager(_table, _state, _core, new ForwardingMotionDoneSink(this));
}
/// <summary>
/// R2-Q4: the entity's <see cref="MotionTableManager"/> — the caller's
/// per-tick drive surface (<see cref="MotionTableManager.AnimationDone"/>
/// per drained AnimDone hook, <see cref="MotionTableManager.UseTime"/>
/// once per tick).
/// </summary>
public MotionTableManager Manager => _manager;
/// <summary>
/// R2-Q4 seam: where <see cref="IMotionDoneSink.MotionDone"/> lands.
/// Null → dropped (diagnostic recorder bound by the host under
/// ACDREAM_DUMP_MOTION); R3 binds MotionInterpreter.MotionDone here
/// (register row: MotionDone observed-not-consumed until R3).
/// </summary>
public Action<uint, bool>? MotionDoneTarget { get; set; }
private sealed class ForwardingMotionDoneSink : IMotionDoneSink
{
private readonly AnimationSequencer _owner;
public ForwardingMotionDoneSink(AnimationSequencer owner) => _owner = owner;
public void MotionDone(uint motion, bool success)
=> _owner.MotionDoneTarget?.Invoke(motion, success);
}
private void EnsureInitialized()
{
if (_initialized)
return;
_initialized = true;
_manager.InitializeState();
} }
// ── Public API ─────────────────────────────────────────────────────────── // ── Public API ───────────────────────────────────────────────────────────
@ -315,10 +368,16 @@ public sealed class AnimationSequencer
/// preserve normal smooth transitions for everything else.</param> /// preserve normal smooth transitions for everything else.</param>
public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false) public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false)
{ {
// ── adjust_motion: remap left→right / backward→forward variants ─── EnsureInitialized();
// ACE MotionInterp.cs:394-428. The MotionTable never stores TurnLeft,
// SideStepLeft, or WalkBackward cycles; the client plays the mirror // ── Q4 boundary normalization (adjust_motion cyclic subset) ───────
// animation with a negated speed so it runs backward. // Retail adjusts BEFORE the table sees the motion (CMotionInterp::
// adjust_motion, R3 scope) — the interpreted state on the wire is
// already post-adjustment, so remote-driven callers never pass these.
// GameWindow's LOCAL-player path (UpdatePlayerAnimation) still passes
// raw TurnLeft/SideStepLeft/WalkBackward, so the adapter normalizes
// ONCE at this boundary. DELETED in R3-W6 when the local player
// unifies onto MotionInterpreter (r3-port-plan.md J15).
uint adjustedMotion = motion; uint adjustedMotion = motion;
float adjustedSpeed = speedMod; float adjustedSpeed = speedMod;
switch (motion & 0xFFFFu) switch (motion & 0xFFFFu)
@ -337,199 +396,35 @@ public sealed class AnimationSequencer
break; break;
} }
// Fast-path: already playing this exact motion. // ── R2-Q4: dispatch through the verbatim motion-selection stack ───
// // Style change first (style-class ids route GetObjectSequence
// Retail (ACE MotionTable.cs:132-139): when motion == current and // Branch 1 — exit link + entry hop + target style's default cycle),
// sign(speedMod) matches, DON'T restart the cycle — just rescale the // then the motion itself (Branch 2 cycle / 3 action / 4 modifier;
// in-flight cyclic-tail's framerate via multiply_cyclic_animation_fr. // the Branch-2 fast re-speed path replaces the old adapter
// This keeps the run/walk loop smooth when a new UpdateMotion arrives // fast-path, remove_redundant_links replaces Fix B, get_link's
// with a different ForwardSpeed (e.g. when the server broadcasts a // reversed-key double-hop replaces the stop-anim fallback).
// player's updated RunRate mid-step). if (style != 0 && style != _state.Style)
// _manager.PerformMovement(MotionTableMovement.Interpreted(style, 1f));
// **Sign-flip case (2026-05-02):** when the server sends adjust_motion'd
// backward walk as `WalkForward + speed=-N`, motion stays 0x45000005
// but speedMod sign flips. We MUST do a full cycle restart in that case
// so the new (negative) framerate takes effect; otherwise the cycle
// keeps playing forward with the old positive framerate and the
// observer sees the player walking forward despite the negative speed.
if (CurrentStyle == style && CurrentMotion == motion
&& _core.FirstCyclic != null && _core.Count > 0
&& MathF.Sign(speedMod) == MathF.Sign(CurrentSpeedMod))
{
if (MathF.Abs(speedMod - CurrentSpeedMod) > 1e-4f
&& MathF.Abs(CurrentSpeedMod) > 1e-6f)
{
MultiplyCyclicFramerate(speedMod / CurrentSpeedMod);
CurrentSpeedMod = speedMod;
}
// D3 (Commit A 2026-05-03): SCFAST — proves whether the fast-path uint dispatchResult = _manager.PerformMovement(
// is firing instead of the full rebuild. Throttled to 0.5s per MotionTableMovement.Interpreted(adjustedMotion, adjustedSpeed));
// instance (re-throttled after A.1 unthrottled experiment).
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
if (nowSec - _lastSetCycleDiagTime > 0.5)
{
System.Console.WriteLine(
$"[SCFAST] motion=0x{motion:X8} speedMod={speedMod:F3} "
+ $"oldSpeedMod={CurrentSpeedMod:F3} "
+ $"qCount={_core.Count} "
+ $"currNodeIsCyclic={ReferenceEquals(_core.CurrAnim, _core.FirstCyclic)}");
_lastSetCycleDiagTime = nowSec;
}
}
return;
}
// Resolve transition link (currentSubstate → adjustedMotion). Pass // K-fix18 (2026-04-26, register row survives → R3): instant-engage
// both speeds — GetLink switches lookup branches based on sign. // cases (Falling on jump start) strip the just-selected transition
// CurrentSpeedMod defaults to 1.0 (positive) on a fresh sequencer, // links so the new cycle starts immediately. Post-Q4 expression:
// so a Ready → WalkBackward transition correctly enters GetLink's // the retail primitive CPhysicsObj::RemoveLinkAnimations
// negative-speed (reversed-key) branch. // (0x0050fe20 → CSequence::remove_all_link_animations) — exactly
// K-fix18: when the caller asked to skip the transition link // what R3-W4's LeaveGround will call, at which point this flag and
// (instant-engage cases like Falling on jump start), force // its GameWindow call sites are deleted. The pending-queue entry's
// linkData to null so only the cycle gets enqueued. // tick count intentionally stays (retail's RemoveLinkAnimations
MotionData? linkData = (skipTransitionLink || CurrentMotion == 0) // does not touch the manager queue; the countdown chain absorbs it).
? null
: GetLink(style, CurrentMotion, CurrentSpeedMod, adjustedMotion, adjustedSpeed);
// Stop-anim fallback: dat-authored leg-settle / turn-stop links are
// keyed under the FORWARD/RIGHT variant only. Stopping from
// WalkBackward / SideStepLeft / TurnLeft hits a null linkData and
// would visibly snap to Ready. The settle anim is direction-agnostic
// (legs come to standing the same way regardless of which way you
// were walking), so retry GetLink with the substate's low-byte
// remapped to its forward/right peer.
if (linkData is null && !skipTransitionLink && CurrentMotion != 0)
{
uint substateLow = CurrentMotion & 0xFFu;
uint adjustedSubstate = substateLow switch
{
0x06u => (CurrentMotion & 0xFFFFFF00u) | 0x05u, // WalkBackward → WalkForward
0x10u => (CurrentMotion & 0xFFFFFF00u) | 0x0Fu, // SideStepLeft → SideStepRight
0x0Eu => (CurrentMotion & 0xFFFFFF00u) | 0x0Du, // TurnLeft → TurnRight
_ => CurrentMotion,
};
if (adjustedSubstate != CurrentMotion)
{
linkData = GetLink(style, adjustedSubstate, CurrentSpeedMod, adjustedMotion, adjustedSpeed);
}
}
// Resolve target cycle using the ADJUSTED motion (TurnRight not TurnLeft).
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
_mtable.Cycles.TryGetValue(cycleKey, out var cycleData);
// ── Rebuild path (R1-P5) ───────────────────────────────────────────
// remove_cyclic_anims (0x00524e40): drop the old cyclic tail; keep
// any non-cyclic head that hasn't been played yet — the core snaps
// curr_anim back to the preceding link node (or clears to empty).
_core.RemoveCyclicAnims();
// K-fix18: when the caller asked for instant-engage, ALSO drain
// any in-flight non-cyclic transition frames from the previous
// cycle. Without this, the old RunForward → ??? link would
// continue draining for ~100 ms before the new Falling cycle
// starts, defeating the "skip the link" intent.
// clear_animations (0x00524dc0): full wipe.
if (skipTransitionLink) if (skipTransitionLink)
{
_core.ClearAnimations();
}
// Clear sequence-wide physics before the rebuild. Retail's
// GetObjectSequence calls sequence.clear_physics() before each
// add_motion chain (MotionTable.cs L100-L101, L152-L153).
_core.ClearPhysics();
// Enqueue link frames (with adjusted speed for left→right remapping).
if (linkData is { Anims.Count: > 0 })
EnqueueMotionData(linkData, adjustedSpeed);
// Enqueue new cycle.
if (cycleData is { Anims.Count: > 0 })
{
EnqueueMotionData(cycleData, adjustedSpeed);
}
else if (_core.Count == 0)
{
// No cycle and no link — nothing to play; reset fully.
CurrentStyle = style;
CurrentMotion = motion;
return;
}
// Fix B (both old AND new locomotion low-byte, #39 2026-05-06): for
// direct cyclic-locomotion → cyclic-locomotion transitions
// (Walk↔Run on Shift toggle, W↔S direct flip, A↔D, Forward↔Strafe),
// land curr_anim on the new CYCLE, not on the link, and remove the
// just-enqueued link nodes from the list entirely.
//
// Why: the transition link's drain time (~100300 ms at
// Framerate 30 × link runSpeed) gets restarted before it can
// end if the user toggles Shift faster than that. curr_anim
// sits on a fresh link every UM and Advance never reaches
// the cycle. User observes "blips forward in walking
// animation" — what they're seeing is the link's
// interpolation pose, never the new cycle.
//
// Conditional on BOTH old AND new being locomotion cycles to
// avoid regressing the cases where the link IS the right
// animation:
// - Idle (Ready) → any cycle: link is the wind-up pose
// - Falling → Ready: landing animation
// - Ready → Sitting/Crouching: pose-change links
// - Combat substates (attack/parry/ready transitions)
// Commit c06b6c5 (reverted in a2ae2ae) demonstrated that
// unconditionally skipping the link breaks all of these.
//
// Retail reference: cdb live trace 2026-05-03 of a Walk→Run
// direct transition logged
// add_to_queue(45000005, looping=1) walk
// add_to_queue(44000007, looping=1) run
// with truncate_animation_list never firing — i.e. retail
// appends the new cycle directly without a separate link
// enqueue or visible link pose for cyclic→cyclic. Our
// structural mismatch was always enqueueing link+cycle and
// forcing curr onto the link; this fix matches retail's
// observed semantics for the locomotion subset.
//
// Retail mechanism = remove_redundant_links (0x0051bf20, R2 scope,
// gap map "Invented behaviors" note). Ported here as
// remove_all_link_animations (the R1 core's verbatim remove-family
// primitive) — this IS Fix B's outcome for the locomotion subset,
// now expressed through the core instead of hand-rolled queue
// surgery.
bool prevIsLocomotion = IsLocomotionCycleLowByte(CurrentMotion & 0xFFu);
bool newIsLocomotion = IsLocomotionCycleLowByte(motion & 0xFFu);
if (prevIsLocomotion && newIsLocomotion && _core.FirstCyclic is not null)
{
_core.RemoveAllLinkAnimations(); _core.RemoveAllLinkAnimations();
}
// D3 (Commit A 2026-05-03): SCFULL — counterpart to SCFAST. Fires on // Failed dispatch (missing cycle for this style, is_allowed reject):
// the full-rebuild SetCycle path. Throttled to 0.5s per instance. // retail leaves sequence AND state untouched — no synthesis either.
// Logs prev CurrentMotion so the line shows the transition directly if (dispatchResult != MotionTableManagerError.Success)
// (e.g. "Run → Ready" = cycle just got reset). return;
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
if (nowSec - _lastSetCycleDiagTime > 0.5)
{
System.Console.WriteLine(
$"[SCFULL] prev=0x{CurrentMotion:X8} -> motion=0x{motion:X8} adjustedMotion=0x{adjustedMotion:X8} "
+ $"speedMod={speedMod:F3} "
+ $"qCount={_core.Count} "
+ $"currNodeIsCyclic={ReferenceEquals(_core.CurrAnim, _core.FirstCyclic)} "
+ $"firstCyclicNull={(_core.FirstCyclic is null)}");
_lastSetCycleDiagTime = nowSec;
}
}
CurrentStyle = style;
CurrentMotion = motion;
CurrentSpeedMod = speedMod;
// ── Synthesize CurrentVelocity for locomotion cycles ────────────── // ── Synthesize CurrentVelocity for locomotion cycles ──────────────
// The Humanoid motion table ships every locomotion MotionData with // The Humanoid motion table ships every locomotion MotionData with
@ -637,48 +532,11 @@ public sealed class AnimationSequencer
private const float RunAnimSpeed = 4.0f; private const float RunAnimSpeed = 4.0f;
private const float SidestepAnimSpeed = 1.25f; private const float SidestepAnimSpeed = 1.25f;
/// <summary> // R2-Q4: MultiplyCyclicFramerate DELETED (zero external callers). The
/// Scale every cyclic node's framerate by <paramref name="factor"/>, // gap-map-G13 composite stand-in (framerate scale + velocity/omega
/// mirroring retail's <c>multiply_cyclic_animation_fr</c> (0x00524940): // rescale) is retired — same-motion re-speeds now route through the
/// walks <c>first_cyclic</c> through the tail of the list and scales // verbatim CMotionTable fast re-speed path (change_cycle_speed +
/// each node's framerate. The non-cyclic head (link frames) is // subtract_motion(old) + combine_motion(new), decomp §5).
/// untouched — those drain at their original framerate, which matches
/// retail: the sequencer "catches up" the transition before applying
/// the new run speed.
///
/// <para>
/// Called from <see cref="SetCycle"/> when the same (style, motion) pair
/// is re-issued with a different speedMod — for instance, when a remote
/// player's ForwardSpeed changes mid-run. Does NOT restart the animation,
/// so footsteps keep planting where they are.
/// </para>
///
/// <para>
/// R1-P5 / gap-map G13: the core's <c>MultiplyCyclicAnimationFramerate</c>
/// touches ONLY node framerates (retail-verbatim). The velocity/omega
/// rescale below is the ADAPTER-level stand-in for retail's
/// <c>change_cycle_speed</c> composite (<c>subtract_motion(oldSpeed)</c>
/// + <c>combine_motion(newSpeed)</c>, algebraically equal to scaling by
/// newSpeed/oldSpeed) — an R2 mechanism this adapter keeps inline until
/// R2 routes speed changes through the real subtract/combine_motion
/// pair. See gap map G13 for the register-row rationale.
/// </para>
/// </summary>
/// <param name="factor">Framerate multiplier (newSpeed / oldSpeed).</param>
public void MultiplyCyclicFramerate(float factor)
{
if (_core.FirstCyclic == null) return;
if (factor < 0f || float.IsNaN(factor) || float.IsInfinity(factor))
return;
_core.MultiplyCyclicAnimationFramerate(factor);
// R2-composite stand-in (G13): retail's change_cycle_speed rescales
// velocity/omega via subtract_motion(old)+combine_motion(new), which
// is algebraically newSpeed/oldSpeed — exactly this factor.
_core.SetVelocity(_core.Velocity * factor);
_core.SetOmega(_core.Omega * factor);
}
/// <summary> /// <summary>
/// Advance the animation by <paramref name="dt"/> seconds and return the /// Advance the animation by <paramref name="dt"/> seconds and return the
@ -751,150 +609,48 @@ public sealed class AnimationSequencer
// contract — for R6's per-tick wiring. // contract — for R6's per-tick wiring.
/// <summary> /// <summary>
/// Play a one-shot action/modifier motion (Jump, emote, attack, etc.) /// Play a one-shot action / modifier motion (Jump, emote, attack, etc.)
/// on top of the current cycle. The action frames are inserted in the /// on top of the current cycle.
/// list immediately before the looping cyclic tail; they drain once
/// and then the cycle resumes naturally.
/// ///
/// <para> /// <para>
/// Retail semantics: actions and modifiers live in /// R2-Q4: a thin shim over
/// <see cref="MotionTable.Modifiers"/> (a separate dict from /// <see cref="MotionTableManager.PerformMovement"/> — action-class ids
/// <see cref="MotionTable.Cycles"/>) keyed by /// (0x10000000) route <c>GetObjectSequence</c> Branch 3 (rebuild:
/// <c>(style &lt;&lt; 16) | (motion &amp; 0xFFFFFF)</c>. A motion like /// substate→action link + base cycle re-added, the action tracked on the
/// <c>Jump = 0x2500003b</c> is a Modifier (class byte 0x25) not a /// MotionState action FIFO and popped by the manager's countdown);
/// SubState — feeding it to <see cref="SetCycle"/> silently fails the /// modifier-class ids (0x20000000) route Branch 4 (PHYSICS-ONLY
/// cycle lookup. Routing through <c>PlayAction</c> instead resolves /// <c>combine_motion</c> — no animation frames; the pre-Q4
/// from the Modifiers table and interleaves the action frames with /// insert-before-tail modifier-anim mechanism was an acdream invention,
/// the ongoing cyclic motion. /// deleted). Unknown ids fail the dispatch and are a no-op, matching the
/// </para> /// pre-Q4 contract.
///
/// <para>
/// If no entry is found in the Modifiers table for the requested
/// motion, this is a no-op.
/// </para>
///
/// <para>
/// R1-P5: inserts directly on the core's internal list via
/// <see cref="CSequence.AnimList"/> / <see cref="CSequence.FirstCyclicNode"/> /
/// <see cref="CSequence.CurrAnimNode"/> (internal members visible within
/// this assembly). Preserves the exact pre-cutover semantics: action
/// nodes insert ahead of the cyclic tail and play once; if the cursor
/// was sitting on the cyclic tail (or nothing was playing), it jumps to
/// the first inserted action node so the action plays immediately.
/// </para> /// </para>
/// </summary> /// </summary>
/// <param name="motionCommand">Raw MotionCommand (e.g. 0x2500003b for Jump).</param> /// <param name="motionCommand">Raw MotionCommand (e.g. 0x2500003b for Jump).</param>
/// <param name="speedMod">Speed multiplier for the action's framerate.</param> /// <param name="speedMod">Speed multiplier for the action's framerate.</param>
public void PlayAction(uint motionCommand, float speedMod = 1f) public void PlayAction(uint motionCommand, float speedMod = 1f)
{ {
// Resolve motion data. The lookup depends on the command's mask class: EnsureInitialized();
// _manager.PerformMovement(MotionTableMovement.Interpreted(motionCommand, speedMod));
// - Action (mask 0x10): stored in the Links dict as the transition
// FROM currentSubstate TO the action motion. Matches ACE
// MotionTable.GetObjectSequence @ line 189-207 (CommandMask.Action).
// - Modifier (mask 0x20): stored in the Modifiers dict, keyed by
// (style<<16) | (motion&0xFFFFFF) (or unstyled key). Matches ACE
// @ line 234-242 (CommandMask.Modifier).
//
// Jump (0x2500003B) has BOTH bits set (0x20|0x04|0x01) but ACE treats
// it via the Modifier path. FallDown (0x10000050) / Jumpup (0x1000004B)
// are pure Actions (mask 0x10) and live in Links.
//
// We try Links first (via GetLink, which reproduces ACE's get_link
// fallback chain). If that fails and the motion is a Modifier, fall
// through to the Modifiers dict.
const uint ActionMask = 0x10000000u;
const uint ModifierMask = 0x20000000u;
MotionData? data = null;
if ((motionCommand & ActionMask) != 0 && CurrentMotion != 0)
{
// Action: look up the transition link from current substate → action.
// Action overlays always play forward (positive speeds) — the
// action speed mod is the caller-supplied modifier, not part of
// the substate cycle's direction.
data = GetLink(CurrentStyle, CurrentMotion, /*substateSpeed:*/ 1f, motionCommand, /*speed:*/ 1f);
}
if (data is null && (motionCommand & ModifierMask) != 0)
{
uint styleKey = CurrentStyle << 16;
int keyStyled = (int)(styleKey | (motionCommand & 0xFFFFFFu));
int keyPlain = (int)(motionCommand & 0xFFFFFFu);
if (!_mtable.Modifiers.TryGetValue(keyStyled, out data))
_mtable.Modifiers.TryGetValue(keyPlain, out data);
}
if (data is null || data.Anims.Count == 0)
return;
// Build AnimSequenceNodes from the action's AnimData list. All
// non-looping — they drain once, then the list falls through to
// first_cyclic. Velocity/omega gate matches EnqueueMotionData (G17).
Vector3 vel = data.Flags.HasFlag(MotionDataFlags.HasVelocity)
? data.Velocity * speedMod : Vector3.Zero;
Vector3 omg = data.Flags.HasFlag(MotionDataFlags.HasOmega)
? data.Omega * speedMod : Vector3.Zero;
var newNodes = new List<AnimSequenceNode>(data.Anims.Count);
foreach (var ad in data.Anims)
{
var node = BuildNode(ad, speedMod);
if (node.HasAnim) newNodes.Add(node);
}
if (newNodes.Count == 0) return;
var animList = _core.AnimList;
var firstCyclicNode = _core.FirstCyclicNode;
// Insert before the cyclic tail (so the action plays, then cycle
// resumes). If there's no cyclic tail yet, append at the end.
LinkedListNode<AnimSequenceNode>? firstInserted = null;
if (firstCyclicNode != null)
{
foreach (var n in newNodes)
{
var inserted = animList.AddBefore(firstCyclicNode, n);
firstInserted ??= inserted;
}
}
else
{
foreach (var n in newNodes)
{
var inserted = animList.AddLast(n);
firstInserted ??= inserted;
}
}
if (data.Flags.HasFlag(MotionDataFlags.HasVelocity))
_core.SetVelocity(vel);
if (data.Flags.HasFlag(MotionDataFlags.HasOmega))
_core.SetOmega(omg);
// If we're currently on the cyclic tail (or nothing was playing),
// jump the cursor back to the first newly-inserted action node so
// the action plays immediately instead of after the next cycle wrap.
var currNode = _core.CurrAnimNode;
bool cursorOnCyclic = currNode != null && ReferenceEquals(currNode, firstCyclicNode);
if (cursorOnCyclic || currNode == null)
{
_core.CurrAnimNode = firstInserted;
if (firstInserted != null)
_core.FrameNumber = firstInserted.Value.GetStartingFrame();
}
} }
/// <summary> /// <summary>
/// Reset the sequencer to an unplaying state without clearing the /// Reset the sequencer to an unplaying state without clearing the
/// motion table reference. /// motion table reference. R2-Q4: drains the pending-animation queue
/// (exit-world semantics — each entry fires MotionDone(success:false))
/// and zeroes the MotionState so the next SetCycle re-runs
/// initialize_state.
/// </summary> /// </summary>
public void Reset() public void Reset()
{ {
_manager.HandleExitWorld();
_core.Clear(); _core.Clear();
_pendingHooks.Clear(); _pendingHooks.Clear();
CurrentStyle = 0; _state.Style = 0;
CurrentMotion = 0; _state.Substate = 0;
CurrentSpeedMod = 1f; _state.SubstateMod = 1f;
_state.ClearModifiers();
_state.ClearActions();
_initialized = false;
} }
// ── Private helpers ────────────────────────────────────────────────────── // ── Private helpers ──────────────────────────────────────────────────────
@ -925,150 +681,13 @@ public sealed class AnimationSequencer
private static readonly AnimationDoneHook AnimationDoneSentinel = private static readonly AnimationDoneHook AnimationDoneSentinel =
new() { Direction = AnimationHookDir.Both }; new() { Direction = AnimationHookDir.Both };
/// <summary> // R2-Q4: GetLink DELETED - re-homed verbatim as CMotionTable.GetLink
/// Look up the transition MotionData for going from <paramref name="substate"/> // (Q2, field-validated reversed-key branch preserved; Q0-pins A1).
/// (current state, played at <paramref name="substateSpeed"/>) to
/// <paramref name="motion"/> (new state, played at <paramref name="speed"/>).
///
/// <para>
/// Port of ACE's MotionTable.get_link (MotionTable.cs:395-426). The lookup
/// path differs by sign of the speeds — the retail/ACE mechanism is two
/// distinct branches:
/// <list type="bullet">
/// <item><b>Both speeds positive</b> (forward → forward, normal case):
/// Look up Links[(style&lt;&lt;16) | substate][motion] — the link FROM
/// substate TO motion. Played forward.</item>
/// <item><b>Either speed negative</b> (any direction reversal —
/// WalkBackward, SideStepLeft, TurnLeft): Look up the REVERSED key
/// Links[(style&lt;&lt;16) | motion][substate] — the link FROM motion TO
/// substate. Played in reverse, this anim visually transitions
/// substate → motion's pose, then the cycle continues from where it
/// left off. Without this branch, Ready→WalkBackward would queue the
/// "start walking forward" link played in reverse, which strands the
/// cursor at the wrong cycle frame and causes the user-visible
/// "left leg twitches forward two times" glitch on the X key.</item>
/// </list>
/// </para>
///
/// DatReaderWriter encodes Links as Dictionary&lt;int, MotionCommandData&gt;
/// where MotionCommandData.MotionData is Dictionary&lt;int, MotionData&gt;.
/// </summary>
private MotionData? GetLink(uint style, uint substate, float substateSpeed, uint motion, float speed)
{
if (speed < 0f || substateSpeed < 0f)
{
// Reversed-direction path: link FROM motion TO substate.
int reversedKey = (int)((style << 16) | (motion & 0xFFFFFFu));
if (_mtable.Links.TryGetValue(reversedKey, out var revLink)
&& revLink.MotionData.TryGetValue((int)substate, out var revResult))
{
return revResult;
}
// Style-defaults fallback per ACE MotionTable.cs:405-409. // R2-Q4: BuildNode + EnqueueMotionData DELETED - MotionData appends are
if (_mtable.StyleDefaults.TryGetValue( // owned by CMotionTable.AddMotion (add_motion 0x005224b0, unconditional
(DatReaderWriter.Enums.MotionCommand)style, out var defaultMotion)) // velocity/omega set = G17 core semantics; the adapter's HasVelocity/
{ // HasOmega gate is retired with them).
int subKey = (int)((style << 16) | (substate & 0xFFFFFFu));
if (_mtable.Links.TryGetValue(subKey, out var subLink)
&& subLink.MotionData.TryGetValue((int)defaultMotion, out var subResult))
{
return subResult;
}
}
return null;
}
// Forward-direction path: link FROM substate TO motion (the original
// implementation pre-K-fix6).
int outerKey1 = (int)((style << 16) | (substate & 0xFFFFFFu));
if (_mtable.Links.TryGetValue(outerKey1, out var cmd1)
&& cmd1.MotionData.TryGetValue((int)motion, out var result1))
{
return result1;
}
// Fallback: style-level catch-all (ACE line 419-422).
int outerKey2 = (int)(style << 16);
if (_mtable.Links.TryGetValue(outerKey2, out var cmd2)
&& cmd2.MotionData.TryGetValue((int)motion, out var result2))
{
return result2;
}
return null;
}
/// <summary>
/// Build one <see cref="AnimSequenceNode"/> from an <see cref="AnimData"/>,
/// speed-scaled retail-style (only Framerate scales — retail
/// <c>AnimData::operator*</c>, 0x00525d00). Returns a node whose
/// <see cref="AnimSequenceNode.HasAnim"/> may be false (unresolved dat
/// id); callers filter those out.
/// </summary>
private AnimSequenceNode BuildNode(AnimData ad, float speedMod)
{
var scaled = new AnimData
{
AnimId = ad.AnimId,
LowFrame = ad.LowFrame,
HighFrame = ad.HighFrame,
Framerate = ad.Framerate * speedMod,
};
return new AnimSequenceNode(scaled, _loader);
}
/// <summary>
/// Append all AnimData entries from <paramref name="motionData"/> to the
/// core's animation list (retail free function <c>add_motion</c>,
/// 0x005224b0). Each AnimData becomes one <see cref="AnimSequenceNode"/>
/// via <see cref="CSequence.AppendAnimation"/>, which slides
/// <c>first_cyclic</c> to the just-appended node on EVERY call (G10) —
/// so a multi-anim MotionData's loop membership is naturally "only the
/// LAST AnimData loops" once a subsequent MotionData is appended, and a
/// single MotionData with N AnimData entries makes the WHOLE MotionData
/// the cyclic tail as of its last entry.
/// </summary>
/// <param name="motionData">The MotionData to enqueue.</param>
/// <param name="speedMod">Speed multiplier for AnimData framerate scaling.</param>
/// <remarks>
/// The <c>isLooping</c> parameter from the pre-cutover signature is
/// deleted — retail has no per-call loop flag (G10); loop membership is
/// purely a function of append order via <c>first_cyclic</c>.
/// </remarks>
private void EnqueueMotionData(MotionData motionData, float speedMod)
{
// Sequence-wide velocity/omega: retail add_motion (0x005224b0) calls
// set_velocity/set_omega UNCONDITIONALLY (G17 core semantics — a
// MotionData without HasVelocity carries a zero Velocity field, so
// "unconditional replace" and "replace with zero" are the same
// retail call). The adapter keeps today's HasVelocity/HasOmega GATE
// (G17 adapter split) so a transient zero-velocity modifier doesn't
// stomp a running cycle's velocity — this is the documented R2
// stand-in until modifiers route through combine_motion instead of
// add_motion. See gap map G17.
if (motionData.Flags.HasFlag(MotionDataFlags.HasVelocity))
_core.SetVelocity(motionData.Velocity * speedMod);
if (motionData.Flags.HasFlag(MotionDataFlags.HasOmega))
_core.SetOmega(motionData.Omega * speedMod);
foreach (var ad in motionData.Anims)
{
// Speed-scale (retail AnimData::operator*, 0x00525d00 — ONLY
// framerate scales) then hand the scaled AnimData straight to
// append_animation; the core resolves + clamps via
// AnimSequenceNode.SetAnimationId and silently discards nodes
// whose dat id doesn't resolve (mirrors BuildNode's HasAnim
// filter without constructing a throwaway node here).
_core.AppendAnimation(new AnimData
{
AnimId = ad.AnimId,
LowFrame = ad.LowFrame,
HighFrame = ad.HighFrame,
Framerate = ad.Framerate * speedMod,
});
}
}
/// <summary> /// <summary>
/// Build the per-part blended transform from the current animation frame. /// Build the per-part blended transform from the current animation frame.
@ -1166,19 +785,10 @@ public sealed class AnimationSequencer
return result; return result;
} }
/// <summary> // R2-Q4: IsLocomotionCycleLowByte DELETED with Fix B - the cyclic-to-
/// True if the given motion-low-byte names a locomotion cycle — // cyclic link-skip is now retail's remove_redundant_links (0x0051bf20,
/// WalkForward (0x05), WalkBackward (0x06), RunForward (0x07), // MotionTableManager.RemoveRedundantLinks) operating on the pending
/// SideStepRight (0x0F), or SideStepLeft (0x10). // queue, not a locomotion-subset special case in the adapter.
/// Used by <see cref="SetCycle"/> to recognise cyclic→cyclic
/// direct transitions and bypass the transition link in that case
/// (retail's observed add_to_queue semantics).
/// </summary>
private static bool IsLocomotionCycleLowByte(uint lowByte)
{
return lowByte == 0x05u || lowByte == 0x06u || lowByte == 0x07u
|| lowByte == 0x0Fu || lowByte == 0x10u;
}
/// <summary> /// <summary>
/// Quaternion slerp matching the retail client's <c>FUN_005360d0</c> /// Quaternion slerp matching the retail client's <c>FUN_005360d0</c>

View file

@ -1,5 +1,6 @@
using AcDream.Core.Physics; using AcDream.Core.Physics;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Xunit; using Xunit;
namespace AcDream.Core.Tests.Physics; namespace AcDream.Core.Tests.Physics;
@ -24,7 +25,13 @@ public sealed class AnimationCommandRouterTests
[Fact] [Fact]
public void RouteWireCommand_SubState_UsesSetCycle() public void RouteWireCommand_SubState_UsesSetCycle()
{ {
var seq = MakeEmptySequencer(); // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0)
// — GetObjectSequence also refuses substate==0, so the MotionTable
// needs both a StyleDefaults entry AND cycles for the style's default
// substate (Ready, installed by initialize_state) and for the routed
// Dead substate, or the whole dispatch chain silently no-ops and
// CurrentStyle/CurrentMotion stay at their zero defaults.
var seq = MakeRoutableSequencer();
var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0011); var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0011);
@ -59,8 +66,66 @@ public sealed class AnimationCommandRouterTests
return new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader()); return new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader());
} }
/// <summary>
/// R2-Q4: a MotionTable that can actually complete a SubState dispatch —
/// StyleDefaults[NonCombat]=Ready (required by initialize_state's
/// SetDefaultState) plus cycles for both Ready (the installed baseline)
/// and Dead (the substate this test routes to). One shared part/anim is
/// enough; RouteWireCommand only inspects CurrentStyle/CurrentMotion.
/// </summary>
private static AnimationSequencer MakeRoutableSequencer()
{
const uint Ready = 0x41000003u;
const uint Dead = 0x40000011u;
const uint AnimId = 0x03000001u;
var setup = new Setup();
setup.Parts.Add(0x01000000u);
setup.DefaultScale.Add(System.Numerics.Vector3.One);
var mt = new MotionTable
{
DefaultStyle = (DatReaderWriter.Enums.MotionCommand)NonCombat,
};
mt.StyleDefaults[(DatReaderWriter.Enums.MotionCommand)NonCombat] =
(DatReaderWriter.Enums.MotionCommand)Ready;
int ReadyKey = (int)((NonCombat << 16) | (Ready & 0xFFFFFFu));
int DeadKey = (int)((NonCombat << 16) | (Dead & 0xFFFFFFu));
mt.Cycles[ReadyKey] = MakeMotionData(AnimId);
mt.Cycles[DeadKey] = MakeMotionData(AnimId);
var loader = new NullAnimationLoader();
loader.Register(AnimId, MakeAnim());
return new AnimationSequencer(setup, mt, loader);
}
private static MotionData MakeMotionData(uint animId)
{
var md = new MotionData();
QualifiedDataId<Animation> qid = animId;
md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 30f });
return md;
}
private static Animation MakeAnim()
{
var anim = new Animation();
var pf = new AnimationFrame(1);
pf.Frames.Add(new Frame
{
Origin = System.Numerics.Vector3.Zero,
Orientation = System.Numerics.Quaternion.Identity,
});
anim.PartFrames.Add(pf);
return anim;
}
private sealed class NullAnimationLoader : IAnimationLoader private sealed class NullAnimationLoader : IAnimationLoader
{ {
public Animation? LoadAnimation(uint id) => null; private readonly System.Collections.Generic.Dictionary<uint, Animation> _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;
} }
} }

View file

@ -254,8 +254,16 @@ public sealed class AnimationSequencerCutoverTraceTests
seq.SetCycle(NC, Ready); seq.SetCycle(NC, Ready);
seq.SetCycle(NC, Walk, 1.0f); seq.SetCycle(NC, Walk, 1.0f);
seq.SetCycle(NC, Run, 2.0f); seq.SetCycle(NC, Run, 2.0f);
// EXPECTED-DIFF(Q4): pre-cutover Fix B stripped every link leaving
// "102@60.0*^". The verbatim GetObjectSequence (Branch 2, no direct
// Walk->Run link in this fixture) routes the DOUBLE-HOP: Walk->Ready
// settle (105 at the OLD substate mod) + Ready->Run windup (104 at
// the new speed) + the Run cycle; the old Ready->Walk link (103)
// keeps draining first (pending-queue discipline). Rapid same-motion
// re-issues now collapse via remove_redundant_links (the retail
// Fix B), not an adapter locomotion special case.
Assert.Equal( Assert.Equal(
"102@60.0*^ | frame=0.0 vel=(0.00,8.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=44000007 mod=2.00", "103@30.0^,105@30.0,104@60.0,102@60.0* | frame=0.0 vel=(0.00,8.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=44000007 mod=2.00",
Describe(seq, loader)); Describe(seq, loader));
} }
@ -267,8 +275,12 @@ public sealed class AnimationSequencerCutoverTraceTests
seq.SetCycle(NC, Walk, 1.0f); seq.SetCycle(NC, Walk, 1.0f);
seq.SetCycle(NC, Run, 2.0f); seq.SetCycle(NC, Run, 2.0f);
seq.SetCycle(NC, Run, 2.5f); seq.SetCycle(NC, Run, 2.5f);
// EXPECTED-DIFF(Q4): inherits S3's double-hop list; the re-speed
// itself is the verbatim Branch-2 fast path (change_cycle_speed
// scales ONLY first_cyclic..tail: 102 60->75; links untouched;
// velocity via subtract_motion(2.0)+combine_motion(2.5)).
Assert.Equal( Assert.Equal(
"102@75.0*^ | frame=0.0 vel=(0.00,10.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=44000007 mod=2.50", "103@30.0^,105@30.0,104@60.0,102@75.0* | frame=0.0 vel=(0.00,10.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=44000007 mod=2.50",
Describe(seq, loader)); Describe(seq, loader));
} }
@ -278,11 +290,18 @@ public sealed class AnimationSequencerCutoverTraceTests
var seq = NewSeq(out var loader); var seq = NewSeq(out var loader);
seq.SetCycle(NC, Ready); seq.SetCycle(NC, Ready);
seq.SetCycle(NC, WalkBack, 1.0f); seq.SetCycle(NC, WalkBack, 1.0f);
// Reversed-key GetLink resolves the Walk→Ready link (0x105) played in // Node list BYTE-IDENTICAL to pre-cutover: the reversed-key get_link
// reverse (fr=-19.5 = 30 × -0.65 BackwardsFactor); cursor starts at // resolves the Walk->Ready link (0x105) played in reverse (fr=-19.5 =
// the link's reverse starting frame (HighFrame+1 = 3). // 30 x -0.65 BackwardsFactor); cursor at the reverse starting frame
// (HighFrame+1 = 3).
// EXPECTED-DIFF(Q4), mirrors only: CurrentMotion now reads the
// POST-adjust_motion substate (45000005, was 45000006) and
// CurrentSpeedMod the signed mod (-0.65, was 1.00) - MotionState owns
// the state and retail's interpreted state is post-adjustment. The
// om=(-0.00,...) is IEEE negative zero from add_motion's
// zero-omega x negative-speed multiply (numerically equal to 0).
Assert.Equal( Assert.Equal(
"105@-19.5^,101@-19.5* | frame=3.0 vel=(0.00,-2.03,0.00) om=(0.00,0.00,0.00) style=8000003D motion=45000006 mod=1.00", "105@-19.5^,101@-19.5* | frame=3.0 vel=(0.00,-2.03,0.00) om=(-0.00,-0.00,-0.00) style=8000003D motion=45000005 mod=-0.65",
Describe(seq, loader)); Describe(seq, loader));
} }
@ -293,12 +312,14 @@ public sealed class AnimationSequencerCutoverTraceTests
seq.SetCycle(NC, Ready); seq.SetCycle(NC, Ready);
seq.SetCycle(NC, WalkBack, 1.0f); seq.SetCycle(NC, WalkBack, 1.0f);
seq.SetCycle(NC, Ready, 1.0f); seq.SetCycle(NC, Ready, 1.0f);
// Pre-cutover: the OLD reversed link (0x105@-19.5, mid-drain) stays at // EXPECTED-DIFF(Q4): pre-cutover the stop-anim low-byte fallback
// the head while the settle link (0x105@30, via the stop-anim // re-keyed the settle as 105@30 (forward). The verbatim get_link
// low-byte fallback 0x06→0x05) and the Ready cycle append behind it — // (SubstateMod=-0.65 routes the REVERSED-key branch) resolves the
// link-before-link stacking. // Ready->Walk windup (103) played in REVERSE (fr=-30) - retail's
// actual backward-walk settle. The old reversed link (105@-19.5,
// mid-drain) still drains first, unchanged from pre-cutover.
Assert.Equal( Assert.Equal(
"105@-19.5^,105@30.0,100@30.0* | frame=3.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=41000003 mod=1.00", "105@-19.5^,103@-30.0,100@30.0* | frame=3.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=41000003 mod=1.00",
Describe(seq, loader)); Describe(seq, loader));
} }
@ -332,8 +353,13 @@ public sealed class AnimationSequencerCutoverTraceTests
seq.SetCycle(NC, Ready); seq.SetCycle(NC, Ready);
seq.SetCycle(NC, Walk, 1.0f); seq.SetCycle(NC, Walk, 1.0f);
seq.PlayAction(TurnMod, 1.0f); seq.PlayAction(TurnMod, 1.0f);
// EXPECTED-DIFF(Q4): pre-cutover PlayAction INSERTED the modifier's
// anim (109) before the cyclic tail - an acdream invention. Retail
// Branch 4 is PHYSICS-ONLY combine_motion (the AP-73 mechanism): the
// walk cycle's frames are untouched, the modifier contributes omega
// (0,0,1.5) and is tracked on the MotionState modifier stack.
Assert.Equal( Assert.Equal(
"103@30.0^,109@30.0,101@30.0* | frame=0.0 vel=(0.00,3.12,0.00) om=(0.00,0.00,1.50) style=8000003D motion=45000005 mod=1.00", "103@30.0^,101@30.0* | frame=0.0 vel=(0.00,3.12,0.00) om=(0.00,0.00,1.50) style=8000003D motion=45000005 mod=1.00",
Describe(seq, loader)); Describe(seq, loader));
} }
@ -343,8 +369,12 @@ public sealed class AnimationSequencerCutoverTraceTests
var seq = NewSeq(out var loader); var seq = NewSeq(out var loader);
seq.SetCycle(NC, Ready); seq.SetCycle(NC, Ready);
seq.SetCycle(Style2, Ready, 1.0f); seq.SetCycle(Style2, Ready, 1.0f);
// EXPECTED-DIFF(Q4): pre-cutover switched cycles bare ("107@30.0*^").
// GetObjectSequence Branch 1 (style-change) plays the cross-style
// entry link (10A = Ready->Style2) before the target style's default
// cycle - retail's stance-transition animation.
Assert.Equal( Assert.Equal(
"107@30.0*^ | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000004C motion=41000003 mod=1.00", "10A@30.0^,107@30.0* | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000004C motion=41000003 mod=1.00",
Describe(seq, loader)); Describe(seq, loader));
} }
} }

View file

@ -333,13 +333,21 @@ public sealed class AnimationSequencerTests
[Fact] [Fact]
public void SetCycle_WithTransitionLink_PrependLinkFrames() public void SetCycle_WithTransitionLink_PrependLinkFrames()
{ {
// Two animations: link (2 frames at Y=1) and cycle (4 frames at X=1). // R2-Q4: GetObjectSequence gates Branch 2 (cyclic substates) on the
const uint Style = 0x003Du; // 0x40000000 class bit and Branch 1 (style change) on the top bit —
const uint IdleMotion = 0x0003u; // bare low-word ids like the pre-cutover 0x0003/0x0005 never satisfy
const uint WalkMotion = 0x0005u; // those gates and the dispatch silently fails. Tag Style/IdleMotion/
// WalkMotion with their class bits (masking to the low 24 bits for
// the cycle/link key hash is unaffected — CMotionTable keys on
// `id & 0xFFFFFF`).
const uint Style = 0x8000003Du;
const uint IdleMotion = 0x40000003u;
const uint WalkMotion = 0x40000005u;
const uint IdleAnim = 0x03000012u;
const uint CycleAnim = 0x03000010u; const uint CycleAnim = 0x03000010u;
const uint LinkAnim = 0x03000011u; const uint LinkAnim = 0x03000011u;
var idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity);
var cycleAnim = Fixtures.MakeAnim(4, 1, new Vector3(1, 0, 0), Quaternion.Identity); var cycleAnim = Fixtures.MakeAnim(4, 1, new Vector3(1, 0, 0), Quaternion.Identity);
var linkAnim = Fixtures.MakeAnim(2, 1, new Vector3(0, 1, 0), Quaternion.Identity); var linkAnim = Fixtures.MakeAnim(2, 1, new Vector3(0, 1, 0), Quaternion.Identity);
@ -352,15 +360,26 @@ public sealed class AnimationSequencerTests
fromMotion: IdleMotion, fromMotion: IdleMotion,
toMotion: WalkMotion, toMotion: WalkMotion,
linkAnimId: LinkAnim); linkAnimId: LinkAnim);
// R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) —
// route it at IdleMotion (the state this test "was already playing"
// before priming) and give IdleMotion its own cycle so the real
// SetCycle(Style, IdleMotion) priming call below actually dispatches.
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 30f);
var loader = new FakeLoader(); var loader = new FakeLoader();
loader.Register(IdleAnim, idleAnim);
loader.Register(CycleAnim, cycleAnim); loader.Register(CycleAnim, cycleAnim);
loader.Register(LinkAnim, linkAnim); loader.Register(LinkAnim, linkAnim);
var seq = new AnimationSequencer(setup, mt, loader); var seq = new AnimationSequencer(setup, mt, loader);
// Prime the sequencer as if it was already playing IdleMotion. // Prime the sequencer as if it was already playing IdleMotion — a
SetCurrentMotion(seq, Style, IdleMotion); // real SetCycle call now that CurrentStyle/CurrentMotion are
// read-only mirrors of MotionState (R2-Q4; reflection SetValue no
// longer works, "Property set method not found").
seq.SetCycle(Style, IdleMotion);
seq.SetCycle(Style, WalkMotion); seq.SetCycle(Style, WalkMotion);
@ -382,9 +401,13 @@ public sealed class AnimationSequencerTests
// link's starting pose at the link→cycle boundary. Symptoms: door // link's starting pose at the link→cycle boundary. Symptoms: door
// swing-open flap (frame 0 = closed); player run-stop twitch // swing-open flap (frame 0 = closed); player run-stop twitch
// (frame 0 = mid-stride). // (frame 0 = mid-stride).
const uint Style = 0x003Du; // R2-Q4: class-bit-tagged ids (GetObjectSequence gates Branch 1/2 on
const uint IdleMotion = 0x0003u; // the 0x80000000/0x40000000 bits) — masking to the low 24 bits for
const uint WalkMotion = 0x0005u; // the cycle/link key hash is unaffected.
const uint Style = 0x8000003Du;
const uint IdleMotion = 0x40000003u;
const uint WalkMotion = 0x40000005u;
const uint IdleAnim = 0x03000082u;
const uint CycleAnim = 0x03000080u; const uint CycleAnim = 0x03000080u;
const uint LinkAnim = 0x03000081u; const uint LinkAnim = 0x03000081u;
@ -402,6 +425,7 @@ public sealed class AnimationSequencerTests
// Cycle anim: single frame at Y=0 (the "open" / "idle" rest pose). // Cycle anim: single frame at Y=0 (the "open" / "idle" rest pose).
var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity); var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity);
var idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable( var mt = Fixtures.MakeMtable(
@ -412,13 +436,23 @@ public sealed class AnimationSequencerTests
toMotion: WalkMotion, toMotion: WalkMotion,
linkAnimId: LinkAnim, linkAnimId: LinkAnim,
framerate: 30f); framerate: 30f);
// R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0)
// — route it at IdleMotion (the state we prime through below) with
// its own cycle so the priming SetCycle call actually dispatches.
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 30f);
var loader = new FakeLoader(); var loader = new FakeLoader();
loader.Register(IdleAnim, idleAnim);
loader.Register(CycleAnim, cycleAnim); loader.Register(CycleAnim, cycleAnim);
loader.Register(LinkAnim, linkAnim); loader.Register(LinkAnim, linkAnim);
var seq = new AnimationSequencer(setup, mt, loader); var seq = new AnimationSequencer(setup, mt, loader);
SetCurrentMotion(seq, Style, IdleMotion); // Prime the sequencer as if it was already playing IdleMotion — a
// real SetCycle call (reflection SetValue no longer works against
// the now-read-only CurrentStyle/CurrentMotion mirrors).
seq.SetCycle(Style, IdleMotion);
seq.SetCycle(Style, WalkMotion); seq.SetCycle(Style, WalkMotion);
// Advance to _framePosition ≈ 2.5 — past the last integer frame (2) // Advance to _framePosition ≈ 2.5 — past the last integer frame (2)
@ -440,53 +474,71 @@ public sealed class AnimationSequencerTests
[Fact] [Fact]
public void SetCycle_StopFromWalkBackward_FallsBackToWalkForwardStopLink() public void SetCycle_StopFromWalkBackward_FallsBackToWalkForwardStopLink()
{ {
// Stop-anim asymmetry: the Humanoid motion table only authors a // R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests.
// "stop walking" link under WalkForward (low byte 0x05). Stopping // S6_WalkBackToReady_StopSettleFallback): the pre-cutover adapter had
// from WalkBackward (0x06) without a fallback returns null linkData // an invented "stop-anim low-byte fallback" that re-keyed a
// and the cycle snaps to Ready with no settle blend. Fix: when the // WalkForward->Ready link when a WalkBackward->Ready lookup missed.
// primary GetLink lookup fails, retry with WalkBackward's low byte // Retail has no such fallback — CMotionTable.GetLink's verbatim
// remapped to WalkForward. // reversed-key branch (Q0-pins A1) does the real job: adjust_motion
const uint Style = 0x003Du; // remaps WalkBackward to WalkForward with a NEGATIVE SubstateMod, so
const uint WalkForwardCmd = 0x0005u; // stopping from it drives GetLink's "either speed negative -> swapped
const uint WalkBackCmd = 0x0006u; // keys" path, which resolves the link stored FROM the style default
const uint ReadyCmd = 0x0003u; // (Ready) TO WalkForward and plays it IN REVERSE (the Ready->Walk
// windup run backward as a settle). The fixture below stores that
// link under Links[(style,Ready)][WalkForward] — the opposite
// direction from the old WalkForward->Ready fallback entry — because
// that's the key GetLink's reversed branch actually probes.
const uint Style = 0x8000003Du;
const uint WalkForwardCmd = 0x40000005u;
const uint WalkBackCmd = 0x40000006u;
const uint ReadyCmd = 0x40000003u;
const uint CycleAnim = 0x03000090u; // Ready cycle (Y=0) const uint CycleAnim = 0x03000090u; // Ready cycle (Y=0)
const uint LinkAnim = 0x03000091u; // stop-link (Y=7) const uint LinkAnim = 0x03000091u; // Ready->Walk windup (Y=7), played reversed as the settle
var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity); var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity);
var linkAnim = Fixtures.MakeAnim(4, 1, new Vector3(0, 7, 0), Quaternion.Identity); var linkAnim = Fixtures.MakeAnim(4, 1, new Vector3(0, 7, 0), Quaternion.Identity);
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
// Table: Ready cycle + WalkForward→Ready link. NO WalkBackward→Ready link. // Table: Ready cycle + Ready->WalkForward windup link (probed
// REVERSED by GetLink's swapped-key branch when settling out of a
// negative-SubstateMod substate). No forward WalkBackward cycle is
// needed — adjust_motion remaps WalkBackward to WalkForward with a
// negated + BackwardsFactor-scaled speed before dispatch ever sees it.
var mt = Fixtures.MakeMtable( var mt = Fixtures.MakeMtable(
style: Style, style: Style,
motion: ReadyCmd, motion: ReadyCmd,
cycleAnimId: CycleAnim, cycleAnimId: CycleAnim,
fromMotion: WalkForwardCmd, fromMotion: ReadyCmd,
toMotion: ReadyCmd, toMotion: WalkForwardCmd,
linkAnimId: LinkAnim, linkAnimId: LinkAnim,
framerate: 30f); framerate: 30f);
// WalkForward also needs a cycle — adjust_motion's WalkBackward remap
// dispatches WalkForward's cycle (with the negated/scaled speed) as
// part of entering "backward" motion below.
int walkKey = (int)((Style << 16) | (WalkForwardCmd & 0xFFFFFFu));
mt.Cycles[walkKey] = Fixtures.MakeMotionData(CycleAnim, framerate: 30f);
var loader = new FakeLoader(); var loader = new FakeLoader();
loader.Register(CycleAnim, cycleAnim); loader.Register(CycleAnim, cycleAnim);
loader.Register(LinkAnim, linkAnim); loader.Register(LinkAnim, linkAnim);
var seq = new AnimationSequencer(setup, mt, loader); var seq = new AnimationSequencer(setup, mt, loader);
// Simulate "we were walking backward" — substate = WalkBackward, // Enter WalkBackward for real — SetCycle's adjust_motion head remaps
// substateSpeed = +1 (the original speedMod stored by SetCycle). // this to WalkForward with SubstateMod = -0.65 (BackwardsFactor),
SetCurrentMotion(seq, Style, WalkBackCmd); // which is what makes the SUBSEQUENT stop-to-Ready call route
// GetLink's reversed-key branch.
seq.SetCycle(Style, WalkBackCmd, 1.0f);
seq.SetCycle(Style, ReadyCmd); seq.SetCycle(Style, ReadyCmd);
// Advance a tiny dt — should land on link frame 0 (Y=7), not the // Advance a tiny dt — should land on the reversed windup link
// cycle (Y=0). Without the fallback, linkData is null, only the // (Y=7), not the Ready cycle (Y=0).
// Ready cycle is enqueued, and we read Y=0 immediately.
var transforms = seq.Advance(0.001f); var transforms = seq.Advance(0.001f);
Assert.Single(transforms); Assert.Single(transforms);
Assert.True(transforms[0].Origin.Y > 5f, Assert.True(transforms[0].Origin.Y > 5f,
$"Stop-from-backward should fall back to WalkForward→Ready link " $"Stop-from-backward should resolve GetLink's reversed-key branch "
+ $"(expect Y≈7 from link); got Y={transforms[0].Origin.Y} " + $"(expect Y≈7 from the reversed windup link); got Y={transforms[0].Origin.Y} "
+ "(Y=0 means linkData was null and we snapped to Ready cycle)."); + "(Y=0 means the link didn't resolve and we snapped to the Ready cycle).");
} }
[Fact] [Fact]
@ -581,10 +633,14 @@ public sealed class AnimationSequencerTests
// with negated speed, so the animation plays in reverse. // with negated speed, so the animation plays in reverse.
// We verify this by checking CurrentMotion is still TurnLeft (the // We verify this by checking CurrentMotion is still TurnLeft (the
// original command), but the sequencer internally uses TurnRight's anim. // original command), but the sequencer internally uses TurnRight's anim.
// R2-Q4: Style needs the 0x80000000 top bit and TurnRight/TurnLeft the
// 0x40000000 cycle-class bit — GetObjectSequence's entry/branch gates
// (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity) test the
// FULL command word, not just the low 16 bits adjust_motion remaps.
const uint Style = 0x003Du; // NonCombat const uint Style = 0x8000003Du; // NonCombat
const uint TurnRight = 0x0045000Du; // bit pattern for TurnRight in NonCombat const uint TurnRight = 0x4045000Du; // bit pattern for TurnRight in NonCombat
const uint TurnLeft = 0x0045000Eu; // bit pattern for TurnLeft const uint TurnLeft = 0x4045000Eu; // bit pattern for TurnLeft
const uint AnimId = 0x03000050u; const uint AnimId = 0x03000050u;
// 4-frame animation; each frame has a distinct Z-origin so we can tell // 4-frame animation; each frame has a distinct Z-origin so we can tell
@ -600,6 +656,11 @@ public sealed class AnimationSequencerTests
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable(); var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style; mt.DefaultStyle = (DRWMotionCommand)Style;
// R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0)
// — route the default straight at TurnRight (the only cycle this
// fixture defines) so initialize_state's baseline install succeeds
// and state.Style/Substate are non-zero before the explicit dispatch.
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)TurnRight;
// Register TurnRight cycle (adjusted motion, not TurnLeft). // Register TurnRight cycle (adjusted motion, not TurnLeft).
int cycleKey = (int)((Style << 16) | (TurnRight & 0xFFFFFFu)); int cycleKey = (int)((Style << 16) | (TurnRight & 0xFFFFFFu));
@ -611,8 +672,16 @@ public sealed class AnimationSequencerTests
var seq = new AnimationSequencer(setup, mt, loader); var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, TurnLeft, speedMod: 1f); seq.SetCycle(Style, TurnLeft, speedMod: 1f);
// CurrentMotion should record the original TurnLeft command. // R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests.
Assert.Equal(TurnLeft, seq.CurrentMotion); // S5_WalkBackward_RemapNegativeSpeed's "mirrors only" diff):
// CurrentMotion is now a GET-ONLY mirror of MotionState.Substate,
// and MotionState owns the POST-adjust_motion substate — retail's
// interpreted state IS the adjusted one (TurnRight played reversed),
// not the raw TurnLeft the caller passed in. Pre-cutover the adapter
// kept its own separate CurrentMotion field and never overwrote it
// with the adjusted id; that field no longer exists.
Assert.Equal(TurnRight, seq.CurrentMotion);
Assert.Equal(-1f, seq.CurrentSpeedMod, 3);
// R1-P5 (2026-07-02): pre-cutover this pinned the ACE-fabricated // R1-P5 (2026-07-02): pre-cutover this pinned the ACE-fabricated
// epsilon boundary ((EndFrame+1)-eps ~= 3.99999). Retail's // epsilon boundary ((EndFrame+1)-eps ~= 3.99999). Retail's
@ -633,14 +702,17 @@ public sealed class AnimationSequencerTests
public void Advance_NegativeSpeed_FramePositionDecreases() public void Advance_NegativeSpeed_FramePositionDecreases()
{ {
// Verify that a cycle loaded with negative framerate counts downward. // Verify that a cycle loaded with negative framerate counts downward.
const uint Style = 0x003Du; // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class
const uint Motion = 0x0003u; // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity).
const uint Style = 0x8000003Du;
const uint Motion = 0x40000003u;
const uint AnimId = 0x03000060u; const uint AnimId = 0x03000060u;
var anim = Fixtures.MakeAnim(8, 1, Vector3.Zero, Quaternion.Identity); var anim = Fixtures.MakeAnim(8, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable(); var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style; mt.DefaultStyle = (DRWMotionCommand)Style;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
// Register cycle with NEGATIVE framerate to simulate reverse playback. // Register cycle with NEGATIVE framerate to simulate reverse playback.
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
@ -726,9 +798,11 @@ public sealed class AnimationSequencerTests
{ {
// Queue: [linkNode (2 frames, 10fps, non-looping)] → [cycleNode (4 frames, looping)] // Queue: [linkNode (2 frames, 10fps, non-looping)] → [cycleNode (4 frames, looping)]
// Advance enough to exhaust the link node, then verify we're in the cycle. // Advance enough to exhaust the link node, then verify we're in the cycle.
const uint Style = 0x003Du; // R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames).
const uint IdleMotion = 0x0003u; const uint Style = 0x8000003Du;
const uint WalkMotion = 0x0005u; const uint IdleMotion = 0x40000003u;
const uint WalkMotion = 0x40000005u;
const uint IdleAnim = 0x03000082u;
const uint CycleAnim = 0x03000080u; const uint CycleAnim = 0x03000080u;
const uint LinkAnim = 0x03000081u; const uint LinkAnim = 0x03000081u;
@ -736,6 +810,7 @@ public sealed class AnimationSequencerTests
var linkAnim = Fixtures.MakeAnim(2, 1, new Vector3(0, 5, 0), Quaternion.Identity); var linkAnim = Fixtures.MakeAnim(2, 1, new Vector3(0, 5, 0), Quaternion.Identity);
// Cycle anim: 4 frames, X=9 (distinct marker). // Cycle anim: 4 frames, X=9 (distinct marker).
var cycleAnim = Fixtures.MakeAnim(4, 1, new Vector3(9, 0, 0), Quaternion.Identity); var cycleAnim = Fixtures.MakeAnim(4, 1, new Vector3(9, 0, 0), Quaternion.Identity);
var idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable( var mt = Fixtures.MakeMtable(
@ -746,13 +821,22 @@ public sealed class AnimationSequencerTests
toMotion: WalkMotion, toMotion: WalkMotion,
linkAnimId: LinkAnim, linkAnimId: LinkAnim,
framerate: 10f); framerate: 10f);
// R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0)
// — route it at IdleMotion with its own cycle so the priming
// SetCycle call below actually dispatches.
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 10f);
var loader = new FakeLoader(); var loader = new FakeLoader();
loader.Register(IdleAnim, idleAnim);
loader.Register(CycleAnim, cycleAnim); loader.Register(CycleAnim, cycleAnim);
loader.Register(LinkAnim, linkAnim); loader.Register(LinkAnim, linkAnim);
var seq = new AnimationSequencer(setup, mt, loader); var seq = new AnimationSequencer(setup, mt, loader);
SetCurrentMotion(seq, Style, IdleMotion); // Prime as if already playing IdleMotion — real SetCycle call
// (reflection SetValue no longer works, see WithTransitionLink test).
seq.SetCycle(Style, IdleMotion);
seq.SetCycle(Style, WalkMotion); seq.SetCycle(Style, WalkMotion);
// Link node is 2 frames at 10fps → 0.2s to exhaust. // Link node is 2 frames at 10fps → 0.2s to exhaust.
@ -923,8 +1007,11 @@ public sealed class AnimationSequencerTests
public void Advance_ForwardHookDoesNotFire_OnReversePlayback() public void Advance_ForwardHookDoesNotFire_OnReversePlayback()
{ {
// A hook tagged Direction.Forward should NOT fire when playback is reversed. // A hook tagged Direction.Forward should NOT fire when playback is reversed.
const uint Style = 0x003Du; // R2-Q4: class-bit-tagged ids + retail-mandatory StyleDefaults — the bare
const uint Motion = 0x0003u; // ids made this test pass VACUOUSLY (dispatch silently failed, no anim
// played, so "hook did not fire" held for the wrong reason).
const uint Style = 0x8000003Du;
const uint Motion = 0x40000003u;
const uint AnimId = 0x03000103u; const uint AnimId = 0x03000103u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
@ -937,6 +1024,7 @@ public sealed class AnimationSequencerTests
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable(); var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style; mt.DefaultStyle = (DRWMotionCommand)Style;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData(); var md = new MotionData();
QualifiedDataId<Animation> qid = AnimId; QualifiedDataId<Animation> qid = AnimId;
@ -951,7 +1039,10 @@ public sealed class AnimationSequencerTests
seq.ConsumePendingHooks(); seq.ConsumePendingHooks();
// Reverse playback: cursor starts near frame 4 and counts down. // Reverse playback: cursor starts near frame 4 and counts down.
seq.Advance(0.15f); // 0.25s at -10fps = -2.5 frames → crosses the 3→2 boundary, so the
// hooked frame IS reached (same advance as the Backward sibling test
// — the direction filter, not distance, is what's under test).
seq.Advance(0.25f);
var hooks = seq.ConsumePendingHooks(); var hooks = seq.ConsumePendingHooks();
// Forward-only hook on frame 2 should NOT fire on reverse playback. // Forward-only hook on frame 2 should NOT fire on reverse playback.
@ -961,8 +1052,10 @@ public sealed class AnimationSequencerTests
[Fact] [Fact]
public void Advance_BackwardHook_FiresOnReversePlayback() public void Advance_BackwardHook_FiresOnReversePlayback()
{ {
const uint Style = 0x003Du; // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class
const uint Motion = 0x0003u; // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity).
const uint Style = 0x8000003Du;
const uint Motion = 0x40000003u;
const uint AnimId = 0x03000104u; const uint AnimId = 0x03000104u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
@ -975,6 +1068,7 @@ public sealed class AnimationSequencerTests
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable(); var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style; mt.DefaultStyle = (DRWMotionCommand)Style;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData(); var md = new MotionData();
QualifiedDataId<Animation> qid = AnimId; QualifiedDataId<Animation> qid = AnimId;
@ -1045,8 +1139,13 @@ public sealed class AnimationSequencerTests
public void CurrentVelocity_ExposedFromMotionData_WhenHasVelocity() public void CurrentVelocity_ExposedFromMotionData_WhenHasVelocity()
{ {
// MotionData with HasVelocity flag should surface via CurrentVelocity. // MotionData with HasVelocity flag should surface via CurrentVelocity.
const uint Style = 0x003Du; // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) —
const uint Motion = 0x0003u; // without it, initialize_state's SetDefaultState fails, state.Style
// stays 0, and GetObjectSequence's entry guard rejects every
// dispatch. Route the default straight at Motion (the cycle this
// test cares about).
const uint Style = 0x8000003Du;
const uint Motion = 0x40000003u;
const uint AnimId = 0x03000120u; const uint AnimId = 0x03000120u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
@ -1054,6 +1153,7 @@ public sealed class AnimationSequencerTests
var mt = new MotionTable(); var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style; mt.DefaultStyle = (DRWMotionCommand)Style;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData var md = new MotionData
{ {
@ -1077,8 +1177,12 @@ public sealed class AnimationSequencerTests
[Fact] [Fact]
public void CurrentVelocity_ScaledBySpeedMod() public void CurrentVelocity_ScaledBySpeedMod()
{ {
const uint Style = 0x003Du; // R2-Q4: retail-mandatory StyleDefaults, and Motion needs its
const uint Motion = 0x0003u; // 0x40000000 cycle-class bit — the same-motion re-speed fast path
// (Branch 2, target==substate) still requires the class-bit gate to
// be reached in the first place.
const uint Style = 0x8000003Du;
const uint Motion = 0x40000003u;
const uint AnimId = 0x03000121u; const uint AnimId = 0x03000121u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
@ -1086,6 +1190,7 @@ public sealed class AnimationSequencerTests
var mt = new MotionTable(); var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style; mt.DefaultStyle = (DRWMotionCommand)Style;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData var md = new MotionData
{ {
@ -1112,27 +1217,37 @@ public sealed class AnimationSequencerTests
// When a non-cyclic link node exhausts and we advance_to_next_animation, // When a non-cyclic link node exhausts and we advance_to_next_animation,
// an AnimationDoneHook should be queued so consumers can react (e.g. UI // an AnimationDoneHook should be queued so consumers can react (e.g. UI
// wake-on-idle-complete). // wake-on-idle-complete).
const uint Style = 0x003Du; // R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames).
const uint IdleMotion = 0x0003u; const uint Style = 0x8000003Du;
const uint WalkMotion = 0x0005u; const uint IdleMotion = 0x40000003u;
const uint WalkMotion = 0x40000005u;
const uint IdleAnim = 0x03000132u;
const uint CycleAnim = 0x03000130u; const uint CycleAnim = 0x03000130u;
const uint LinkAnim = 0x03000131u; const uint LinkAnim = 0x03000131u;
var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity); var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity);
var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable( var mt = Fixtures.MakeMtable(
style: Style, motion: WalkMotion, cycleAnimId: CycleAnim, style: Style, motion: WalkMotion, cycleAnimId: CycleAnim,
fromMotion: IdleMotion, toMotion: WalkMotion, linkAnimId: LinkAnim, fromMotion: IdleMotion, toMotion: WalkMotion, linkAnimId: LinkAnim,
framerate: 10f); framerate: 10f);
// R2-Q4: retail-mandatory StyleDefaults — route it at IdleMotion with
// its own cycle so the priming SetCycle call below actually dispatches.
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 10f);
var loader = new FakeLoader(); var loader = new FakeLoader();
loader.Register(IdleAnim, idleAnim);
loader.Register(CycleAnim, cycleAnim); loader.Register(CycleAnim, cycleAnim);
loader.Register(LinkAnim, linkAnim); loader.Register(LinkAnim, linkAnim);
var seq = new AnimationSequencer(setup, mt, loader); var seq = new AnimationSequencer(setup, mt, loader);
SetCurrentMotion(seq, Style, IdleMotion); // Prime as if already playing IdleMotion — real SetCycle call.
seq.SetCycle(Style, IdleMotion);
seq.SetCycle(Style, WalkMotion); seq.SetCycle(Style, WalkMotion);
seq.ConsumePendingHooks(); seq.ConsumePendingHooks();
@ -1150,8 +1265,11 @@ public sealed class AnimationSequencerTests
{ {
// A 10-frame cycle at 10 fps = 1.0s per loop. If we halve the playback // A 10-frame cycle at 10 fps = 1.0s per loop. If we halve the playback
// rate (factor 0.5), advancing 1.0s should produce half a loop (5 frames). // rate (factor 0.5), advancing 1.0s should produce half a loop (5 frames).
const uint Style = 0x003Du; // R2-Q4: Motion needs the 0x40000000 cycle-class bit — GetObjectSequence
const uint Motion = 0x0007u; // RunForward // Branch 2 (and its same-motion fast re-speed path) never triggers on
// a bare low-word id (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity).
const uint Style = 0x8000003Du;
const uint Motion = 0x40000007u; // RunForward
const uint AnimId = 0x03000401u; const uint AnimId = 0x03000401u;
// Unique per-frame Z so we can tell where the cursor lands. // Unique per-frame Z so we can tell where the cursor lands.
@ -1177,6 +1295,11 @@ public sealed class AnimationSequencerTests
Framerate = 10f, Framerate = 10f,
}); });
mt.Cycles[cycleKey] = md; mt.Cycles[cycleKey] = md;
// R2-Q4: the dispatch stack needs the retail-mandatory StyleDefaults
// entry (SetDefaultState 0x005230a0 requires StyleDefaults[DefaultStyle];
// GetObjectSequence refuses a zero style/substate). Real dat tables
// always carry it.
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
var loader = new FakeLoader(); var loader = new FakeLoader();
loader.Register(AnimId, anim); loader.Register(AnimId, anim);
@ -1184,8 +1307,11 @@ public sealed class AnimationSequencerTests
var seq = new AnimationSequencer(setup, mt, loader); var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion, speedMod: 1f); seq.SetCycle(Style, Motion, speedMod: 1f);
// Halve the playback rate. // R2-Q4: halve the playback rate via the retail same-motion re-speed
seq.MultiplyCyclicFramerate(0.5f); // (GetObjectSequence Branch-2 fast path: change_cycle_speed +
// subtract_motion(old) + combine_motion(new), decomp §5) — the old
// MultiplyCyclicFramerate adapter composite is deleted.
seq.SetCycle(Style, Motion, speedMod: 0.5f);
// 10 frames at 5 fps = 2.0s per loop. Advance 1.0s → cursor ~= frame 5. // 10 frames at 5 fps = 2.0s per loop. Advance 1.0s → cursor ~= frame 5.
seq.Advance(1.0f); seq.Advance(1.0f);
@ -1193,7 +1319,8 @@ public sealed class AnimationSequencerTests
Assert.Single(frames); Assert.Single(frames);
Assert.InRange(frames[0].Origin.Z, 4f, 6f); Assert.InRange(frames[0].Origin.Z, 4f, 6f);
// Velocity also scales: originally (0,4,0), now (0,2,0). // Velocity also scales: originally (0,4,0), now (0,2,0)
// (subtract_motion(1.0) + combine_motion(0.5) = ×0.5 net).
Assert.Equal(2f, seq.CurrentVelocity.Y, 1); Assert.Equal(2f, seq.CurrentVelocity.Y, 1);
} }
@ -1202,8 +1329,11 @@ public sealed class AnimationSequencerTests
{ {
// Changing speed mid-cycle must NOT reset the frame cursor — the // Changing speed mid-cycle must NOT reset the frame cursor — the
// animation keeps playing from where it was, just faster/slower. // animation keeps playing from where it was, just faster/slower.
const uint Style = 0x003Du; // R2-Q4: class-bit-tagged ids — the bare ids made this test pass
const uint Motion = 0x0007u; // VACUOUSLY (dispatch silently failed; "cursor unchanged" held
// because nothing moved at all).
const uint Style = 0x8000003Du;
const uint Motion = 0x40000007u;
const uint AnimId = 0x03000402u; const uint AnimId = 0x03000402u;
var anim = new Animation(); var anim = new Animation();
@ -1219,6 +1349,9 @@ public sealed class AnimationSequencerTests
mt.DefaultStyle = (DRWMotionCommand)Style; mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(AnimId, framerate: 10f); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(AnimId, framerate: 10f);
// R2-Q4: retail-mandatory StyleDefaults entry (see
// MultiplyCyclicFramerate_HalvesPlaybackRate).
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
var loader = new FakeLoader(); var loader = new FakeLoader();
loader.Register(AnimId, anim); loader.Register(AnimId, anim);
@ -1228,7 +1361,9 @@ public sealed class AnimationSequencerTests
seq.Advance(0.3f); // cursor ~ frame 3 seq.Advance(0.3f); // cursor ~ frame 3
double before = GetFramePosition(seq); double before = GetFramePosition(seq);
seq.MultiplyCyclicFramerate(2.0f); // R2-Q4: mid-cycle re-speed via the retail Branch-2 fast path — must
// not touch the cursor (change_cycle_speed scales framerates only).
seq.SetCycle(Style, Motion, speedMod: 2.0f);
double after = GetFramePosition(seq); double after = GetFramePosition(seq);
Assert.Equal(before, after, 5); Assert.Equal(before, after, 5);
@ -1241,8 +1376,10 @@ public sealed class AnimationSequencerTests
// NOT reset the cursor — it should call MultiplyCyclicFramerate to // NOT reset the cursor — it should call MultiplyCyclicFramerate to
// keep the run loop smooth (retail behavior for a mid-run RunRate // keep the run loop smooth (retail behavior for a mid-run RunRate
// broadcast). Mirror of ACE MotionTable.cs:132-139 fast-path. // broadcast). Mirror of ACE MotionTable.cs:132-139 fast-path.
const uint Style = 0x003Du; // R2-Q4: Motion needs the 0x40000000 cycle-class bit — see
const uint Motion = 0x0007u; // CurrentVelocity_ExposedFromMotionData_WhenHasVelocity.
const uint Style = 0x8000003Du;
const uint Motion = 0x40000007u;
const uint AnimId = 0x03000403u; const uint AnimId = 0x03000403u;
var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, Quaternion.Identity); var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, Quaternion.Identity);
@ -1273,14 +1410,17 @@ public sealed class AnimationSequencerTests
// surface as (0,4,0) at speedMod=1.0, (0,6,0) at 1.5×, (0,2,0) at // surface as (0,4,0) at speedMod=1.0, (0,6,0) at 1.5×, (0,2,0) at
// 0.5×. The dead-reckoning integrator in TickAnimations reads // 0.5×. The dead-reckoning integrator in TickAnimations reads
// CurrentVelocity each tick, so this has to be accurate. // CurrentVelocity each tick, so this has to be accurate.
const uint Style = 0x003Du; // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class
const uint Motion = 0x0007u; // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity).
const uint Style = 0x8000003Du;
const uint Motion = 0x40000007u;
const uint AnimId = 0x03000405u; const uint AnimId = 0x03000405u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable(); var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style; mt.DefaultStyle = (DRWMotionCommand)Style;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 4, 0) }; var md = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 4, 0) };
@ -1316,8 +1456,10 @@ public sealed class AnimationSequencerTests
{ {
// Guard: the new speed-path must not break the classic // Guard: the new speed-path must not break the classic
// "identical call = no state change" behavior. // "identical call = no state change" behavior.
const uint Style = 0x003Du; // R2-Q4: Motion needs the 0x40000000 cycle-class bit — see
const uint Motion = 0x0007u; // CurrentVelocity_ExposedFromMotionData_WhenHasVelocity.
const uint Style = 0x8000003Du;
const uint Motion = 0x40000007u;
const uint AnimId = 0x03000404u; const uint AnimId = 0x03000404u;
var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, Quaternion.Identity); var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, Quaternion.Identity);
@ -1344,14 +1486,17 @@ public sealed class AnimationSequencerTests
// A turn cycle with MotionData.Omega = (0, 0, 1) rad/sec (yaw) // A turn cycle with MotionData.Omega = (0, 0, 1) rad/sec (yaw)
// should surface as CurrentOmega = (0, 0, 1) after SetCycle. // should surface as CurrentOmega = (0, 0, 1) after SetCycle.
// Scales with speedMod exactly like Velocity. // Scales with speedMod exactly like Velocity.
const uint Style = 0x003Du; // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class
const uint Motion = 0x000Du; // TurnRight // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity).
const uint Style = 0x8000003Du;
const uint Motion = 0x4000000Du; // TurnRight
const uint AnimId = 0x03000701u; const uint AnimId = 0x03000701u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable(); var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style; mt.DefaultStyle = (DRWMotionCommand)Style;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData { Flags = MotionDataFlags.HasOmega, Omega = new Vector3(0, 0, 1.0f) }; var md = new MotionData { Flags = MotionDataFlags.HasOmega, Omega = new Vector3(0, 0, 1.0f) };
@ -1380,19 +1525,27 @@ public sealed class AnimationSequencerTests
// reads the cycle's run-speed and moves the entity smoothly. // reads the cycle's run-speed and moves the entity smoothly.
// Crucial: otherwise remote entities would stutter at every stance // Crucial: otherwise remote entities would stutter at every stance
// transition while the link plays. // transition while the link plays.
const uint Style = 0x003Du; // R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames).
const uint IdleMotion = 0x0003u; const uint Style = 0x8000003Du;
const uint WalkMotion = 0x0005u; const uint IdleMotion = 0x40000003u;
const uint WalkMotion = 0x40000005u;
const uint IdleAnim = 0x03000603u;
const uint CycleAnim = 0x03000601u; const uint CycleAnim = 0x03000601u;
const uint LinkAnim = 0x03000602u; const uint LinkAnim = 0x03000602u;
var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity); var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity);
var idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable(); var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style; mt.DefaultStyle = (DRWMotionCommand)Style;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)WalkMotion; // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0)
// — route it at IdleMotion (the state we prime through below).
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 10f);
int cycleKey = (int)((Style << 16) | (WalkMotion & 0xFFFFFFu)); int cycleKey = (int)((Style << 16) | (WalkMotion & 0xFFFFFFu));
var cycleMd = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 3.12f, 0) }; var cycleMd = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 3.12f, 0) };
@ -1410,11 +1563,13 @@ public sealed class AnimationSequencerTests
mt.Links[linkOuter] = linkCmdData; mt.Links[linkOuter] = linkCmdData;
var loader = new FakeLoader(); var loader = new FakeLoader();
loader.Register(IdleAnim, idleAnim);
loader.Register(CycleAnim, cycleAnim); loader.Register(CycleAnim, cycleAnim);
loader.Register(LinkAnim, linkAnim); loader.Register(LinkAnim, linkAnim);
var seq = new AnimationSequencer(setup, mt, loader); var seq = new AnimationSequencer(setup, mt, loader);
SetCurrentMotion(seq, Style, IdleMotion); // Prime as if already playing IdleMotion — real SetCycle call.
seq.SetCycle(Style, IdleMotion);
seq.SetCycle(Style, WalkMotion); seq.SetCycle(Style, WalkMotion);
// We just enqueued [link(0)][cycle(3.12 forward)]. Current node is // We just enqueued [link(0)][cycle(3.12 forward)]. Current node is
@ -1438,7 +1593,11 @@ public sealed class AnimationSequencerTests
// An Action-class command (mask 0x10) resolves via the Links dict // An Action-class command (mask 0x10) resolves via the Links dict
// keyed by (style, currentSubstate) → motion. Example: a ThrustMed // keyed by (style, currentSubstate) → motion. Example: a ThrustMed
// attack while in SwordCombat stance. // attack while in SwordCombat stance.
const uint Style = 0x003Eu; // SwordCombat // R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry
// (SetDefaultState 0x005230a0 is retail-mandatory — GetObjectSequence
// refuses style==0/substate==0, see
// CurrentVelocity_ExposedFromMotionData_WhenHasVelocity).
const uint Style = 0x8000003Eu; // SwordCombat
const uint IdleMotion = 0x41000003u; // Ready const uint IdleMotion = 0x41000003u; // Ready
const uint ActionMotion = 0x10000058u; // ThrustMed (Action class) const uint ActionMotion = 0x10000058u; // ThrustMed (Action class)
const uint IdleAnimId = 0x03000501u; const uint IdleAnimId = 0x03000501u;
@ -1451,6 +1610,7 @@ public sealed class AnimationSequencerTests
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable(); var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style; mt.DefaultStyle = (DRWMotionCommand)Style;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
@ -1484,7 +1644,9 @@ public sealed class AnimationSequencerTests
// values followed by Ready. Retail keeps currState.Substate at Ready // values followed by Ready. Retail keeps currState.Substate at Ready
// while the action link drains, so the Ready echo must not abort the // while the action link drains, so the Ready echo must not abort the
// in-flight swing. // in-flight swing.
const uint Style = 0x003Du; // R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry
// (see PlayAction_Action_ResolvesFromLinksDict).
const uint Style = 0x8000003Du;
const uint IdleMotion = 0x41000003u; const uint IdleMotion = 0x41000003u;
const uint AttackMotion = 0x10000052u; const uint AttackMotion = 0x10000052u;
const uint IdleAnimId = 0x03000503u; const uint IdleAnimId = 0x03000503u;
@ -1492,6 +1654,7 @@ public sealed class AnimationSequencerTests
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)Style }; var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)Style };
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
@ -1519,11 +1682,19 @@ public sealed class AnimationSequencerTests
[Fact] [Fact]
public void PlayAction_Modifier_ResolvesFromModifiersDict() public void PlayAction_Modifier_ResolvesFromModifiersDict()
{ {
// A Modifier-class command (mask 0x20) — like Jump (0x2500003B) — // R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests.
// resolves from the Modifiers dict, first with style-specific key // S9_TurnModifier): a Modifier-class command (mask 0x20) — like Jump
// then with unstyled fallback. Empirically: the modifier's anim // (0x2500003B) or a turn-while-moving overlay — resolves from the
// plays on top of the current cycle. // Modifiers dict, first with style-specific key then with unstyled
const uint Style = 0x003Du; // fallback (CMotionTable.GetObjectSequence Branch 4). Pre-cutover the
// adapter INSERTED the modifier's anim before the cyclic tail — an
// acdream invention. Retail Branch 4 is PHYSICS-ONLY combine_motion:
// the base cycle's anim list is untouched (no new nodes), and the
// modifier contributes velocity/omega on top of the cycle's own,
// tracked on the MotionState modifier stack (AP-73 mechanism).
// R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry
// (see PlayAction_Action_ResolvesFromLinksDict).
const uint Style = 0x8000003Du;
const uint IdleMotion = 0x41000003u; const uint IdleMotion = 0x41000003u;
const uint JumpMotion = 0x2500003Bu; // Modifier class const uint JumpMotion = 0x2500003Bu; // Modifier class
const uint IdleAnimId = 0x03000510u; const uint IdleAnimId = 0x03000510u;
@ -1535,12 +1706,18 @@ public sealed class AnimationSequencerTests
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable(); var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style; mt.DefaultStyle = (DRWMotionCommand)Style;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
// Modifier: (Style, Jump) // Modifier: (Style, Jump) — carries an omega contribution (a jump
// kick's angular nudge) rather than an anim payload, since Branch 4
// never touches the anim list.
int modKey = (int)((Style << 16) | (JumpMotion & 0xFFFFFFu)); int modKey = (int)((Style << 16) | (JumpMotion & 0xFFFFFFu));
mt.Modifiers[modKey] = Fixtures.MakeMotionData(JumpAnimId, framerate: 10f); var jumpMd = Fixtures.MakeMotionData(JumpAnimId, framerate: 10f);
jumpMd.Flags = MotionDataFlags.HasOmega;
jumpMd.Omega = new Vector3(0f, 0f, 2.5f);
mt.Modifiers[modKey] = jumpMd;
var loader = new FakeLoader(); var loader = new FakeLoader();
loader.Register(IdleAnimId, idleAnim); loader.Register(IdleAnimId, idleAnim);
@ -1548,12 +1725,15 @@ public sealed class AnimationSequencerTests
var seq = new AnimationSequencer(setup, mt, loader); var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion); seq.SetCycle(Style, IdleMotion);
int queueBefore = seq.QueueCount;
seq.PlayAction(JumpMotion); seq.PlayAction(JumpMotion);
var fr = seq.Advance(0.01f); // No anim nodes inserted — the queue is unchanged from before the
Assert.Single(fr); // modifier fired.
Assert.Equal(77f, fr[0].Origin.Z, 1); Assert.Equal(queueBefore, seq.QueueCount);
// The modifier's omega is combined onto the sequence's physics.
Assert.Equal(2.5f, seq.CurrentOmega.Z, 3);
} }
[Fact] [Fact]
@ -1563,7 +1743,9 @@ public sealed class AnimationSequencerTests
// Action(0x10) | ChatEmote(0x02) | Mappable(0x01). Because the // Action(0x10) | ChatEmote(0x02) | Mappable(0x01). Because the
// Action bit is set, they route through the Links-dict lookup just // Action bit is set, they route through the Links-dict lookup just
// like attacks. Verifies the class-bit math. // like attacks. Verifies the class-bit math.
const uint Style = 0x003Du; // R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry
// (see PlayAction_Action_ResolvesFromLinksDict).
const uint Style = 0x8000003Du;
const uint IdleMotion = 0x41000003u; const uint IdleMotion = 0x41000003u;
const uint WaveMotion = 0x13000087u; const uint WaveMotion = 0x13000087u;
const uint IdleAnimId = 0x03000520u; const uint IdleAnimId = 0x03000520u;
@ -1575,6 +1757,7 @@ public sealed class AnimationSequencerTests
var setup = Fixtures.MakeSetup(1); var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable(); var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style; mt.DefaultStyle = (DRWMotionCommand)Style;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);