diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index d0927842..719e1af6 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -179,9 +179,9 @@ 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 | +| AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) | ## 4. Temporary stopgap (TS) — 33 rows diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 26083fbd..0b979ab1 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -408,7 +408,7 @@ public sealed class GameWindow : IDisposable /// remote gets the same treatment as the local player. /// /// - internal sealed class RemoteMotion // internal: RemoteMotionSink (L.2g S2b) consumes it + internal sealed class RemoteMotion // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it { public AcDream.Core.Physics.PhysicsBody Body; public AcDream.Core.Physics.MotionInterpreter Motion; @@ -3708,47 +3708,17 @@ public sealed class GameWindow : IDisposable seqMotion = AcDream.Core.Physics.MotionCommand.Ready; } - // Phase L.1c followup (2026-04-28): apply the same - // missing-cycle fallback the OnLiveMotionUpdated path - // uses. Without this, a monster spawned in combat - // stance with the wire's seqMotion absent from its - // MotionTable hits ClearCyclicTail() with no - // replacement enqueue, every body part snaps to its - // setup-default offset, and the visual collapses to - // "torso on the ground" — visible to acdream - // observers when another client is in combat with a - // monster, until the first OnLiveMotionUpdated UM - // applies the same fallback there. - uint spawnCycle = seqMotion; - if (!sequencer.HasCycle(seqStyle, spawnCycle)) - { - uint origCycle = spawnCycle; - // RunForward → WalkForward → Ready - if ((spawnCycle & 0xFFu) == 0x07 - && sequencer.HasCycle(seqStyle, 0x45000005u)) - { - spawnCycle = 0x45000005u; - } - else if (sequencer.HasCycle(seqStyle, 0x41000003u)) - { - spawnCycle = 0x41000003u; - } - else - { - spawnCycle = 0; - } - - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - { - Console.WriteLine( - $"spawn cycle missing for guid=0x{spawn.Guid:X8} mtable=0x{mtableId:X8} " + - $"style=0x{seqStyle:X8} requested=0x{origCycle:X8} " + - $"→ fallback=0x{spawnCycle:X8}"); - } - } - - if (spawnCycle != 0) - sequencer.SetCycle(seqStyle, spawnCycle); + // R2-Q5: retail's enter-world sequence — initialize_ + // state installs the table default (so the entity is + // NEVER without a cycle: the L.1c "torso on the + // ground" hazard is structurally gone), then the + // wire's initial motion dispatches through the + // verbatim GetObjectSequence (a missing cycle leaves + // the default playing — retail's own miss behavior; + // the Run→Walk→Ready fallback chain is deleted with + // RemoteMotionSink). + sequencer.InitializeState(); + sequencer.SetCycle(seqStyle, seqMotion); } } } @@ -3821,8 +3791,11 @@ public sealed class GameWindow : IDisposable : NonCombatStyle; uint spawnState = spawn.PhysicsState ?? 0u; uint initialCycle = (spawnState & EtherealPs) != 0 ? MotionOn : MotionOff; - if (sequencer.HasCycle(initialStyle, initialCycle)) - sequencer.SetCycle(initialStyle, initialCycle); + // R2-Q5: initialize_state guarantees a playing default; + // the On/Off dispatch no-ops harmlessly if the door's + // table lacks the cycle (HasCycle guard deleted). + sequencer.InitializeState(); + sequencer.SetCycle(initialStyle, initialCycle); var template = new (uint, IReadOnlyDictionary?)[meshRefs.Count]; for (int i = 0; i < meshRefs.Count; i++) @@ -4210,6 +4183,12 @@ public sealed class GameWindow : IDisposable _worldState.RemoveEntityByServerGuid(serverGuid); _worldGameState.RemoveById(existingEntity.Id); + // R2-Q5: retail's exit-world drain — every pending queue entry fires + // MotionDone(success:false) before the object goes away + // (MotionTableManager::HandleExitWorld 0x0051bda0; R3 adds the + // CMotionInterp-side drain alongside per r3-port-plan §4). + if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone)) + aeGone.Sequencer?.Manager.HandleExitWorld(); _animatedEntities.Remove(existingEntity.Id); _classificationCache.InvalidateEntity(existingEntity.Id); _physicsEngine.ShadowObjects.Deregister(existingEntity.Id); @@ -4528,12 +4507,13 @@ public sealed class GameWindow : IDisposable // MotionInterpreter.MoveToInterpretedState applies it with // retail's exact dispatch order + the 15-bit action-stamp // gate (conformance: RetailObserverTraceConformanceTests, - // 183/183 vs the live retail-observer cdb trace), and - // RemoteMotionSink maps the gate-passed dispatches onto the - // sequencer (axis-priority pick + missing-cycle fallback - // moved verbatim from the pre-S2 block; airborne handling is - // the funnel's contact_allows_move gate — the retail - // mechanism behind the old K-fix17 guard). + // 183/183 vs the live retail-observer cdb trace), and the + // gate-passed dispatches go straight into the motion-table + // stack via MotionTableDispatchSink -> PerformMovement + // (R2-Q5; GetObjectSequence + is_allowed decide — no sink- + // side pick; airborne handling is the funnel's + // contact_allows_move gate, the retail mechanism behind the + // old K-fix17 guard). // // MoveTo packets (mt 6/7) reuse the PlanMoveToStart seed as // the funnel's forward command (fullMotion/speedMod computed @@ -4640,11 +4620,32 @@ public sealed class GameWindow : IDisposable ims.Actions = actionList; } - RemoteMotionSink? sink = ae.Sequencer is not null - ? new RemoteMotionSink(ae.Sequencer, remoteMot, update.Guid) - : null; + // R2-Q5: funnel dispatches go STRAIGHT into the motion- + // table stack (GetObjectSequence + is_allowed decide) — + // RemoteMotionSink's single-cycle pick + fallback chains + // are deleted (AP-73 retired). The callbacks are the + // ObservedOmega seam: seed remote rotation from the wire + // turn this tick (retail rotates from sequence omega in + // the per-tick apply_physics chain — R6; register row). + AcDream.Core.Physics.Motion.MotionTableDispatchSink? sink = null; + if (ae.Sequencer is not null) + { + var rmForSink = remoteMot; + sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(ae.Sequencer) + { + TurnApplied = (turnMotion, turnSpeed) => + { + float signed = (turnMotion & 0xFFu) == 0x0E + ? -System.MathF.Abs(turnSpeed) + : turnSpeed; + rmForSink.ObservedOmega = new System.Numerics.Vector3( + 0f, 0f, -(System.MathF.PI / 2f) * signed); + }, + TurnStopped = () => + rmForSink.ObservedOmega = System.Numerics.Vector3.Zero, + }; + } remoteMot.Motion.MoveToInterpretedState(ims, sink); - sink?.Commit(); } } diff --git a/src/AcDream.App/Rendering/RemoteMotionSink.cs b/src/AcDream.App/Rendering/RemoteMotionSink.cs deleted file mode 100644 index bdf1efaf..00000000 --- a/src/AcDream.App/Rendering/RemoteMotionSink.cs +++ /dev/null @@ -1,217 +0,0 @@ -using System; -using AcDream.Core.Physics; - -namespace AcDream.App.Rendering; - -/// -/// L.2g S2b — the App-side for remote -/// entities: receives the funnel's retail-ordered dispatches -/// (CMotionInterp::apply_interpreted_movement order: style → -/// forward-or-Falling → sidestep → turn → actions) and maps them onto the -/// single-cycle . -/// -/// The axis-priority pick (forward locomotion beats sidestep beats -/// turn), the missing-cycle fallback chain (Run→Walk→Ready), and the -/// overlay routing for action-class commands are MOVED VERBATIM from the -/// pre-S2 GameWindow.OnLiveMotionUpdated block — they are acdream's -/// documented approximation of retail's substate+modifier model -/// (GetObjectSequence + re_modify, deviation DEV-9) pending the S3/S6 -/// modifier port. What changed is upstream: the STATE now flows through -/// the verbatim CMotionInterp funnel instead of ad-hoc bulk copies. -/// -/// Airborne behavior needs no special-casing here: the funnel's -/// verbatim contact_allows_move gate (0x00528240) blocks -/// style/locomotion/action dispatches for a gravity-bound creature without -/// ground contact — retail's real mechanism behind the old K-fix17 -/// "preserve the Falling cycle while airborne" guard. -/// -/// Lifecycle: construct per-UM, pass to -/// , then call -/// once to resolve the collected axes into at most one -/// SetCycle. -/// -internal sealed class RemoteMotionSink : IInterpretedMotionSink -{ - private readonly AnimationSequencer _sequencer; - private readonly GameWindow.RemoteMotion _remote; - private readonly uint _guid; - - private uint _style; // last style-class dispatch (0x8xxxxxxx) - private uint _substate; // first substate-class dispatch (0x4xxxxxxx) — fwd wins by dispatch order - private float _substateSpeed = 1f; - private uint _sidestep; // modifier-class sidestep (0x6500000F/0x10) - private float _sidestepSpeed = 1f; - private uint _turn; // modifier-class turn (0x6500000D/0x0E) - private float _turnSpeed = 1f; - - public RemoteMotionSink(AnimationSequencer sequencer, GameWindow.RemoteMotion remote, uint guid) - { - _sequencer = sequencer; - _remote = remote; - _guid = guid; - _style = sequencer.CurrentStyle != 0 ? sequencer.CurrentStyle : 0x8000003Du; - } - - public void ApplyMotion(uint motion, float speed) - { - // Style class (stance): retail dispatches it first each apply pass. - if ((motion & 0x80000000u) != 0 && (motion & 0x10000000u) == 0) - { - _style = motion; - return; - } - - // Turn/sidestep FIRST: they are Modifier-class (0x65xxxxxx), so the - // generic overlay classification below would swallow them — that - // exact ordering bug shipped in the first S2b cut and killed - // turn-in-place animation + the ObservedOmega seed (S2 smoke, - // 2026-07-02: 30 [TURN_WIRE] events, zero turn SetCycles). They are - // cycle-axis candidates for the single-cycle pick, not overlays. - uint low = motion & 0xFFu; - bool isTurn = low is 0x0D or 0x0E && (motion & 0xFF000000u) == 0x65000000u; - bool isSidestep = low is 0x0F or 0x10 && (motion & 0xFF000000u) == 0x65000000u; - - if (!isTurn && !isSidestep) - { - var route = AnimationCommandRouter.Classify(motion); - if (route is AnimationCommandRouteKind.Action - or AnimationCommandRouteKind.Modifier - or AnimationCommandRouteKind.ChatEmote) - { - // Overlay (attack swings, emotes): append over the current - // cyclic tail immediately — the substate cycle is unaffected - // (retail Action branch of GetObjectSequence). The funnel's - // contact gate already filtered airborne cases. - AnimationCommandRouter.RouteFullCommand( - _sequencer, _style, motion, speed <= 0f ? 1f : speed); - return; - } - } - - if (isTurn) - { - _turn = motion; - _turnSpeed = speed; - // Seed ObservedOmega from the wire turn so rotation starts THIS - // tick; UpdatePosition orientation snaps correct any drift. - // TurnLeft arrives as TurnRight + negative speed (adjust_motion - // convention) or as the explicit 0x0E command. - float signed = low == 0x0E ? -MathF.Abs(speed) : speed; - _remote.ObservedOmega = new System.Numerics.Vector3( - 0, 0, -(MathF.PI / 2f) * signed); - return; - } - if (isSidestep) - { - _sidestep = motion; - _sidestepSpeed = speed; - return; - } - - // Substate class (Walk/Run/Ready/Falling/door On-Off/...): the - // FIRST one wins — the funnel dispatches forward before modifiers, - // so forward locomotion naturally outranks the rest. - if (_substate == 0) - { - _substate = motion; - _substateSpeed = speed; - } - } - - public void StopMotion(uint motion) - { - uint low = motion & 0xFFu; - if (low is 0x0D or 0x0E) - { - // Turn cleared — stop rotating immediately, don't coast. - _remote.ObservedOmega = System.Numerics.Vector3.Zero; - } - // Sidestep/turn STATE was already cleared by the funnel's flat - // copy_movement_from; nothing else to do. - } - - /// - /// Resolve the collected axes into the sequencer cycle. Priority - /// (pre-S2 picker, moved verbatim): forward locomotion → sidestep → - /// turn → whatever substate arrived (Ready included). Missing-cycle - /// fallback Run→Walk→Ready avoids SetCycle's unconditional - /// ClearCyclicTail on a cycle the MotionTable lacks ("torso on the - /// ground"). - /// - public void Commit() - { - uint animCycle; - float animSpeed; - - uint subLow = _substate & 0xFFu; - bool fwdIsRunWalk = subLow is 0x05 or 0x06 or 0x07; - - if (fwdIsRunWalk || (_sidestep == 0 && _turn == 0)) - { - if (_substate == 0) return; // nothing dispatched (airborne UM) - animCycle = _substate; - animSpeed = _substateSpeed; - } - else if (_sidestep != 0) - { - animCycle = _sidestep; - animSpeed = _sidestepSpeed; - } - else - { - animCycle = _turn; - animSpeed = _turnSpeed; - } - - // Missing-cycle fallback chain (moved verbatim; see the pre-S2 - // comment block: regression 186a584, creatures lacking a - // (stance, RunForward) cycle). - uint cycleToPlay = animCycle; - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" - && (animCycle & 0xFFu) is 0x05u or 0x07u) - { - bool hc = _sequencer.HasCycle(_style, cycleToPlay); - System.Console.WriteLine( - $"[HASCYCLE] guid={_guid:X8} style=0x{_style:X8} " - + $"requestedCycle=0x{cycleToPlay:X8} HasCycle={hc}"); - } - if (!_sequencer.HasCycle(_style, cycleToPlay)) - { - uint requested = cycleToPlay; - if ((cycleToPlay & 0xFFu) == 0x07 - && _sequencer.HasCycle(_style, 0x45000005u)) - { - cycleToPlay = 0x45000005u; // RunForward → WalkForward - } - else if (_sequencer.HasCycle(_style, 0x41000003u)) - { - cycleToPlay = 0x41000003u; // → Ready - } - else - { - cycleToPlay = 0; // leave the existing cycle alone - } - - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - { - Console.WriteLine( - $"UM cycle missing for guid=0x{_guid:X8} " + - $"style=0x{_style:X8} requested=0x{requested:X8} " + - $"→ fallback=0x{cycleToPlay:X8}"); - } - } - - if (cycleToPlay == 0) return; - - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" - && (_sequencer.CurrentMotion != cycleToPlay - || MathF.Abs(_sequencer.CurrentSpeedMod - animSpeed) > 1e-3f)) - { - System.Console.WriteLine( - $"[SETCYCLE] guid={_guid:X8} " - + $"old=(motion=0x{_sequencer.CurrentMotion:X8} speed={_sequencer.CurrentSpeedMod:F3}) " - + $"new=(motion=0x{cycleToPlay:X8} speed={animSpeed:F3})"); - } - _sequencer.SetCycle(_style, cycleToPlay, animSpeed); - } -} diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 74e7d153..263be47f 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -316,31 +316,12 @@ public sealed class AnimationSequencer // ── Public API ─────────────────────────────────────────────────────────── - /// - /// Check whether the underlying MotionTable contains a cycle for the - /// given (style, motion) pair. Useful for callers that want to fall - /// back to a known-good motion (e.g. WalkForward → - /// Ready) instead of triggering 's - /// unconditional cyclic-tail rebuild on a missing cycle — which leaves - /// the body without any animation tail and snaps every part to the - /// setup-default offset (visible as "torso on the ground" since most - /// creatures' setup-default has limbs at the torso origin). - /// - public bool HasCycle(uint style, uint motion) - { - // adjust_motion remapping (mirrors the head of SetCycle): - // TurnLeft, SideStepLeft, WalkBackward map to their right/forward - // mirror cycles. - uint adjustedMotion = motion; - switch (motion & 0xFFFFu) - { - case 0x000E: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du; break; - case 0x0010: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu; break; - case 0x0006: adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u; break; - } - int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu)); - return _mtable.Cycles.ContainsKey(cycleKey); - } + // R2-Q5: HasCycle DELETED (caller-free). The missing-cycle hazard it + // guarded ("torso on the ground": legacy SetCycle's unconditional + // cyclic-tail drop) is structurally gone — the verbatim + // GetObjectSequence checks the cycle BEFORE any list surgery and leaves + // the sequence untouched on a miss, so the spawn/UM fallback chains + // (Run→Walk→Ready) that probed it are retired with RemoteMotionSink. /// /// Switch to a new cyclic motion, prepending any transition link frames @@ -406,7 +387,11 @@ public sealed class AnimationSequencer if (style != 0 && style != _state.Style) _manager.PerformMovement(MotionTableMovement.Interpreted(style, 1f)); - uint dispatchResult = _manager.PerformMovement( + // Motion via the PerformMovement passthrough (velocity synthesis on + // success — same helper the R2-Q5 funnel sink path uses; the + // adjusted motion + adjusted speed produce the identical synthesis + // result as the original pair). + uint dispatchResult = PerformMovement( MotionTableMovement.Interpreted(adjustedMotion, adjustedSpeed)); // K-fix18 (2026-04-26, register row survives → R3): instant-engage @@ -423,74 +408,10 @@ public sealed class AnimationSequencer // Failed dispatch (missing cycle for this style, is_allowed reject): // retail leaves sequence AND state untouched — no synthesis either. + // (Velocity synthesis already ran inside PerformMovement on success.) if (dispatchResult != MotionTableManagerError.Success) return; - // ── Synthesize CurrentVelocity for locomotion cycles ────────────── - // The Humanoid motion table ships every locomotion MotionData with - // Flags=0x00 (no HasVelocity), so EnqueueMotionData leaves - // CurrentVelocity at Vector3.Zero. That matches the literal retail - // dat, but retail's body physics uses CMotionInterp::get_state_velocity - // (FUN_00528960) which returns RunAnimSpeed × ForwardSpeed for - // RunForward, independent of the dat's HasVelocity flag. The dat - // velocity is a separate additive source (kick-off velocity, flying - // creatures, etc) not the primary locomotion drive. - // - // For our sequencer's to be usable by - // consumers (local-player get_state_velocity via Option B, remote - // dead-reckoning in GameWindow) it must carry the retail-constant - // locomotion value when the dat is silent. Synthesize it here, - // post-EnqueueMotionData, only when the cycle is a locomotion cycle - // AND the dat didn't populate it. - // - // Constants match etc — - // decompiled from _DAT_007c96e0/e4/e8. The velocity is body-local - // (+Y = forward, +X = right); consumers rotate into world space via - // the owning entity's orientation. - // For known locomotion cycles, ALWAYS overwrite CurrentVelocity with - // the synthesized value — even if the transition link set - // CurrentVelocity from its own HasVelocity flag. The link's velocity - // is for the brief transition (e.g. small stride into run-pose); the - // cycle's intended steady-state velocity is what consumers (remote - // body translation in GameWindow.TickAnimations env-var path) need. - // Without this, walking-to-running transitions left CurrentVelocity - // at the link's slow pace, and the user reported "it just blips - // forward walking" until another motion command (turn, etc) forced - // a re-synth. - { - float yvel = 0f; - float xvel = 0f; - uint low = motion & 0xFFu; - bool isLocomotion = false; - switch (low) - { - case 0x05: // WalkForward - yvel = WalkAnimSpeed * adjustedSpeed; - isLocomotion = true; - break; - case 0x06: // WalkBackward — adjust_motion remapped to WalkForward - // with speedMod *= -0.65f. - yvel = WalkAnimSpeed * adjustedSpeed; - isLocomotion = true; - break; - case 0x07: // RunForward - yvel = RunAnimSpeed * adjustedSpeed; - isLocomotion = true; - break; - case 0x0F: // SideStepRight - xvel = SidestepAnimSpeed * adjustedSpeed; - isLocomotion = true; - break; - case 0x10: // SideStepLeft — remapped to SideStepRight with - // negated speed; same handling as backward walk. - xvel = SidestepAnimSpeed * adjustedSpeed; - isLocomotion = true; - break; - } - if (isLocomotion) - _core.SetVelocity(new Vector3(xvel, yvel, 0f)); - } - // ── Synthesize CurrentOmega for turn cycles ─────────────────────── // Same story as velocity synthesis above: Humanoid turn MotionData // often ships without HasOmega. Retail clients turn the body via @@ -525,6 +446,75 @@ public sealed class AnimationSequencer } } + /// + /// R2-Q5: run retail's enter_default_state analog now if it + /// hasn't run yet (idempotent). Spawn paths call this so an entity with + /// no initial wire motion still plays the table default (retail: every + /// CPhysicsObj entering the world runs initialize_state). + /// + public void InitializeState() => EnsureInitialized(); + + /// + /// R2-Q5: the single dispatch entry — lazy initialize_state, then + /// , then (on a + /// successful InterpretedCommand) the locomotion velocity synthesis + /// (register AP-75; the consumers are remote body translation via + /// PositionManager.ComputeOffset and the local Option-B + /// get_state_velocity — retire in R6 when root motion drives the body). + /// Omega is deliberately NOT synthesized here: remote rotation is the + /// ObservedOmega seam (MotionTableDispatchSink callbacks, retire R6); + /// only the SetCycle path keeps the turn-omega fallback. + /// + public uint PerformMovement(MotionTableMovement movement) + { + EnsureInitialized(); + uint result = _manager.PerformMovement(movement); + if (result == MotionTableManagerError.Success + && movement.Type == MovementType.InterpretedCommand) + { + SynthesizeLocomotionVelocity(movement.Motion, movement.Speed); + } + return result; + } + + /// + /// Overwrite sequence velocity with the retail locomotion constant when + /// the dispatched motion is a locomotion cycle. The Humanoid motion + /// table ships every locomotion MotionData with a zero Velocity, so + /// add_motion's unconditional set leaves the sequence at zero — + /// but retail's body physics uses CMotionInterp::get_state_velocity + /// (0x00528960: RunAnimSpeed × ForwardSpeed etc., independent of the + /// dat), so consumers of need the + /// constant here. Velocity is body-local (+Y forward, +X right); + /// consumers rotate into world space via the entity orientation. + /// Constants decompiled from _DAT_007c96e0/e4/e8. + /// + private void SynthesizeLocomotionVelocity(uint motion, float speed) + { + float yvel = 0f; + float xvel = 0f; + bool isLocomotion = false; + switch (motion & 0xFFu) + { + case 0x05: // WalkForward + case 0x06: // WalkBackward (pre-adjust callers; adjusted = 0x05) + yvel = WalkAnimSpeed * speed; + isLocomotion = true; + break; + case 0x07: // RunForward + yvel = RunAnimSpeed * speed; + isLocomotion = true; + break; + case 0x0F: // SideStepRight + case 0x10: // SideStepLeft (pre-adjust callers; adjusted = 0x0F) + xvel = SidestepAnimSpeed * speed; + isLocomotion = true; + break; + } + if (isLocomotion) + _core.SetVelocity(new Vector3(xvel, yvel, 0f)); + } + // Retail locomotion constants — mirror of MotionInterpreter.RunAnimSpeed // etc. Kept here to keep AnimationSequencer self-contained for the // synthesize-velocity path above. Values decompiled from _DAT_007c96e0/e4/e8. diff --git a/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs b/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs index 521298b9..8982ca7e 100644 --- a/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs +++ b/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs @@ -18,10 +18,11 @@ public interface IInterpretedMotionSink /// The CPhysicsObj::DoInterpretedMotion → … → /// CMotionTable::GetObjectSequence backend — called for every motion /// that passes contact_allows_move, in retail dispatch order - /// (style → forward-or-Falling → sidestep → turn → actions). The App - /// implementation decides how the axes map onto the visible - /// AnimationSequencer cycle (retaining the HasCycle fallback, - /// overlay routing, etc.). + /// (style → forward-or-Falling → sidestep → turn → actions). R2-Q5: the + /// production implementation is Motion.MotionTableDispatchSink, + /// which dispatches straight into the entity's motion-table stack + /// (PerformMovement → GetObjectSequence + is_allowed decide) — + /// no sink-side axis pick or fallback chain. /// void ApplyMotion(uint motion, float speed); diff --git a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs new file mode 100644 index 00000000..a5e7de0e --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs @@ -0,0 +1,70 @@ +using System; + +namespace AcDream.Core.Physics.Motion; + +/// +/// R2-Q5 — the that dispatches the +/// CMotionInterp funnel's retail-ordered motion events STRAIGHT into the +/// entity's motion-table stack via +/// (lazy initialize_state + +/// MotionTableManager::PerformMovement 0x0051c0b0). +/// +/// +/// This replaces the App-side RemoteMotionSink (deleted): no axis +/// collection, no single-cycle priority pick, no Commit pass, no HasCycle +/// probe, no Run→Walk→Ready missing-cycle chain — retail's +/// GetObjectSequence (0x00522860) + is_allowed decide what +/// plays. Forward locomotion installs the substate cycle (Branch 2); +/// sidesteps resolve by the dat (cycle when authored, else modifier); +/// turns are Branch-4 physics-only modifiers blended over the substate via +/// combine_motion/re_modify — the composition the retired +/// AP-73 row approximated with one cycle. +/// +/// +/// +/// The / callbacks are the +/// interim ObservedOmega seam: the App seeds the remote body's angular +/// velocity from the wire turn so rotation starts the same tick (retail +/// rotates the body from the sequence omega inside the per-tick +/// apply_physics chain — R6 scope; register row). They fire BEFORE +/// the dispatch so a consumer sees the seed even if the dat lacks the +/// modifier entry. +/// +/// +public sealed class MotionTableDispatchSink : IInterpretedMotionSink +{ + private readonly AnimationSequencer _sequencer; + + /// Turn-class dispatch observed: (motion, signedSpeed) — + /// TurnLeft arrives either as the explicit 0x0E command or as + /// TurnRight + negative speed (adjust_motion wire convention). + public Action? TurnApplied { get; set; } + + /// Turn-class stop observed. + public Action? TurnStopped { get; set; } + + public MotionTableDispatchSink(AnimationSequencer sequencer) + { + ArgumentNullException.ThrowIfNull(sequencer); + _sequencer = sequencer; + } + + private static bool IsTurn(uint motion) + => (motion & 0xFF000000u) == 0x65000000u && (motion & 0xFFu) is 0x0D or 0x0E; + + public void ApplyMotion(uint motion, float speed) + { + if (IsTurn(motion)) + TurnApplied?.Invoke(motion, speed); + + _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed)); + } + + public void StopMotion(uint motion) + { + if (IsTurn(motion)) + TurnStopped?.Invoke(); + + _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f)); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index e3478330..ed63ef8e 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -224,32 +224,16 @@ public sealed class AnimationSequencerTests } [Fact] - public void HasCycle_PresentInTable_ReturnsTrue() + public void SetCycle_MissingCycle_LeavesSequenceAndStateUntouched() { - // Phase L.1c followup (2026-04-28): regression guard for - // "torso on the ground" — caller (GameWindow MoveTo path) needs - // to query the table before SetCycle to avoid the - // ClearCyclicTail wipe on a missing cycle. - const uint Style = 0x003Cu; // HandCombat - const uint Motion = 0x0003u; // Ready - const uint AnimId = 0x03000001u; - - var setup = Fixtures.MakeSetup(2); - var mt = Fixtures.MakeMtable(Style, Motion, AnimId); - var loader = new FakeLoader(); - loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity)); - var seq = new AnimationSequencer(setup, mt, loader); - - // Caller passes the SAME shape SetCycle expects: full style with - // class byte (0x80000000) and full motion (0x40000000 / 0x10000000). - Assert.True(seq.HasCycle(0x8000003Cu, 0x41000003u)); - } - - [Fact] - public void HasCycle_MissingFromTable_ReturnsFalse() - { - const uint Style = 0x003Cu; - const uint ReadyMotion = 0x0003u; + // R2-Q5: HasCycle + the caller-side fallback chains are DELETED. + // The retail mechanism replacing the L.1c "torso on the ground" + // guard: GetObjectSequence (0x00522860) checks the cycle BEFORE any + // list surgery — a missing cycle leaves the sequence AND MotionState + // untouched, so whatever was playing (here the initialize_state + // default) keeps playing. + const uint Style = 0x8000003Cu; // HandCombat (full command) + const uint ReadyMotion = 0x41000003u; const uint AnimId = 0x03000001u; var setup = Fixtures.MakeSetup(2); @@ -258,9 +242,17 @@ public sealed class AnimationSequencerTests loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity)); var seq = new AnimationSequencer(setup, mt, loader); - // RunForward (0x44000007) is NOT in the table — caller should - // see false and fall back to a known motion (WalkForward / Ready). - Assert.False(seq.HasCycle(0x8000003Cu, 0x44000007u)); + seq.InitializeState(); + Assert.Equal(ReadyMotion, seq.CurrentMotion); + int nodesBefore = seq.QueueCount; + + // RunForward (0x44000007) is NOT in the table — the dispatch fails + // and nothing changes (no cyclic-tail wipe, no state overwrite). + seq.SetCycle(Style, 0x44000007u); + + Assert.Equal(ReadyMotion, seq.CurrentMotion); + Assert.Equal(nodesBefore, seq.QueueCount); + Assert.True(seq.HasCurrentNode); } [Fact] diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs new file mode 100644 index 00000000..192927a6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs @@ -0,0 +1,167 @@ +using System.Linq; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R2-Q5 — : the funnel's dispatches go +/// straight into (no axis +/// collection, no priority pick, no fallback chain — GetObjectSequence +/// 0x00522860 + is_allowed decide) with the TurnApplied/TurnStopped +/// ObservedOmega seam. +/// +public class MotionTableDispatchSinkTests +{ + private const uint NC = 0x8000003Du; + private const uint Ready = 0x41000003u; + private const uint Walk = 0x45000005u; + private const uint TurnRight = 0x6500000Du; + + private const uint ReadyAnim = 0x200u; + private const uint WalkAnim = 0x201u; + + private sealed class Loader : IAnimationLoader + { + 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; + } + + private static Animation MakeAnim(int frames) + { + var anim = new Animation(); + for (int f = 0; f < frames; f++) + { + var pf = new AnimationFrame(1); + pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf); + } + return anim; + } + + private static MotionData MakeMd(uint animId, Vector3? omega = null) + { + var md = new MotionData(); + QualifiedDataId qid = animId; + md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 30f }); + if (omega is { } o) + { + md.Omega = o; + md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasOmega; + } + return md; + } + + private static AnimationSequencer MakeSequencer(bool withTurnModifier = true) + { + var setup = new Setup(); + setup.Parts.Add(0x01000000u); + setup.DefaultScale.Add(Vector3.One); + + var loader = new Loader(); + loader.Register(ReadyAnim, MakeAnim(4)); + loader.Register(WalkAnim, MakeAnim(6)); + + var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC }; + mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready; + mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(ReadyAnim); + mt.Cycles[(int)((NC << 16) | (Walk & 0xFFFFFFu))] = MakeMd(WalkAnim); + if (withTurnModifier) + { + mt.Modifiers[(int)((NC << 16) | (TurnRight & 0xFFFFFFu))] = + MakeMd(ReadyAnim, omega: new Vector3(0f, 0f, 1.2f)); + } + + return new AnimationSequencer(setup, mt, loader); + } + + [Fact] + public void ApplyMotion_CycleClass_InstallsSubstate_LazyInitialized() + { + var seq = MakeSequencer(); + var sink = new MotionTableDispatchSink(seq); + + // Fresh sequencer: the first dispatch lazily runs initialize_state + // (retail lazy-create, r3-motioninterp-decomp §6g) then installs + // the requested substate. + sink.ApplyMotion(Walk, 2.0f); + + Assert.Equal(Walk, seq.CurrentMotion); + Assert.Equal(2.0f, seq.CurrentSpeedMod); + // Locomotion velocity synthesis ran in the passthrough (AP-75). + Assert.Equal(MotionInterpreter.WalkAnimSpeed * 2.0f, seq.CurrentVelocity.Y, 3); + } + + [Fact] + public void ApplyMotion_Turn_Branch4Modifier_FiresTurnApplied_NoCycleChange() + { + var seq = MakeSequencer(); + uint? turnMotion = null; + float turnSpeed = 0f; + var sink = new MotionTableDispatchSink(seq) + { + TurnApplied = (m, s) => { turnMotion = m; turnSpeed = s; }, + }; + + sink.ApplyMotion(Walk, 1.0f); + sink.ApplyMotion(TurnRight, 1.5f); + + // The AP-73 mechanism for real: substate cycle untouched, the turn + // is a MotionState modifier with its dat omega combined. + Assert.Equal(Walk, seq.CurrentMotion); + Assert.Equal((TurnRight, 1.5f), (turnMotion!.Value, turnSpeed)); + Assert.Equal(1.2f * 1.5f, seq.CurrentOmega.Z, 3); + } + + [Fact] + public void StopMotion_Turn_FiresTurnStopped_UnwindsModifierPhysics() + { + var seq = MakeSequencer(); + bool stopped = false; + var sink = new MotionTableDispatchSink(seq) { TurnStopped = () => stopped = true }; + + sink.ApplyMotion(Walk, 1.0f); + sink.ApplyMotion(TurnRight, 1.5f); + sink.StopMotion(TurnRight); + + Assert.True(stopped); + // StopSequenceMotion Case B (0x00522fc0): subtract_motion of the dat + // omega + modifier unlinked. + Assert.Equal(0f, seq.CurrentOmega.Z, 3); + } + + [Fact] + public void StopMotion_CurrentSubstate_ReDrivesToStyleDefault() + { + var seq = MakeSequencer(); + var sink = new MotionTableDispatchSink(seq); + + sink.ApplyMotion(Walk, 1.0f); + sink.StopMotion(Walk); + + // StopSequenceMotion Case A: stopping the active cycle re-drives + // GetObjectSequence toward the style default (Ready). + Assert.Equal(Ready, seq.CurrentMotion); + } + + [Fact] + public void ApplyMotion_MissingEverywhere_NoOp_DefaultKeepsPlaying() + { + var seq = MakeSequencer(withTurnModifier: false); + var sink = new MotionTableDispatchSink(seq); + + sink.ApplyMotion(Walk, 1.0f); + // 0x44000007 RunForward: no cycle, no modifier -> dispatch fails, + // sequence + state untouched (no fallback chain — H4/H5). + sink.ApplyMotion(0x44000007u, 2.0f); + + Assert.Equal(Walk, seq.CurrentMotion); + Assert.Equal(1.0f, seq.CurrentSpeedMod); + } +}