From 3b9d9bb6be340dfb720a14ca5455b166d438db6c Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 2 Jul 2026 21:41:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(R2-Q4):=20adapter=20cutover=20=E2=80=94=20?= =?UTF-8?q?SetCycle/PlayAction=20rehosted=20on=20PerformMovement;=20Fix=20?= =?UTF-8?q?B=20/=20fast-path=20/=20stop-anim=20fallback=20/=20G17=20gate?= =?UTF-8?q?=20DELETED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../retail-divergence-register.md | 12 +- src/AcDream.App/Rendering/GameWindow.cs | 26 + .../Physics/AnimationSequencer.cs | 680 ++++-------------- .../Physics/AnimationCommandRouterTests.cs | 69 +- .../AnimationSequencerCutoverTraceTests.cs | 56 +- .../Physics/AnimationSequencerTests.cs | 365 +++++++--- 6 files changed, 562 insertions(+), 646 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 6b16b69b..d0927842 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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 | |---|---|---|---|---|---| | 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-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-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 | @@ -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 | |---|---|---|---|---|---| @@ -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-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList`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-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 | |---|---|---|---|---|---| @@ -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-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 @@ -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. They are the highest-priority audits: each needs either a recorded diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6317f1a5..26083fbd 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -9875,10 +9875,33 @@ public sealed class GameWindow : IDisposable } 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 // damage frames, particle spawns, part swaps, etc.) and fan // them out to registered subsystems via the hook router. // 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(); if (hooks.Count > 0) { @@ -9888,8 +9911,11 @@ public sealed class GameWindow : IDisposable var hook = hooks[hi]; if (hook is null) continue; _hookRouter.OnHook(ae.Entity.Id, worldPos, hook); + if (hook is DatReaderWriter.Types.AnimationDoneHook) + ae.Sequencer.Manager.AnimationDone(success: true); } } + ae.Sequencer.Manager.UseTime(); } else { diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 073d4365..74e7d153 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -119,19 +119,28 @@ public sealed class AnimationSequencer { // ── Public state ───────────────────────────────────────────────────────── - /// Current style (stance) command. - public uint CurrentStyle { get; private set; } - - /// Current cyclic motion command. - public uint CurrentMotion { get; private set; } + /// + /// Current style (stance) command — R2-Q4: read-only mirror of the + /// retail (MotionState OWNS + /// style/substate/substate_mod; the adapter no longer keeps copies). + /// + public uint CurrentStyle => _state.Style; /// - /// Speed multiplier currently applied to the cyclic tail. Starts at 1.0 - /// and is updated by when the same motion is - /// re-issued with a different speed (which triggers - /// instead of a cycle restart). + /// Current cyclic motion command — mirror of + /// . NOTE (Q4): this is the + /// POST-adjust_motion substate (WalkBackward reads back as WalkForward + /// with a negative ) — retail's interpreted + /// state is post-adjustment. /// - public float CurrentSpeedMod { get; private set; } = 1f; + public uint CurrentMotion => _state.Substate; + + /// + /// Speed multiplier of the current substate — mirror of + /// (signed; see + /// ). + /// + public float CurrentSpeedMod => _state.SubstateMod; /// /// Sequence-wide velocity mirror of the core's @@ -221,6 +230,23 @@ public sealed class AnimationSequencer // accumulators. 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 // AdapterHookQueue seam below); drained via ConsumePendingHooks. private readonly List _pendingHooks = new(); @@ -229,14 +255,6 @@ public sealed class AnimationSequencer // (retail CSequence::update(Frame*), 0x00525b80) — the old adapter // 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 ────────────────────────────────────────────────────────── /// @@ -259,6 +277,41 @@ public sealed class AnimationSequencer _loader = loader; _core = new CSequence(loader); _core.HookObj = new AdapterHookQueue(this); + _table = new CMotionTable(motionTable); + _state = new MotionState(); + _manager = new MotionTableManager(_table, _state, _core, new ForwardingMotionDoneSink(this)); + } + + /// + /// R2-Q4: the entity's — the caller's + /// per-tick drive surface ( + /// per drained AnimDone hook, + /// once per tick). + /// + public MotionTableManager Manager => _manager; + + /// + /// R2-Q4 seam: where 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). + /// + public Action? 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 ─────────────────────────────────────────────────────────── @@ -315,10 +368,16 @@ public sealed class AnimationSequencer /// preserve normal smooth transitions for everything else. public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false) { - // ── adjust_motion: remap left→right / backward→forward variants ─── - // ACE MotionInterp.cs:394-428. The MotionTable never stores TurnLeft, - // SideStepLeft, or WalkBackward cycles; the client plays the mirror - // animation with a negated speed so it runs backward. + EnsureInitialized(); + + // ── Q4 boundary normalization (adjust_motion cyclic subset) ─────── + // 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; float adjustedSpeed = speedMod; switch (motion & 0xFFFFu) @@ -337,199 +396,35 @@ public sealed class AnimationSequencer break; } - // Fast-path: already playing this exact motion. - // - // Retail (ACE MotionTable.cs:132-139): when motion == current and - // sign(speedMod) matches, DON'T restart the cycle — just rescale the - // in-flight cyclic-tail's framerate via multiply_cyclic_animation_fr. - // This keeps the run/walk loop smooth when a new UpdateMotion arrives - // with a different ForwardSpeed (e.g. when the server broadcasts a - // player's updated RunRate mid-step). - // - // **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; - } + // ── R2-Q4: dispatch through the verbatim motion-selection stack ─── + // Style change first (style-class ids route GetObjectSequence + // Branch 1 — exit link + entry hop + target style's default cycle), + // then the motion itself (Branch 2 cycle / 3 action / 4 modifier; + // the Branch-2 fast re-speed path replaces the old adapter + // fast-path, remove_redundant_links replaces Fix B, get_link's + // reversed-key double-hop replaces the stop-anim fallback). + if (style != 0 && style != _state.Style) + _manager.PerformMovement(MotionTableMovement.Interpreted(style, 1f)); - // D3 (Commit A 2026-05-03): SCFAST — proves whether the fast-path - // is firing instead of the full rebuild. Throttled to 0.5s per - // 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; - } + uint dispatchResult = _manager.PerformMovement( + MotionTableMovement.Interpreted(adjustedMotion, adjustedSpeed)); - // Resolve transition link (currentSubstate → adjustedMotion). Pass - // both speeds — GetLink switches lookup branches based on sign. - // CurrentSpeedMod defaults to 1.0 (positive) on a fresh sequencer, - // so a Ready → WalkBackward transition correctly enters GetLink's - // negative-speed (reversed-key) branch. - // K-fix18: when the caller asked to skip the transition link - // (instant-engage cases like Falling on jump start), force - // linkData to null so only the cycle gets enqueued. - MotionData? linkData = (skipTransitionLink || CurrentMotion == 0) - ? 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. + // K-fix18 (2026-04-26, register row survives → R3): instant-engage + // cases (Falling on jump start) strip the just-selected transition + // links so the new cycle starts immediately. Post-Q4 expression: + // the retail primitive CPhysicsObj::RemoveLinkAnimations + // (0x0050fe20 → CSequence::remove_all_link_animations) — exactly + // what R3-W4's LeaveGround will call, at which point this flag and + // its GameWindow call sites are deleted. The pending-queue entry's + // tick count intentionally stays (retail's RemoveLinkAnimations + // does not touch the manager queue; the countdown chain absorbs it). 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 (~100–300 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(); - } - // D3 (Commit A 2026-05-03): SCFULL — counterpart to SCFAST. Fires on - // the full-rebuild SetCycle path. Throttled to 0.5s per instance. - // Logs prev CurrentMotion so the line shows the transition directly - // (e.g. "Run → Ready" = cycle just got reset). - 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; + // Failed dispatch (missing cycle for this style, is_allowed reject): + // retail leaves sequence AND state untouched — no synthesis either. + if (dispatchResult != MotionTableManagerError.Success) + return; // ── Synthesize CurrentVelocity for locomotion cycles ────────────── // 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 SidestepAnimSpeed = 1.25f; - /// - /// Scale every cyclic node's framerate by , - /// mirroring retail's multiply_cyclic_animation_fr (0x00524940): - /// walks first_cyclic through the tail of the list and scales - /// each node's framerate. The non-cyclic head (link frames) is - /// untouched — those drain at their original framerate, which matches - /// retail: the sequencer "catches up" the transition before applying - /// the new run speed. - /// - /// - /// Called from 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. - /// - /// - /// - /// R1-P5 / gap-map G13: the core's MultiplyCyclicAnimationFramerate - /// touches ONLY node framerates (retail-verbatim). The velocity/omega - /// rescale below is the ADAPTER-level stand-in for retail's - /// change_cycle_speed composite (subtract_motion(oldSpeed) - /// + combine_motion(newSpeed), 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. - /// - /// - /// Framerate multiplier (newSpeed / oldSpeed). - 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); - } + // R2-Q4: MultiplyCyclicFramerate DELETED (zero external callers). The + // gap-map-G13 composite stand-in (framerate scale + velocity/omega + // rescale) is retired — same-motion re-speeds now route through the + // verbatim CMotionTable fast re-speed path (change_cycle_speed + + // subtract_motion(old) + combine_motion(new), decomp §5). /// /// Advance the animation by seconds and return the @@ -751,150 +609,48 @@ public sealed class AnimationSequencer // contract — for R6's per-tick wiring. /// - /// Play a one-shot action/modifier motion (Jump, emote, attack, etc.) - /// on top of the current cycle. The action frames are inserted in the - /// list immediately before the looping cyclic tail; they drain once - /// and then the cycle resumes naturally. + /// Play a one-shot action / modifier motion (Jump, emote, attack, etc.) + /// on top of the current cycle. /// /// - /// Retail semantics: actions and modifiers live in - /// (a separate dict from - /// ) keyed by - /// (style << 16) | (motion & 0xFFFFFF). A motion like - /// Jump = 0x2500003b is a Modifier (class byte 0x25) not a - /// SubState — feeding it to silently fails the - /// cycle lookup. Routing through PlayAction instead resolves - /// from the Modifiers table and interleaves the action frames with - /// the ongoing cyclic motion. - /// - /// - /// - /// If no entry is found in the Modifiers table for the requested - /// motion, this is a no-op. - /// - /// - /// - /// R1-P5: inserts directly on the core's internal list via - /// / / - /// (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. + /// R2-Q4: a thin shim over + /// — action-class ids + /// (0x10000000) route GetObjectSequence Branch 3 (rebuild: + /// substate→action link + base cycle re-added, the action tracked on the + /// MotionState action FIFO and popped by the manager's countdown); + /// modifier-class ids (0x20000000) route Branch 4 (PHYSICS-ONLY + /// combine_motion — no animation frames; the pre-Q4 + /// insert-before-tail modifier-anim mechanism was an acdream invention, + /// deleted). Unknown ids fail the dispatch and are a no-op, matching the + /// pre-Q4 contract. /// /// /// Raw MotionCommand (e.g. 0x2500003b for Jump). /// Speed multiplier for the action's framerate. public void PlayAction(uint motionCommand, float speedMod = 1f) { - // Resolve motion data. The lookup depends on the command's mask class: - // - // - 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(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? 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(); - } + EnsureInitialized(); + _manager.PerformMovement(MotionTableMovement.Interpreted(motionCommand, speedMod)); } /// /// 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. /// public void Reset() { + _manager.HandleExitWorld(); _core.Clear(); _pendingHooks.Clear(); - CurrentStyle = 0; - CurrentMotion = 0; - CurrentSpeedMod = 1f; + _state.Style = 0; + _state.Substate = 0; + _state.SubstateMod = 1f; + _state.ClearModifiers(); + _state.ClearActions(); + _initialized = false; } // ── Private helpers ────────────────────────────────────────────────────── @@ -925,150 +681,13 @@ public sealed class AnimationSequencer private static readonly AnimationDoneHook AnimationDoneSentinel = new() { Direction = AnimationHookDir.Both }; - /// - /// Look up the transition MotionData for going from - /// (current state, played at ) to - /// (new state, played at ). - /// - /// - /// 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: - /// - /// Both speeds positive (forward → forward, normal case): - /// Look up Links[(style<<16) | substate][motion] — the link FROM - /// substate TO motion. Played forward. - /// Either speed negative (any direction reversal — - /// WalkBackward, SideStepLeft, TurnLeft): Look up the REVERSED key - /// Links[(style<<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. - /// - /// - /// - /// DatReaderWriter encodes Links as Dictionary<int, MotionCommandData> - /// where MotionCommandData.MotionData is Dictionary<int, MotionData>. - /// - 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; - } + // R2-Q4: GetLink DELETED - re-homed verbatim as CMotionTable.GetLink + // (Q2, field-validated reversed-key branch preserved; Q0-pins A1). - // Style-defaults fallback per ACE MotionTable.cs:405-409. - if (_mtable.StyleDefaults.TryGetValue( - (DatReaderWriter.Enums.MotionCommand)style, out var defaultMotion)) - { - 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; - } - - /// - /// Build one from an , - /// speed-scaled retail-style (only Framerate scales — retail - /// AnimData::operator*, 0x00525d00). Returns a node whose - /// may be false (unresolved dat - /// id); callers filter those out. - /// - 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); - } - - /// - /// Append all AnimData entries from to the - /// core's animation list (retail free function add_motion, - /// 0x005224b0). Each AnimData becomes one - /// via , which slides - /// first_cyclic 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. - /// - /// The MotionData to enqueue. - /// Speed multiplier for AnimData framerate scaling. - /// - /// The isLooping 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 first_cyclic. - /// - 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, - }); - } - } + // R2-Q4: BuildNode + EnqueueMotionData DELETED - MotionData appends are + // owned by CMotionTable.AddMotion (add_motion 0x005224b0, unconditional + // velocity/omega set = G17 core semantics; the adapter's HasVelocity/ + // HasOmega gate is retired with them). /// /// Build the per-part blended transform from the current animation frame. @@ -1166,19 +785,10 @@ public sealed class AnimationSequencer return result; } - /// - /// True if the given motion-low-byte names a locomotion cycle — - /// WalkForward (0x05), WalkBackward (0x06), RunForward (0x07), - /// SideStepRight (0x0F), or SideStepLeft (0x10). - /// Used by to recognise cyclic→cyclic - /// direct transitions and bypass the transition link in that case - /// (retail's observed add_to_queue semantics). - /// - private static bool IsLocomotionCycleLowByte(uint lowByte) - { - return lowByte == 0x05u || lowByte == 0x06u || lowByte == 0x07u - || lowByte == 0x0Fu || lowByte == 0x10u; - } + // R2-Q4: IsLocomotionCycleLowByte DELETED with Fix B - the cyclic-to- + // cyclic link-skip is now retail's remove_redundant_links (0x0051bf20, + // MotionTableManager.RemoveRedundantLinks) operating on the pending + // queue, not a locomotion-subset special case in the adapter. /// /// Quaternion slerp matching the retail client's FUN_005360d0 diff --git a/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs index 83ca7d07..767265f1 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs @@ -1,5 +1,6 @@ using AcDream.Core.Physics; using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; using Xunit; namespace AcDream.Core.Tests.Physics; @@ -24,7 +25,13 @@ public sealed class AnimationCommandRouterTests [Fact] 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); @@ -59,8 +66,66 @@ public sealed class AnimationCommandRouterTests return new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader()); } + /// + /// 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. + /// + 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 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 { - public Animation? LoadAnimation(uint id) => null; + private readonly System.Collections.Generic.Dictionary _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; } } diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs index 590d6461..88dc0ba3 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs @@ -254,8 +254,16 @@ public sealed class AnimationSequencerCutoverTraceTests seq.SetCycle(NC, Ready); seq.SetCycle(NC, Walk, 1.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( - "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)); } @@ -267,8 +275,12 @@ public sealed class AnimationSequencerCutoverTraceTests seq.SetCycle(NC, Walk, 1.0f); seq.SetCycle(NC, Run, 2.0f); 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( - "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)); } @@ -278,11 +290,18 @@ public sealed class AnimationSequencerCutoverTraceTests var seq = NewSeq(out var loader); seq.SetCycle(NC, Ready); seq.SetCycle(NC, WalkBack, 1.0f); - // Reversed-key GetLink resolves the Walk→Ready link (0x105) played in - // reverse (fr=-19.5 = 30 × -0.65 BackwardsFactor); cursor starts at - // the link's reverse starting frame (HighFrame+1 = 3). + // Node list BYTE-IDENTICAL to pre-cutover: the reversed-key get_link + // resolves the Walk->Ready link (0x105) played in reverse (fr=-19.5 = + // 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( - "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)); } @@ -293,12 +312,14 @@ public sealed class AnimationSequencerCutoverTraceTests seq.SetCycle(NC, Ready); seq.SetCycle(NC, WalkBack, 1.0f); seq.SetCycle(NC, Ready, 1.0f); - // Pre-cutover: the OLD reversed link (0x105@-19.5, mid-drain) stays at - // the head while the settle link (0x105@30, via the stop-anim - // low-byte fallback 0x06→0x05) and the Ready cycle append behind it — - // link-before-link stacking. + // EXPECTED-DIFF(Q4): pre-cutover the stop-anim low-byte fallback + // re-keyed the settle as 105@30 (forward). The verbatim get_link + // (SubstateMod=-0.65 routes the REVERSED-key branch) resolves the + // 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( - "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)); } @@ -332,8 +353,13 @@ public sealed class AnimationSequencerCutoverTraceTests seq.SetCycle(NC, Ready); seq.SetCycle(NC, Walk, 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( - "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)); } @@ -343,8 +369,12 @@ public sealed class AnimationSequencerCutoverTraceTests var seq = NewSeq(out var loader); seq.SetCycle(NC, Ready); 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( - "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)); } } diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index 606bc130..e3478330 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -333,13 +333,21 @@ public sealed class AnimationSequencerTests [Fact] public void SetCycle_WithTransitionLink_PrependLinkFrames() { - // Two animations: link (2 frames at Y=1) and cycle (4 frames at X=1). - const uint Style = 0x003Du; - const uint IdleMotion = 0x0003u; - const uint WalkMotion = 0x0005u; + // R2-Q4: GetObjectSequence gates Branch 2 (cyclic substates) on the + // 0x40000000 class bit and Branch 1 (style change) on the top bit — + // bare low-word ids like the pre-cutover 0x0003/0x0005 never satisfy + // 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 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 linkAnim = Fixtures.MakeAnim(2, 1, new Vector3(0, 1, 0), Quaternion.Identity); @@ -352,15 +360,26 @@ public sealed class AnimationSequencerTests fromMotion: IdleMotion, toMotion: WalkMotion, 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(); + loader.Register(IdleAnim, idleAnim); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); var seq = new AnimationSequencer(setup, mt, loader); - // Prime the sequencer as if it was already playing IdleMotion. - SetCurrentMotion(seq, Style, IdleMotion); + // Prime the sequencer as if it was already playing IdleMotion — a + // 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); @@ -382,9 +401,13 @@ public sealed class AnimationSequencerTests // link's starting pose at the link→cycle boundary. Symptoms: door // swing-open flap (frame 0 = closed); player run-stop twitch // (frame 0 = mid-stride). - const uint Style = 0x003Du; - const uint IdleMotion = 0x0003u; - const uint WalkMotion = 0x0005u; + // R2-Q4: class-bit-tagged ids (GetObjectSequence gates Branch 1/2 on + // the 0x80000000/0x40000000 bits) — masking to the low 24 bits for + // 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 LinkAnim = 0x03000081u; @@ -402,6 +425,7 @@ public sealed class AnimationSequencerTests // 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 idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = Fixtures.MakeMtable( @@ -412,13 +436,23 @@ public sealed class AnimationSequencerTests toMotion: WalkMotion, linkAnimId: LinkAnim, 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(); + loader.Register(IdleAnim, idleAnim); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); 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); // Advance to _framePosition ≈ 2.5 — past the last integer frame (2) @@ -440,53 +474,71 @@ public sealed class AnimationSequencerTests [Fact] public void SetCycle_StopFromWalkBackward_FallsBackToWalkForwardStopLink() { - // Stop-anim asymmetry: the Humanoid motion table only authors a - // "stop walking" link under WalkForward (low byte 0x05). Stopping - // from WalkBackward (0x06) without a fallback returns null linkData - // and the cycle snaps to Ready with no settle blend. Fix: when the - // primary GetLink lookup fails, retry with WalkBackward's low byte - // remapped to WalkForward. - const uint Style = 0x003Du; - const uint WalkForwardCmd = 0x0005u; - const uint WalkBackCmd = 0x0006u; - const uint ReadyCmd = 0x0003u; + // R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests. + // S6_WalkBackToReady_StopSettleFallback): the pre-cutover adapter had + // an invented "stop-anim low-byte fallback" that re-keyed a + // WalkForward->Ready link when a WalkBackward->Ready lookup missed. + // Retail has no such fallback — CMotionTable.GetLink's verbatim + // reversed-key branch (Q0-pins A1) does the real job: adjust_motion + // remaps WalkBackward to WalkForward with a NEGATIVE SubstateMod, so + // stopping from it drives GetLink's "either speed negative -> swapped + // keys" path, which resolves the link stored FROM the style default + // (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 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 linkAnim = Fixtures.MakeAnim(4, 1, new Vector3(0, 7, 0), Quaternion.Identity); 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( style: Style, motion: ReadyCmd, cycleAnimId: CycleAnim, - fromMotion: WalkForwardCmd, - toMotion: ReadyCmd, + fromMotion: ReadyCmd, + toMotion: WalkForwardCmd, linkAnimId: LinkAnim, 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(); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); var seq = new AnimationSequencer(setup, mt, loader); - // Simulate "we were walking backward" — substate = WalkBackward, - // substateSpeed = +1 (the original speedMod stored by SetCycle). - SetCurrentMotion(seq, Style, WalkBackCmd); + // Enter WalkBackward for real — SetCycle's adjust_motion head remaps + // this to WalkForward with SubstateMod = -0.65 (BackwardsFactor), + // 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); - // Advance a tiny dt — should land on link frame 0 (Y=7), not the - // cycle (Y=0). Without the fallback, linkData is null, only the - // Ready cycle is enqueued, and we read Y=0 immediately. + // Advance a tiny dt — should land on the reversed windup link + // (Y=7), not the Ready cycle (Y=0). var transforms = seq.Advance(0.001f); Assert.Single(transforms); Assert.True(transforms[0].Origin.Y > 5f, - $"Stop-from-backward should fall back to WalkForward→Ready link " - + $"(expect Y≈7 from link); got Y={transforms[0].Origin.Y} " - + "(Y=0 means linkData was null and we snapped to Ready cycle)."); + $"Stop-from-backward should resolve GetLink's reversed-key branch " + + $"(expect Y≈7 from the reversed windup link); got Y={transforms[0].Origin.Y} " + + "(Y=0 means the link didn't resolve and we snapped to the Ready cycle)."); } [Fact] @@ -581,10 +633,14 @@ public sealed class AnimationSequencerTests // with negated speed, so the animation plays in reverse. // We verify this by checking CurrentMotion is still TurnLeft (the // 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 TurnRight = 0x0045000Du; // bit pattern for TurnRight in NonCombat - const uint TurnLeft = 0x0045000Eu; // bit pattern for TurnLeft + const uint Style = 0x8000003Du; // NonCombat + const uint TurnRight = 0x4045000Du; // bit pattern for TurnRight in NonCombat + const uint TurnLeft = 0x4045000Eu; // bit pattern for TurnLeft const uint AnimId = 0x03000050u; // 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 mt = new MotionTable(); 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). int cycleKey = (int)((Style << 16) | (TurnRight & 0xFFFFFFu)); @@ -611,8 +672,16 @@ public sealed class AnimationSequencerTests var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, TurnLeft, speedMod: 1f); - // CurrentMotion should record the original TurnLeft command. - Assert.Equal(TurnLeft, seq.CurrentMotion); + // R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests. + // 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 // epsilon boundary ((EndFrame+1)-eps ~= 3.99999). Retail's @@ -633,14 +702,17 @@ public sealed class AnimationSequencerTests public void Advance_NegativeSpeed_FramePositionDecreases() { // Verify that a cycle loaded with negative framerate counts downward. - const uint Style = 0x003Du; - const uint Motion = 0x0003u; + // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class + // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). + const uint Style = 0x8000003Du; + const uint Motion = 0x40000003u; const uint AnimId = 0x03000060u; var anim = Fixtures.MakeAnim(8, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; + mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; // Register cycle with NEGATIVE framerate to simulate reverse playback. 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)] // Advance enough to exhaust the link node, then verify we're in the cycle. - const uint Style = 0x003Du; - const uint IdleMotion = 0x0003u; - const uint WalkMotion = 0x0005u; + // R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames). + const uint Style = 0x8000003Du; + const uint IdleMotion = 0x40000003u; + const uint WalkMotion = 0x40000005u; + const uint IdleAnim = 0x03000082u; const uint CycleAnim = 0x03000080u; 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); // Cycle anim: 4 frames, X=9 (distinct marker). 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 mt = Fixtures.MakeMtable( @@ -746,13 +821,22 @@ public sealed class AnimationSequencerTests toMotion: WalkMotion, linkAnimId: LinkAnim, 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(); + loader.Register(IdleAnim, idleAnim); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); 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); // Link node is 2 frames at 10fps → 0.2s to exhaust. @@ -923,8 +1007,11 @@ public sealed class AnimationSequencerTests public void Advance_ForwardHookDoesNotFire_OnReversePlayback() { // A hook tagged Direction.Forward should NOT fire when playback is reversed. - const uint Style = 0x003Du; - const uint Motion = 0x0003u; + // R2-Q4: class-bit-tagged ids + retail-mandatory StyleDefaults — the bare + // 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; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); @@ -937,6 +1024,7 @@ public sealed class AnimationSequencerTests var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; + mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); var md = new MotionData(); QualifiedDataId qid = AnimId; @@ -951,7 +1039,10 @@ public sealed class AnimationSequencerTests seq.ConsumePendingHooks(); // 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(); // Forward-only hook on frame 2 should NOT fire on reverse playback. @@ -961,8 +1052,10 @@ public sealed class AnimationSequencerTests [Fact] public void Advance_BackwardHook_FiresOnReversePlayback() { - const uint Style = 0x003Du; - const uint Motion = 0x0003u; + // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class + // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). + const uint Style = 0x8000003Du; + const uint Motion = 0x40000003u; const uint AnimId = 0x03000104u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); @@ -975,6 +1068,7 @@ public sealed class AnimationSequencerTests var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; + mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); var md = new MotionData(); QualifiedDataId qid = AnimId; @@ -1045,8 +1139,13 @@ public sealed class AnimationSequencerTests public void CurrentVelocity_ExposedFromMotionData_WhenHasVelocity() { // MotionData with HasVelocity flag should surface via CurrentVelocity. - const uint Style = 0x003Du; - const uint Motion = 0x0003u; + // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) — + // 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; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); @@ -1054,6 +1153,7 @@ public sealed class AnimationSequencerTests var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; + mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); var md = new MotionData { @@ -1077,8 +1177,12 @@ public sealed class AnimationSequencerTests [Fact] public void CurrentVelocity_ScaledBySpeedMod() { - const uint Style = 0x003Du; - const uint Motion = 0x0003u; + // R2-Q4: retail-mandatory StyleDefaults, and Motion needs its + // 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; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); @@ -1086,6 +1190,7 @@ public sealed class AnimationSequencerTests var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; + mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); 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, // an AnimationDoneHook should be queued so consumers can react (e.g. UI // wake-on-idle-complete). - const uint Style = 0x003Du; - const uint IdleMotion = 0x0003u; - const uint WalkMotion = 0x0005u; + // R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames). + const uint Style = 0x8000003Du; + const uint IdleMotion = 0x40000003u; + const uint WalkMotion = 0x40000005u; + const uint IdleAnim = 0x03000132u; const uint CycleAnim = 0x03000130u; const uint LinkAnim = 0x03000131u; var linkAnim = Fixtures.MakeAnim(2, 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 mt = Fixtures.MakeMtable( style: Style, motion: WalkMotion, cycleAnimId: CycleAnim, fromMotion: IdleMotion, toMotion: WalkMotion, linkAnimId: LinkAnim, 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(); + loader.Register(IdleAnim, idleAnim); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); 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.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 // rate (factor 0.5), advancing 1.0s should produce half a loop (5 frames). - const uint Style = 0x003Du; - const uint Motion = 0x0007u; // RunForward + // R2-Q4: Motion needs the 0x40000000 cycle-class bit — GetObjectSequence + // 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; // Unique per-frame Z so we can tell where the cursor lands. @@ -1177,6 +1295,11 @@ public sealed class AnimationSequencerTests Framerate = 10f, }); 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(); loader.Register(AnimId, anim); @@ -1184,8 +1307,11 @@ public sealed class AnimationSequencerTests var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, Motion, speedMod: 1f); - // Halve the playback rate. - seq.MultiplyCyclicFramerate(0.5f); + // R2-Q4: halve the playback rate via the retail same-motion re-speed + // (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. seq.Advance(1.0f); @@ -1193,7 +1319,8 @@ public sealed class AnimationSequencerTests Assert.Single(frames); 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); } @@ -1202,8 +1329,11 @@ public sealed class AnimationSequencerTests { // Changing speed mid-cycle must NOT reset the frame cursor — the // animation keeps playing from where it was, just faster/slower. - const uint Style = 0x003Du; - const uint Motion = 0x0007u; + // R2-Q4: class-bit-tagged ids — the bare ids made this test pass + // VACUOUSLY (dispatch silently failed; "cursor unchanged" held + // because nothing moved at all). + const uint Style = 0x8000003Du; + const uint Motion = 0x40000007u; const uint AnimId = 0x03000402u; var anim = new Animation(); @@ -1219,6 +1349,9 @@ public sealed class AnimationSequencerTests mt.DefaultStyle = (DRWMotionCommand)Style; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); 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(); loader.Register(AnimId, anim); @@ -1228,7 +1361,9 @@ public sealed class AnimationSequencerTests seq.Advance(0.3f); // cursor ~ frame 3 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); Assert.Equal(before, after, 5); @@ -1241,8 +1376,10 @@ public sealed class AnimationSequencerTests // NOT reset the cursor — it should call MultiplyCyclicFramerate to // keep the run loop smooth (retail behavior for a mid-run RunRate // broadcast). Mirror of ACE MotionTable.cs:132-139 fast-path. - const uint Style = 0x003Du; - const uint Motion = 0x0007u; + // R2-Q4: Motion needs the 0x40000000 cycle-class bit — see + // CurrentVelocity_ExposedFromMotionData_WhenHasVelocity. + const uint Style = 0x8000003Du; + const uint Motion = 0x40000007u; const uint AnimId = 0x03000403u; 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 // 0.5×. The dead-reckoning integrator in TickAnimations reads // CurrentVelocity each tick, so this has to be accurate. - const uint Style = 0x003Du; - const uint Motion = 0x0007u; + // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class + // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). + const uint Style = 0x8000003Du; + const uint Motion = 0x40000007u; const uint AnimId = 0x03000405u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; + mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); 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 // "identical call = no state change" behavior. - const uint Style = 0x003Du; - const uint Motion = 0x0007u; + // R2-Q4: Motion needs the 0x40000000 cycle-class bit — see + // CurrentVelocity_ExposedFromMotionData_WhenHasVelocity. + const uint Style = 0x8000003Du; + const uint Motion = 0x40000007u; const uint AnimId = 0x03000404u; 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) // should surface as CurrentOmega = (0, 0, 1) after SetCycle. // Scales with speedMod exactly like Velocity. - const uint Style = 0x003Du; - const uint Motion = 0x000Du; // TurnRight + // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class + // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). + const uint Style = 0x8000003Du; + const uint Motion = 0x4000000Du; // TurnRight const uint AnimId = 0x03000701u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; + mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); 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. // Crucial: otherwise remote entities would stutter at every stance // transition while the link plays. - const uint Style = 0x003Du; - const uint IdleMotion = 0x0003u; - const uint WalkMotion = 0x0005u; + // R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames). + const uint Style = 0x8000003Du; + const uint IdleMotion = 0x40000003u; + const uint WalkMotion = 0x40000005u; + const uint IdleAnim = 0x03000603u; const uint CycleAnim = 0x03000601u; const uint LinkAnim = 0x03000602u; var cycleAnim = Fixtures.MakeAnim(4, 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 mt = new MotionTable(); 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)); 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; var loader = new FakeLoader(); + loader.Register(IdleAnim, idleAnim); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); 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); // 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 // keyed by (style, currentSubstate) → motion. Example: a ThrustMed // 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 ActionMotion = 0x10000058u; // ThrustMed (Action class) const uint IdleAnimId = 0x03000501u; @@ -1451,6 +1610,7 @@ public sealed class AnimationSequencerTests var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; + mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); 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 // while the action link drains, so the Ready echo must not abort the // 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 AttackMotion = 0x10000052u; const uint IdleAnimId = 0x03000503u; @@ -1492,6 +1654,7 @@ public sealed class AnimationSequencerTests var setup = Fixtures.MakeSetup(1); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)Style }; + mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); @@ -1519,11 +1682,19 @@ public sealed class AnimationSequencerTests [Fact] public void PlayAction_Modifier_ResolvesFromModifiersDict() { - // A Modifier-class command (mask 0x20) — like Jump (0x2500003B) — - // resolves from the Modifiers dict, first with style-specific key - // then with unstyled fallback. Empirically: the modifier's anim - // plays on top of the current cycle. - const uint Style = 0x003Du; + // R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests. + // S9_TurnModifier): a Modifier-class command (mask 0x20) — like Jump + // (0x2500003B) or a turn-while-moving overlay — resolves from the + // Modifiers dict, first with style-specific key then with unstyled + // 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 JumpMotion = 0x2500003Bu; // Modifier class const uint IdleAnimId = 0x03000510u; @@ -1535,12 +1706,18 @@ public sealed class AnimationSequencerTests var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; + mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); 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)); - 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(); loader.Register(IdleAnimId, idleAnim); @@ -1548,12 +1725,15 @@ public sealed class AnimationSequencerTests var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, IdleMotion); + int queueBefore = seq.QueueCount; seq.PlayAction(JumpMotion); - var fr = seq.Advance(0.01f); - Assert.Single(fr); - Assert.Equal(77f, fr[0].Origin.Z, 1); + // No anim nodes inserted — the queue is unchanged from before the + // modifier fired. + 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] @@ -1563,7 +1743,9 @@ public sealed class AnimationSequencerTests // Action(0x10) | ChatEmote(0x02) | Mappable(0x01). Because the // Action bit is set, they route through the Links-dict lookup just // 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 WaveMotion = 0x13000087u; const uint IdleAnimId = 0x03000520u; @@ -1575,6 +1757,7 @@ public sealed class AnimationSequencerTests var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; + mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);