feat(L.2g-S2b): wire remote entities onto the CMotionInterp funnel (DEV-1 integration)

OnLiveMotionUpdated's remote branch (368 lines of bulk-copy + cycle
picker + per-axis DoInterpretedMotion + command-list router) collapses
to: build InboundInterpretedState from the wire (retail UnPack defaults;
MoveTo packets feed the PlanMoveToStart seed as the forward command) ->
MotionInterpreter.MoveToInterpretedState(ims, RemoteMotionSink) ->
sink.Commit().

RemoteMotionSink (new, App): receives the funnel's gate-passed
dispatches in retail order; the axis-priority pick (fwd > side > turn),
Run->Walk->Ready missing-cycle fallback, overlay routing, ObservedOmega
seeding, and diag lines are MOVED VERBATIM from the pre-S2 block.
Register row AP-73 documents the single-cycle composition approximation
(retail blends modifiers via re_modify — DEV-9, retires with S3/S6).

Retail-verbatim behavior changes:
- Absent stance now defaults to NonCombat 0x8000003D (retail UnPack
  default, S0-trace-verified) instead of keep-current.
- Remote command lists flow through the funnel's 15-bit action-stamp
  gate — retail's actual mechanism for ACE's re-bundled stale entries;
  the old skip-SubState router workaround is now local-player-only.
- Airborne cycle preservation is the funnel's contact_allows_move gate
  (K-fix17's guard semantics, now from the retail mechanism).
- Stops ride the same path: empty UM -> flat copy -> Ready dispatch ->
  get_state_velocity 0 (DEV-3 core; the 300ms stop-detection fallback
  stays until S6 verifies NPC unification).

Full suite green (3290). Live smoke next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 18:59:00 +02:00
parent 7b0cbbda2c
commit 67506ce988
3 changed files with 289 additions and 315 deletions

View file

@ -408,7 +408,7 @@ public sealed class GameWindow : IDisposable
/// remote gets the same treatment as the local player.
/// </para>
/// </summary>
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 <c>copy_movement_from</c> 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<AcDream.Core.Physics.InboundMotionAction>(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);