fix(R4-V5): moveto wedge - StopCompletely's missing animation dispatch orphaned pending_motions; + the P1 autonomous store family
Live bug (2026-07-03, user door test): a server MoveTo armed (movingTo=True) but the local body never moved; the retail observer saw ACE walk the server-side player to the door; the next local input reasserted the stale position (rubber-band). Log evidence: the player's pending_motions queue was non-empty on 92/94 MotionDone pops (remotes: 0/40) - MoveToManager's retail wait-for-anims gate (BeginTurnToHeading's MotionsPending check) never opened. ROOT CAUSE (found via a production-faithful repro test - the original suite force-drained the queue, masking it): the R3 port MISIDENTIFIED retail's StopCompletely_Internal body. It is NOT a physics velocity zero - CPhysicsObj::StopCompletely_Internal (0x0050ead0) tailcalls CPartArray::StopCompletelyInternal (0x00518890) which is MotionTableManager::PerformMovement(type 5): an ANIMATION-side full stop whose UNCONDITIONALLY-queued pending_animations entry is the completable partner of the A9 pending_motions node CMotionInterp::StopCompletely enqueues (raw @00527e90 between the state writes and add_to_queue). Without the dispatch, every StopCompletely (login/teleport SetPosition + every MoveToManager PerformMovement head) left an orphan node with no completion - the queue never drained at idle. (The R3 code comment claimed a register row for the stand-in; none was ever added - porting the real mechanism closes that violation without a row.) Fix set (all retail-anchored): - IInterpretedMotionSink gains StopCompletely() (default true = the null-sink posture); MotionTableDispatchSink routes it to the R2 manager's already-ported type-5 op (StopObjectCompletely + Ready-sentinel entry) + TurnStopped for the ObservedOmega seam. - MotionInterpreter.StopCompletely calls DefaultSink?.StopCompletely() at the exact retail slot (after the state writes, before add_to_queue); the immediate set_velocity(Zero) is kept as the documented physics-effect stand-in. - PlayerMovementController ticks CheckForCompletedMotions after UseTime - retail's per-tick CPartArray::HandleMovement slot (0x00517d60 = MotionTableManager::UseTime = CheckForCompletedMotions tailcall, called every tick from UpdateObjectInternal @005159a4). NOTE: a first-cut per-tick apply_current_movement pump was tried and REVERTED - retail's apply callers are all event-driven (hold-key, HitGround/LeaveGround, ReportExhaustion), not per-tick; the remote block's per-tick apply is a pre-existing adaptation outside this fix's scope. - The P1 autonomous-store family, completing the pin's port shape (the gate landed in V5; the STORE side was missing): (a) the unpack store (00509730: last_move_was_autonomous = wire byte) in the GameWindow player branch - local player only for now (remote interps have no WeenieObj, which A3 reads as IsThePlayer; storing a remote's autonomous byte would mis-route their per-tick apply onto the raw branch); (b) input edges store =1 at the controller edge block - retail's CPhysicsObj::DoMotion @00510030 / StopMotion @005100e0 / TakeControlFromServer @006b32f4 input-boundary stores; (c) section 2's per-frame velocity write now PRESERVES the flag (V5's first cut stamped it per frame, the wrong altitude - retail stores it at event boundaries only); (d) InstallSpeculativeTurnToTarget stores false (models the wire mt-6's unpack store; part of the AP-23 adaptation) so the event-driven applies (e.g. HitGround mid-moveto) route interpreted and cannot clobber the manager's dispatched motions with idle raw state. Tests: new ServerMoveToPosition_ProductionCompletionFeed_WalksToArrival drives the controller under the PRODUCTION completion feed (sequencer.Advance -> MotionDoneTarget) instead of force-draining - this is the test that reproduced the live wedge (queue stuck at the two A9 Ready orphans) and now proves the drain. Existing cutover tests mirror the wire-path autonomous store before PerformMovement. Full suite green: 3,957. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b87726dc2c
commit
c2dc1a889c
6 changed files with 201 additions and 12 deletions
|
|
@ -109,12 +109,15 @@ public class PlayerMoveToCutoverTests
|
|||
/// <summary>The EnterPlayerModeNow bind set, verbatim shape: real sink,
|
||||
/// Yaw-authoritative heading seams (P5 bridge), Contact bit, false
|
||||
/// isInterpolating, SimTimeSeconds clock, TS-36 interrupt binding.</summary>
|
||||
private static Rig MakeRig()
|
||||
private static Rig MakeRig() => MakeRig(out _);
|
||||
|
||||
private static Rig MakeRig(out AnimationSequencer seqOut)
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
controller.Yaw = 0f; // heading 90 = facing +X
|
||||
var seq = MakeSequencer();
|
||||
seqOut = seq;
|
||||
controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
|
||||
controller.Motion.RemoveLinkAnimations = seq.RemoveAllLinkAnimations;
|
||||
controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState();
|
||||
|
|
@ -168,6 +171,10 @@ public class PlayerMoveToCutoverTests
|
|||
var c = rig.Controller;
|
||||
var dest = new Vector3(104f, 96f, 50f); // 8 m dead ahead (heading 90)
|
||||
|
||||
// Mirrors the GameWindow wire path's P1 unpack store (00509730):
|
||||
// a server moveto arrives with IsAutonomous=false, routing the
|
||||
// per-tick pump's A3 dual dispatch to the interpreted branch.
|
||||
rig.Controller.SetLastMoveWasAutonomous(false);
|
||||
rig.MoveTo.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.MoveToPosition,
|
||||
|
|
@ -204,6 +211,10 @@ public class PlayerMoveToCutoverTests
|
|||
var rig = MakeRig();
|
||||
var c = rig.Controller;
|
||||
|
||||
// Mirrors the GameWindow wire path's P1 unpack store (00509730):
|
||||
// a server moveto arrives with IsAutonomous=false, routing the
|
||||
// per-tick pump's A3 dual dispatch to the interpreted branch.
|
||||
rig.Controller.SetLastMoveWasAutonomous(false);
|
||||
rig.MoveTo.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.TurnToHeading,
|
||||
|
|
@ -232,6 +243,10 @@ public class PlayerMoveToCutoverTests
|
|||
var rig = MakeRig();
|
||||
var c = rig.Controller;
|
||||
|
||||
// Mirrors the GameWindow wire path's P1 unpack store (00509730):
|
||||
// a server moveto arrives with IsAutonomous=false, routing the
|
||||
// per-tick pump's A3 dual dispatch to the interpreted branch.
|
||||
rig.Controller.SetLastMoveWasAutonomous(false);
|
||||
rig.MoveTo.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.MoveToPosition,
|
||||
|
|
@ -255,12 +270,69 @@ public class PlayerMoveToCutoverTests
|
|||
Assert.True(result.MotionStateChanged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerMoveToPosition_ProductionCompletionFeed_WalksToArrival()
|
||||
{
|
||||
// The 2026-07-03 live wedge repro: same scenario as
|
||||
// ServerMoveToPosition_WalksToArrival_WithNoUserInput, but the
|
||||
// pending_motions queue drains the way PRODUCTION drains it — the
|
||||
// sequencer's Advance() fires MotionDoneTarget → Motion.MotionDone
|
||||
// (the TickAnimations R3-W2 bind) — instead of this suite's
|
||||
// force-drain. Live symptom: the moveto armed (movingTo=True) but
|
||||
// the body never moved; BeginTurnToHeading's wait-for-anims gate
|
||||
// (MotionsPending) never opened because the player's queue never
|
||||
// emptied (launch.log: pending=True on 92/94 player MOTIONDONE
|
||||
// pops; remotes 0/40).
|
||||
var rig = MakeRig(out var seq);
|
||||
var c = rig.Controller;
|
||||
seq.MotionDoneTarget = (m, ok) => c.Motion.MotionDone(m, ok);
|
||||
var dest = new Vector3(104f, 96f, 50f); // 8 m dead ahead (heading 90)
|
||||
|
||||
// A few idle frames first — production always has render ticks
|
||||
// between login (SetPosition → StopCompletely queues the A9 node
|
||||
// with NO dispatch) and the first Use.
|
||||
for (int f = 0; f < 30; f++)
|
||||
{
|
||||
c.Update(1f / 60f, new MovementInput());
|
||||
seq.Advance(1f / 60f);
|
||||
}
|
||||
|
||||
// Mirrors the GameWindow wire path's P1 unpack store (00509730):
|
||||
// a server moveto arrives with IsAutonomous=false, routing the
|
||||
// per-tick pump's A3 dual dispatch to the interpreted branch.
|
||||
rig.Controller.SetLastMoveWasAutonomous(false);
|
||||
rig.MoveTo.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.MoveToPosition,
|
||||
Pos = new Position(c.CellId, dest, Quaternion.Identity),
|
||||
Params = new MovementParameters { UseSpheres = false, DistanceToObject = 0.6f },
|
||||
});
|
||||
Assert.True(rig.MoveTo.IsMovingTo());
|
||||
|
||||
for (int f = 0; f < 1200 && rig.MoveTo.IsMovingTo(); f++)
|
||||
{
|
||||
c.Update(1f / 60f, new MovementInput());
|
||||
seq.Advance(1f / 60f);
|
||||
}
|
||||
|
||||
string queueDump = string.Join(", ",
|
||||
System.Linq.Enumerable.Select(c.Motion.PendingMotions, n => $"0x{n.Motion:X8}"));
|
||||
Assert.False(rig.MoveTo.IsMovingTo(),
|
||||
$"moveto wedged: pending_motions never drained under the production "
|
||||
+ $"completion feed (queue: [{queueDump}])");
|
||||
Assert.Equal(1, rig.MoveToCompleteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpRelease_CancelsMoveTo()
|
||||
{
|
||||
var rig = MakeRig();
|
||||
var c = rig.Controller;
|
||||
|
||||
// Mirrors the GameWindow wire path's P1 unpack store (00509730):
|
||||
// a server moveto arrives with IsAutonomous=false, routing the
|
||||
// per-tick pump's A3 dual dispatch to the interpreted branch.
|
||||
rig.Controller.SetLastMoveWasAutonomous(false);
|
||||
rig.MoveTo.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.MoveToPosition,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue