diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 19de8c4e..0a3abfb4 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -99,7 +99,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 3. Documented approximation (AP) — 68 rows
+## 3. Documented approximation (AP) — 69 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -176,6 +176,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 |
+
## 4. Temporary stopgap (TS) — 33 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 68a5e2ce..6317f1a5 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.
///
///
- private sealed class RemoteMotion
+ internal sealed class RemoteMotion // internal: RemoteMotionSink (L.2g S2b) consumes it
{
public AcDream.Core.Physics.PhysicsBody Body;
public AcDream.Core.Physics.MotionInterpreter Motion;
@@ -4521,83 +4521,32 @@ public sealed class GameWindow : IDisposable
}
else
{
- var forwardRoute = AcDream.Core.Physics.AnimationCommandRouter.Classify(fullMotion);
- bool forwardIsOverlay = forwardRoute is AcDream.Core.Physics.AnimationCommandRouteKind.Action
- or AcDream.Core.Physics.AnimationCommandRouteKind.Modifier
- or AcDream.Core.Physics.AnimationCommandRouteKind.ChatEmote;
- bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck)
- && rmCheck.Airborne;
-
- // Retail MotionTable::GetObjectSequence routes action-class
- // ForwardCommand values (creature attacks, chat-emotes) through
- // the Action branch, where the swing is appended before the
- // current cyclic tail and currState.Substate remains Ready.
- // Treating 0x10000051/52/53 as SetCycle commands made the
- // immediate follow-up Ready packet abort the swing.
- // Phase L.1c followup (2026-04-28): the next two state-update
- // blocks are LIFTED out of the substate-only `else` branch so
- // they run for BOTH overlay (Action/Modifier/ChatEmote) and
- // substate (Walk/Run/Ready/etc) packets. Two separate research
- // agents converged on the same root cause for the user-
- // observed "creature just runs instead of attacking" symptom:
+ // ── L.2g S2b (2026-07-02): remote entities flow through the
+ // verbatim CMotionInterp funnel. The wire state becomes an
+ // InboundInterpretedState (retail UnPack defaults for absent
+ // fields — a flags=0 UM is a wholesale stop, S0-verified),
+ // 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).
//
- // 1. Attack swings arrive as mt=0 with
- // ForwardCommand=AttackHigh1 (Action class). Retail's
- // CMotionInterp::move_to_interpreted_state
- // (acclient_2013_pseudo_c.txt:305936-305992) bulk-copies
- // forward_command from the wire into the body's
- // InterpretedState UNCONDITIONALLY. With
- // forward_command=AttackHigh1, get_state_velocity
- // returns 0 because its gate is RunForward||WalkForward
- // — body stops moving forward.
- //
- // 2. The acdream overlay branch was routing through
- // PlayAction (animation overlay) but skipping ALL of:
- // - ServerMoveToActive flag update
- // - MoveToPath capture
- // - InterpretedState.ForwardCommand assignment
- // So during a swing UM, the body's InterpretedState
- // stayed at RunForward from the prior MoveTo packet,
- // ServerMoveToActive stayed true, and the per-tick
- // remote driver kept steering + applying RunForward
- // velocity through every frame.
- //
- // Note: we bypass DoInterpretedMotion / ApplyMotionToInterpretedState
- // here because the latter is a heuristic that ONLY handles
- // WalkForward / RunForward / WalkBackward / SideStep / Turn
- // / Ready (MotionInterpreter.cs:941-970). For an Action
- // command (e.g. AttackHigh1 = 0x10000062) the switch falls
- // through and InterpretedState is silently NOT updated —
- // exactly the bug we are fixing. Direct field assignment
- // matches retail's copy_movement_from bulk-copy
- // (acclient_2013_pseudo_c.txt:293301-293311).
+ // MoveTo packets (mt 6/7) reuse the PlanMoveToStart seed as
+ // the funnel's forward command (fullMotion/speedMod computed
+ // above) and additionally capture the path for the per-tick
+ // remote driver.
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
{
remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo;
- // Bulk-copy the wire's resolved ForwardCommand + speed
- // into InterpretedState UNCONDITIONALLY (overlay,
- // substate, AND MoveTo packets). Matches retail's
- // copy_movement_from semantics
- // (acclient_2013_pseudo_c.txt:293301-293311) which does
- // not filter by MovementType.
- //
- // For MoveTo packets, fullMotion is the RunForward seed
- // from PlanMoveToStart, so this populates
- // ForwardCommand=RunForward + ForwardSpeed=speed*runRate
- // — what the OLD substate-only DoInterpretedMotion call
- // (commit f794832 removed) used to set. Without it,
- // apply_current_movement reads the default
- // ForwardCommand=Ready and produces zero velocity, so
- // chasing creatures only translate via UpdatePosition
- // hard-snaps and at spawn appear posed at default
- // (visible as "torso on the ground" until the first UP
- // snap hits).
- //
- // For overlay (Action) packets this sets ForwardCommand
- // to the Attack/Twitch/etc command, and
- // get_state_velocity returns 0 because the gate is
- // RunForward||WalkForward — body stops moving forward.
+ // [FWD_WIRE] + observed-velocity history invalidation on
+ // a forward-command change (pre-S2 behavior, unchanged:
+ // the per-tick scaling must not reuse a stale ratio
+ // derived from the OLD motion).
if (remoteMot.Motion.InterpretedState.ForwardCommand != fullMotion)
{
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
@@ -4608,35 +4557,16 @@ public sealed class GameWindow : IDisposable
+ $"newCmd=0x{fullMotion:X8} "
+ $"newLow=0x{fullMotion & 0xFFu:X2} speed={speedMod:F3}");
}
- // Motion command changed — invalidate observed-velocity
- // history so the per-tick scaling in TickAnimations
- // doesn't reuse a stale ratio derived from the OLD
- // motion (e.g. carrying run-pace serverSpeed into the
- // first walk frame, which would briefly accelerate
- // walk to run pace before settling).
remoteMot.PrevServerPosTime = 0.0;
}
- remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion;
- // Pass speedMod through verbatim — preserve sign so retail's
- // adjust_motion'd backward walk (cmd=WalkForward, spd<0)
- // produces backward velocity in get_state_velocity, NOT
- // forward. Pre-fix used `<=0 ? 1 : speedMod` which clamped
- // negative to 1.0 and made the dead-reckoned body translate
- // forward despite the reverse-playback animation — visually
- // "still walking forward" from the observer's POV.
- remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod;
if (update.MotionState.IsServerControlledMoveTo
&& update.MotionState.MoveToPath is { } path)
{
remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver
.OriginToWorld(
- path.OriginCellId,
- path.OriginX,
- path.OriginY,
- path.OriginZ,
- _liveCenterX,
- _liveCenterY);
+ path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
+ _liveCenterX, _liveCenterY);
remoteMot.MoveToMinDistance = path.MinDistance;
remoteMot.MoveToDistanceToObject = path.DistanceToObject;
remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards;
@@ -4646,247 +4576,77 @@ public sealed class GameWindow : IDisposable
}
else if (!update.MotionState.IsServerControlledMoveTo)
{
- // Off MoveTo — clear stale destination so the per-tick
- // driver doesn't keep steering.
+ // Off MoveTo — clear stale destination so the
+ // per-tick driver doesn't keep steering.
remoteMot.HasMoveToDestination = false;
}
- }
- if (forwardIsOverlay)
- {
- if (!remoteIsAirborne)
+ // Build the funnel input. fullMotion/speedMod already
+ // encode retail's absent-field forward defaults (null/0
+ // → Ready; MoveTo → PlanMoveToStart seed). Style:
+ // retail's InterpretedMotionState::UnPack (0x0051f400)
+ // defaults an absent stance to NonCombat 0x8000003D —
+ // NOT keep-current (S0 trace: every empty UM applied
+ // 0x8000003d on the retail observer).
+ var ims = new AcDream.Core.Physics.InboundInterpretedState
{
- AcDream.Core.Physics.AnimationCommandRouter.RouteFullCommand(
- ae.Sequencer,
- fullStyle,
- fullMotion,
- speedMod <= 0f ? 1f : speedMod);
- }
- }
- else
- {
- // Pick which cycle to play on the sequencer. Priority:
- // 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk.
- // 2. Else sidestep cmd if active — legs strafe.
- // 3. Else turn cmd if active — legs pivot.
- // 4. Else Ready — idle.
- //
- // For forward+sidestep or forward+turn, the forward cycle
- // wins at the anim layer; the sidestep/turn contribute via
- // MotionInterpreter velocity/omega writes.
- uint animCycle = fullMotion;
- float animSpeed = speedMod;
- uint fwdLow = fullMotion & 0xFFu;
- bool fwdIsRunWalk = fwdLow == 0x05 /* Walk */ || fwdLow == 0x06 /* WalkBack */
- || fwdLow == 0x07 /* Run */;
- if (!fwdIsRunWalk)
- {
- // Forward is Ready (or absent). Prefer sidestep cycle if present,
- // else turn cycle, else Ready.
- if (update.MotionState.SideStepCommand is { } sideForAnim && sideForAnim != 0)
- {
- uint sideFullForAnim = AcDream.Core.Physics.MotionCommandResolver
- .ReconstructFullCommand(sideForAnim);
- if (sideFullForAnim == 0) sideFullForAnim = 0x65000000u | sideForAnim;
- animCycle = sideFullForAnim;
- animSpeed = update.MotionState.SideStepSpeed ?? 1f;
- }
- else if (update.MotionState.TurnCommand is { } turnForAnim && turnForAnim != 0)
- {
- uint turnFullForAnim = AcDream.Core.Physics.MotionCommandResolver
- .ReconstructFullCommand(turnForAnim);
- if (turnFullForAnim == 0) turnFullForAnim = 0x65000000u | turnForAnim;
- animCycle = turnFullForAnim;
- // SIGNED — do NOT MathF.Abs. ACE encodes TurnLeft on the
- // wire as (TurnCommand=TurnRight, TurnSpeed=NEGATIVE),
- // mirroring retail's adjust_motion convention. The
- // sequencer's negative-speed path (EnqueueMotionData
- // multiplies MotionData.Omega by speedMod, the
- // synthesize-omega fallback flips zomega via
- // -(pi/2)*adjustedSpeed) only produces the correct
- // CCW rotation when the sign is preserved here.
- // Confirmed by live wire trace 2026-05-03: TurnLeft
- // input arrives as turnCmd16=0x000D, speed=-1.500.
- animSpeed = update.MotionState.TurnSpeed ?? 1f;
- }
- }
- // K-fix17 (2026-04-26): preserve the Falling cycle while
- // the remote is airborne. ACE broadcasts UpdateMotion
- // mid-arc when the player turns / runs — the previous
- // SetCycle here swapped Falling → RunForward, breaking
- // the visible jump animation. The arc still played out
- // physics-wise (body went up/down), but the legs ran
- // instead of folded. Skip the cycle swap when airborne;
- // the InterpretedState updates below still fire so the
- // body's velocity matches the new motion command, AND
- // the post-resolve landing path restores the cycle to
- // whatever the interpreted state says when the body
- // lands.
- if (!remoteIsAirborne)
- {
- // Fallback chain for missing cycles in the MotionTable.
- // SetCycle unconditionally calls ClearCyclicTail() before
- // looking up the cycle; if the cycle is absent, the body
- // ends up with no cyclic tail at all and every part snaps
- // to its setup-default offset — visible as "torso on the
- // ground" because most creatures' setup-default puts all
- // limbs at the torso origin.
- //
- // This is specifically a regression from commit 186a584
- // (Phase L.1c port): pre-fix, MoveTo packets fell through
- // to fullMotion=Ready (which always exists in every
- // MotionTable). Post-fix, MoveTo packets seed
- // fullMotion=RunForward, but some creatures (especially
- // when stance=HandCombat) lack a (combat, RunForward)
- // cycle. Fall through RunForward → WalkForward → Ready
- // until we find one the table actually contains.
- //
- // Note: this fallback is for the SEQUENCER (visible
- // animation) only. InterpretedState.ForwardCommand still
- // gets the wire's (or seeded) ForwardCommand verbatim
- // so apply_current_movement produces correct velocity.
- uint cycleToPlay = animCycle;
- if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
- && (animCycle & 0xFFu) is 0x05u or 0x07u)
- {
- bool hc = ae.Sequencer.HasCycle(fullStyle, cycleToPlay);
- System.Console.WriteLine(
- $"[HASCYCLE] guid={update.Guid:X8} style=0x{fullStyle:X8} "
- + $"requestedCycle=0x{cycleToPlay:X8} HasCycle={hc}");
- }
- if (!ae.Sequencer.HasCycle(fullStyle, cycleToPlay))
- {
- uint requested = cycleToPlay;
- // RunForward (0x44000007) → WalkForward (0x45000005)
- if ((cycleToPlay & 0xFFu) == 0x07
- && ae.Sequencer.HasCycle(fullStyle, 0x45000005u))
- {
- cycleToPlay = 0x45000005u;
- }
- // WalkForward → Ready (0x41000003)
- else if (ae.Sequencer.HasCycle(fullStyle, 0x41000003u))
- {
- cycleToPlay = 0x41000003u;
- }
- // Ready missing too — leave the existing cycle alone
- // by not calling SetCycle at all (avoids the
- // ClearCyclicTail wipe).
- else
- {
- cycleToPlay = 0;
- }
-
- if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
- {
- Console.WriteLine(
- $"UM cycle missing for guid=0x{update.Guid:X8} " +
- $"style=0x{fullStyle:X8} requested=0x{requested:X8} " +
- $"→ fallback=0x{cycleToPlay:X8}");
- }
- }
- if (cycleToPlay != 0)
- {
- if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
- && (ae.Sequencer.CurrentMotion != cycleToPlay
- || MathF.Abs(ae.Sequencer.CurrentSpeedMod - animSpeed) > 1e-3f))
- {
- System.Console.WriteLine(
- $"[SETCYCLE] guid={update.Guid:X8} "
- + $"old=(motion=0x{ae.Sequencer.CurrentMotion:X8} speed={ae.Sequencer.CurrentSpeedMod:F3}) "
- + $"new=(motion=0x{cycleToPlay:X8} speed={animSpeed:F3})");
- }
- ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed);
- }
- }
-
- // Retail runs the full MotionInterp state machine on every
- // remote. Route each wire command (forward, sidestep, turn)
- // through DoInterpretedMotion so apply_current_movement →
- // get_state_velocity → PhysicsBody.set_local_velocity fires
- // on a subsequent tick exactly as retail's FUN_00529210
- // (apply_current_movement) does.
- //
- // Decompile refs:
- // FUN_00529930 DoMotion
- // FUN_00528f70 DoInterpretedMotion
- // FUN_00528960 get_state_velocity
- // FUN_00529210 apply_current_movement
- // ServerMoveToActive flag, MoveToPath capture, and the
- // InterpretedState.ForwardCommand bulk-copy are already
- // handled by the LIFTED block above (so overlay-class swings
- // also clear stale MoveTo state and update the body's
- // forward command). This branch only handles sidestep /
- // turn axes plus the ObservedOmega seed — none of which
- // appear on overlay packets, so the existing logic is
- // correct unchanged. (`remoteMot` is the same dictionary
- // entry obtained at the top of the lifted block.)
- if (remoteMot is not null)
- {
- // Sidestep axis.
+ CurrentStyle = stance != 0 ? (0x80000000u | (uint)stance) : 0x8000003Du,
+ ForwardCommand = fullMotion,
+ ForwardSpeed = speedMod,
+ SideStepCommand = 0u,
+ SideStepSpeed = update.MotionState.SideStepSpeed ?? 1f,
+ TurnCommand = 0u,
+ TurnSpeed = update.MotionState.TurnSpeed ?? 1f,
+ };
if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0)
{
uint sideFull = AcDream.Core.Physics.MotionCommandResolver
.ReconstructFullCommand(sideCmd16);
- if (sideFull == 0) sideFull = 0x65000000u | sideCmd16;
- float sideSpd = update.MotionState.SideStepSpeed ?? 1f;
- remoteMot.Motion.DoInterpretedMotion(
- sideFull, sideSpd, modifyInterpretedState: true);
+ ims.SideStepCommand = sideFull != 0 ? sideFull : (0x65000000u | sideCmd16);
}
- else
- {
- // No sidestep — clear any leftover strafing motion.
- remoteMot.Motion.StopInterpretedMotion(
- AcDream.Core.Physics.MotionCommand.SideStepRight, modifyInterpretedState: true);
- remoteMot.Motion.StopInterpretedMotion(
- AcDream.Core.Physics.MotionCommand.SideStepLeft, modifyInterpretedState: true);
- }
-
- // Turn axis — and use as the on/off switch for ObservedOmega.
- // On turn start: seed ObservedOmega from the formula
- // (π/2 × turnSpeed) so rotation begins THIS tick without
- // waiting for the next UP to observe a delta.
- // On turn end: zero ObservedOmega so rotation stops
- // immediately instead of coasting at the last observed
- // rate until the next UP shows zero delta.
- // UpdatePosition still REFINES the rate from actual
- // server deltas (more accurate than the formula), but
- // this ensures instant on/off response.
if (update.MotionState.TurnCommand is { } turnCmd16 && turnCmd16 != 0)
{
uint turnFull = AcDream.Core.Physics.MotionCommandResolver
.ReconstructFullCommand(turnCmd16);
- if (turnFull == 0) turnFull = 0x65000000u | turnCmd16;
- float turnSpd = update.MotionState.TurnSpeed ?? 1f;
+ ims.TurnCommand = turnFull != 0 ? turnFull : (0x65000000u | turnCmd16);
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
System.Console.WriteLine(
$"[TURN_WIRE] guid={update.Guid:X8} turnCmd16=0x{turnCmd16:X4} "
- + $"turnFull=0x{turnFull:X8} low=0x{turnFull & 0xFFu:X2} "
- + $"({(((turnFull & 0xFFu) == 0x0D) ? "TurnRight" : ((turnFull & 0xFFu) == 0x0E) ? "TurnLeft" : "OTHER")}) "
- + $"speed={turnSpd:F3}");
+ + $"turnFull=0x{ims.TurnCommand:X8} speed={ims.TurnSpeed:F3}");
}
- remoteMot.Motion.DoInterpretedMotion(
- turnFull, turnSpd, modifyInterpretedState: true);
- // Seed ObservedOmega with formula so rotation starts
- // immediately; UP deltas will refine the rate.
- uint turnLow = turnFull & 0xFFu;
- if (turnLow == 0x0D /* TurnRight */)
- remoteMot.ObservedOmega = new System.Numerics.Vector3(0, 0, -(MathF.PI / 2f) * turnSpd);
- else if (turnLow == 0x0E /* TurnLeft */)
- remoteMot.ObservedOmega = new System.Numerics.Vector3(0, 0, (MathF.PI / 2f) * turnSpd);
}
- else
+
+ // Command list → the funnel's action list. Retail applies
+ // EVERY entry via DoInterpretedMotion under the 15-bit
+ // server_action_stamp gate — the gate is what makes ACE's
+ // re-bundled stale entries (e.g. the Ready bundled into a
+ // RunForward UM, 2026-05-03 finding) inert, replacing the
+ // old skip-SubState-class workaround.
+ if (update.MotionState.Commands is { Count: > 0 } cmdList)
{
- remoteMot.Motion.StopInterpretedMotion(
- AcDream.Core.Physics.MotionCommand.TurnRight, modifyInterpretedState: true);
- remoteMot.Motion.StopInterpretedMotion(
- AcDream.Core.Physics.MotionCommand.TurnLeft, modifyInterpretedState: true);
- // Zero ObservedOmega immediately — don't coast.
- remoteMot.ObservedOmega = System.Numerics.Vector3.Zero;
+ var actionList = new System.Collections.Generic.List(cmdList.Count);
+ foreach (var item in cmdList)
+ {
+ uint full = AcDream.Core.Physics.MotionCommandResolver
+ .ReconstructFullCommand(item.Command);
+ if (full == 0) full = 0x10000000u | item.Command;
+ actionList.Add(new AcDream.Core.Physics.InboundMotionAction(
+ full,
+ Stamp: item.PackedSequence & 0x7FFF,
+ Autonomous: (item.PackedSequence & 0x8000) != 0,
+ Speed: item.Speed));
+ }
+ ims.Actions = actionList;
}
+
+ RemoteMotionSink? sink = ae.Sequencer is not null
+ ? new RemoteMotionSink(ae.Sequencer, remoteMot, update.Guid)
+ : null;
+ remoteMot.Motion.MoveToInterpretedState(ims, sink);
+ sink?.Commit();
}
}
- }
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
// stamp the _remoteLastMove timestamp to "now". Without this,
@@ -4938,7 +4698,11 @@ public sealed class GameWindow : IDisposable
// a second SetCycle call wiped the first within the same UM
// packet processing. Only Actions/Modifiers/ChatEmotes (overlays
// that interleave with the cycle) belong in the list iteration.
- if (update.MotionState.Commands is { Count: > 0 } cmds)
+ if (update.Guid == _playerServerGuid // L.2g S2b: LOCAL ONLY — remote command
+ // lists flow through the funnel's
+ // action-stamp gate (retail's actual
+ // mechanism for bundled stale entries)
+ && update.MotionState.Commands is { Count: > 0 } cmds)
{
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
@@ -4964,7 +4728,7 @@ public sealed class GameWindow : IDisposable
if (itemRoute == AcDream.Core.Physics.AnimationCommandRouteKind.SubState)
continue;
AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand(
- ae.Sequencer,
+ ae.Sequencer!, // guarded by the enclosing `if (ae.Sequencer is not null)`
fullStyle,
item.Command,
item.Speed);
diff --git a/src/AcDream.App/Rendering/RemoteMotionSink.cs b/src/AcDream.App/Rendering/RemoteMotionSink.cs
new file mode 100644
index 00000000..1eb473ad
--- /dev/null
+++ b/src/AcDream.App/Rendering/RemoteMotionSink.cs
@@ -0,0 +1,208 @@
+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;
+ }
+
+ 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;
+ }
+
+ 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)
+ {
+ _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);
+ }
+}