From 24569fd2a1f149c7633e6db855788e948ca8ae41 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 3 Jul 2026 14:35:44 +0200 Subject: [PATCH] fix(R4-V5): moveto stall #2 - login SetPosition ran before the sink bind, orphaning one immortal pending_motions node The c2dc1a88 wedge fix paired every StopCompletely A9 node with a completable motion-table type-5 entry via DefaultSink?.StopCompletely() - but EnterPlayerModeNow called the initial SetPosition (whose teleport idle is StopCompletely, R3-W6) BEFORE the sequencer/DefaultSink bind block, so the login A9 node dispatched against a NULL sink and stayed an orphan. Head-pop-any semantics mean a queue with one orphan never reaches empty again (later completions just relabel the backlog), so MotionsPending stayed true at every UseTime and the MoveToManager's retail wait-for-anims gate (BeginTurnToHeading) never opened: every server MoveTo armed (movingTo=True, Initialized=True, tracker fed, node plan built) and the body never moved. Pinned live by the [autowalk-gate] probe: type=MoveToObject init=True contact=True motionsPending=True pm=[0x41000003] nodes=[TurnToHeading, MoveToPosition] curCmd=0 - identical every half-second, forever. Fix: the sequencer/sink bind block in EnterPlayerModeNow moves ABOVE the initial physics Resolve + SetPosition (no data dependency - the block only needs playerEntity/_animatedEntities). Teleport-arrival SetPositions were already safe (sink long bound). Test rigs (PlayerMoveToCutoverTests.MakeRig, W6EdgeDrivenMovementTests) mirror the fixed order, and a new LoginQueue_DrainsToEmpty_UnderProductionFeed test pins the invariant the probe caught: after login, pending_motions must reach EMPTY under the production completion feed. The [autowalk-gate]/[autowalk-feed] diagnostics stay in for the live verify pass (TEMPORARY-tagged; strip when #5 closes). Full suite green: 3,958. Co-Authored-By: Claude Fable 5 --- .../Input/PlayerMovementController.cs | 18 ++++++ src/AcDream.App/Rendering/GameWindow.cs | 55 ++++++++++++------- .../Input/PlayerMoveToCutoverTests.cs | 34 +++++++++++- .../Input/W6EdgeDrivenMovementTests.cs | 9 ++- 4 files changed, 90 insertions(+), 26 deletions(-) diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index d60c82a2..b90d6577 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -198,6 +198,8 @@ public sealed class PlayerMovementController private bool _prevTurnLeftHeld; private bool _prevTurnRightHeld; private bool _prevRunHeld; + // TEMPORARY diagnostic clock (2026-07-03 moveto-stall investigation). + private float _lastAutowalkGateLogTime = float.MinValue; // Heartbeat timer. // Cadence is 1.0 sec to match holtburger's @@ -526,6 +528,22 @@ public sealed class PlayerMovementController // slot drains. _motion.CheckForCompletedMotions?.Invoke(); + // TEMPORARY diagnostic (2026-07-03 moveto-stall investigation, + // strip when closed): every gate input the armed moveto's per-tick + // drive depends on, ~2 Hz while armed, ProbeAutoWalk-gated. + if (PhysicsDiagnostics.ProbeAutoWalkEnabled + && MoveTo is { } mtDiag && mtDiag.IsMovingTo() + && _simTimeSeconds - _lastAutowalkGateLogTime > 0.5f) + { + _lastAutowalkGateLogTime = _simTimeSeconds; + var pmParts = new System.Collections.Generic.List(); + foreach (var n in _motion.PendingMotions) pmParts.Add($"0x{n.Motion:X8}"); + var nodeParts = new System.Collections.Generic.List(); + foreach (var n in mtDiag.PendingActions) nodeParts.Add(n.Type.ToString()); + Console.WriteLine(System.FormattableString.Invariant( + $"[autowalk-gate] type={mtDiag.MovementTypeState} init={mtDiag.Initialized} tlid=0x{mtDiag.TopLevelObjectId:X8} contact={_body.InContact} motionsPending={_motion.MotionsPending()} pm=[{string.Join(",", pmParts)}] nodes=[{string.Join(",", nodeParts)}] curCmd=0x{mtDiag.CurrentCommand:X8} auxCmd=0x{mtDiag.AuxCommand:X8} fwd=0x{_motion.InterpretedState.ForwardCommand:X8} pos=({Position.X:F1},{Position.Y:F1}) yaw={Yaw:F2}")); + } + // Portal-space guard: while teleporting, no input is processed and // no physics is resolved. Return a zero-movement result so the caller // can detect the frozen state (MotionStateChanged = false, no commands). diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 85354ccf..ce5bf927 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7927,6 +7927,11 @@ public sealed class GameWindow : IDisposable _playerMoveToTargetGuid, AcDream.Core.Physics.Motion.TargetStatus.Ok, tp, tp)); + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[autowalk-feed] Ok guid=0x{_playerMoveToTargetGuid:X8} pos=({tpos.X:F1},{tpos.Y:F1}) init={playerMtm.Initialized}")); + } } } else @@ -13058,6 +13063,35 @@ public sealed class GameWindow : IDisposable int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f); pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u; } + // R4-V5 moveto-stall fix #2 (2026-07-03, the [autowalk-gate] probe's + // one-immortal-node finding): the sequencer/sink bind block MUST run + // BEFORE the initial SetPosition. SetPosition → StopCompletely + // enqueues the A9 pending_motions node whose completable partner is + // the DefaultSink's type-5 motion-table entry — with the sink still + // null at login, the node was ORPHANED, and pending_motions never + // reached empty again (head-pop-any just relabels a backlog), so + // the MoveToManager's wait-for-anims gate never opened: every + // server MoveTo armed but the body never moved. + if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE) + && playerAE.Sequencer is { } playerSeq) + { + _playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity); + // R3-W4: bind the player interp's retail seams to the player + // sequencer — LeaveGround/HitGround strip transition links here + // (the K-fix18 replacement). + _playerController.Motion.RemoveLinkAnimations = playerSeq.RemoveAllLinkAnimations; + _playerController.Motion.InitializeMotionTables = + () => playerSeq.Manager.InitializeState(); + _playerController.Motion.CheckForCompletedMotions = + playerSeq.Manager.CheckForCompletedMotions; + // R3-W6: the player's cycles are now driven through the SAME + // dispatch sink remotes use (TurnApplied/TurnStopped omitted — + // ObservedOmega is a remote-DR-only concept; local rotation is + // the controller's Yaw integration). + _playerController.Motion.DefaultSink = + new AcDream.Core.Physics.Motion.MotionTableDispatchSink(playerSeq); + } + var initResult = _physicsEngine.Resolve( playerEntity.Position, pinitCellId, System.Numerics.Vector3.Zero, 100f); @@ -13072,27 +13106,6 @@ public sealed class GameWindow : IDisposable playerEntity.SetPosition(initResult.Position); playerEntity.ParentCellId = initResult.CellId; - if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE) - && playerAE.Sequencer is { } playerSeq) - { - _playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity); - // R3-W4: bind the player interp's retail seams to the player - // sequencer — LeaveGround/HitGround strip transition links here - // (the K-fix18 replacement); DefaultSink stays null until R3-W6 - // (UpdatePlayerAnimation still drives the player's cycles). - _playerController.Motion.RemoveLinkAnimations = playerSeq.RemoveAllLinkAnimations; - _playerController.Motion.InitializeMotionTables = - () => playerSeq.Manager.InitializeState(); - _playerController.Motion.CheckForCompletedMotions = - playerSeq.Manager.CheckForCompletedMotions; - // R3-W6: the player's cycles are now driven through the SAME - // dispatch sink remotes use (TurnApplied/TurnStopped omitted — - // ObservedOmega is a remote-DR-only concept; local rotation is - // the controller's Yaw integration). - _playerController.Motion.DefaultSink = - new AcDream.Core.Physics.Motion.MotionTableDispatchSink(playerSeq); - } - var q = playerEntity.Rotation; float rawYaw = MathF.Atan2( 2f * (q.W * q.Z + q.X * q.Y), diff --git a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs index 5ed5d54d..e5146d20 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs @@ -114,14 +114,19 @@ public class PlayerMoveToCutoverTests 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; + // Production (EnterPlayerModeNow) order: the sink binds BEFORE the + // initial SetPosition — SetPosition → StopCompletely needs the sink + // for its type-5 motion-table dispatch, the completable partner of + // the A9 pending_motions node (a null sink orphans it and the + // MoveToManager wait-for-anims gate never opens — the live stall). controller.Motion.DefaultSink = new MotionTableDispatchSink(seq); controller.Motion.RemoveLinkAnimations = seq.RemoveAllLinkAnimations; controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState(); controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions; + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + controller.Yaw = 0f; // heading 90 = facing +X var rig = new Rig { Controller = controller, MoveTo = null! }; var moveTo = new MoveToManager( @@ -270,6 +275,31 @@ public class PlayerMoveToCutoverTests Assert.True(result.MotionStateChanged); } + [Fact] + public void LoginQueue_DrainsToEmpty_UnderProductionFeed() + { + // The second live stall (2026-07-03, [autowalk-gate] probe): ONE + // immortal Ready node in pending_motions — login SetPosition's + // StopCompletely fired before the sink bind, orphaning its A9 node + // (no completable partner). Head-pop-any relabels but never empties + // a queue with an orphan, and MotionsPending==true wedges every + // subsequent moveto. This pins the invariant: after login (rig + // construction, production bind order) the queue must reach EMPTY + // under the production completion feed. + var rig = MakeRig(out var seq); + var c = rig.Controller; + seq.MotionDoneTarget = (m, ok) => c.Motion.MotionDone(m, ok); + + for (int f = 0; f < 300 && c.Motion.MotionsPending(); f++) + { + c.Update(1f / 60f, new MovementInput()); + seq.Advance(1f / 60f); + } + + Assert.False(c.Motion.MotionsPending(), + "login pending_motions must drain to empty under the production feed"); + } + [Fact] public void ServerMoveToPosition_ProductionCompletionFeed_WalksToArrival() { diff --git a/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs b/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs index e810e84b..5cc7f54a 100644 --- a/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs +++ b/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs @@ -95,15 +95,18 @@ public class W6EdgeDrivenMovementTests private static PlayerMovementController MakeControllerWithRealSink(out AnimationSequencer seq) { var controller = new PlayerMovementController(MakeFlatEngine()); - controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); - controller.Yaw = 0f; seq = MakeSequencer(); - // The full W6 GameWindow bind set (EnterPlayerModeNow equivalent). + // The full W6 GameWindow bind set (EnterPlayerModeNow equivalent) — + // sink binds BEFORE SetPosition, matching the R4-V5 stall-fix order + // (SetPosition → StopCompletely needs the sink for its type-5 + // dispatch, else its A9 pending_motions node is orphaned). controller.Motion.DefaultSink = new MotionTableDispatchSink(seq); var s = seq; controller.Motion.RemoveLinkAnimations = s.RemoveAllLinkAnimations; controller.Motion.InitializeMotionTables = () => s.Manager.InitializeState(); controller.Motion.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions; + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + controller.Yaw = 0f; return controller; }