feat(R2-Q5): RemoteMotionSink DELETED — funnel dispatches straight into PerformMovement; AP-73 retired

The funnel's retail-ordered dispatches now go directly into the entity's
motion-table stack via Motion/MotionTableDispatchSink (Core):
ApplyMotion → PerformMovement(InterpretedCommand), StopMotion →
PerformMovement(StopInterpretedCommand). No axis collection, no
single-cycle priority pick, no Commit pass, no HasCycle probe, no
Run→Walk→Ready fallback chain — GetObjectSequence 0x00522860 +
is_allowed decide (closes H4, H5-callers, H11-callers, H15, H17-carry).
Run-while-turning now blends for real: the turn is a Branch-4 modifier
combined over the untouched run substate (AP-73 DELETED from the
register).

AnimationSequencer gains the public PerformMovement passthrough (lazy
initialize_state + locomotion velocity synthesis on success — AP-75;
remote body translation via PositionManager.ComputeOffset depends on
CurrentVelocity) and InitializeState(); HasCycle DELETED (the miss
hazard is structurally gone — GetObjectSequence checks the cycle before
any surgery; new conformance test pins sequence+state untouched on a
miss).

GameWindow: sink swap with the TurnApplied/TurnStopped ObservedOmega
callbacks (H17 carried verbatim — register AP-76, retire R6); spawn +
door sites run retail's enter-world order (initialize_state installs
the table default, the wire's initial motion dispatches unguarded — the
L.1c fallback chains deleted); despawn drains the pending queue via
HandleExitWorld (MotionDone success:false per entry, 0x0051bda0).

5 new MotionTableDispatchSink conformance tests (lazy init, Branch-4
turn blend + callback, Case-B stop unwind, Case-A stop re-drive,
miss no-op). Full suite green: 3,462 passed.

Live smoke vs ACE: in-world, NPC emote/Ready UMs dispatching through
the new path, [MOTIONDONE] completions firing across entities — first
live proof of the Q3→Q4→Q5 chain. Zero exceptions.

Registers: AP-73 deleted; AP-76 added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 21:55:13 +02:00
parent c072b73686
commit d82f07d4e5
8 changed files with 399 additions and 395 deletions

View file

@ -408,7 +408,7 @@ public sealed class GameWindow : IDisposable
/// remote gets the same treatment as the local player.
/// </para>
/// </summary>
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<uint, uint>?)[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();
}
}

View file

@ -1,217 +0,0 @@
using System;
using AcDream.Core.Physics;
namespace AcDream.App.Rendering;
/// <summary>
/// L.2g S2b — the App-side <see cref="IInterpretedMotionSink"/> for remote
/// entities: receives the funnel's retail-ordered dispatches
/// (<c>CMotionInterp::apply_interpreted_movement</c> order: style →
/// forward-or-Falling → sidestep → turn → actions) and maps them onto the
/// single-cycle <see cref="AnimationSequencer"/>.
///
/// <para>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 <c>GameWindow.OnLiveMotionUpdated</c> 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.</para>
///
/// <para>Airborne behavior needs no special-casing here: the funnel's
/// verbatim <c>contact_allows_move</c> 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.</para>
///
/// Lifecycle: construct per-UM, pass to
/// <see cref="MotionInterpreter.MoveToInterpretedState"/>, then call
/// <see cref="Commit"/> once to resolve the collected axes into at most one
/// SetCycle.
/// </summary>
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.
}
/// <summary>
/// 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").
/// </summary>
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);
}
}