From c2dc1a889cfe09fd7c29350d38049c5832b1f262 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 3 Jul 2026 14:15:36 +0200 Subject: [PATCH] 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 --- .../Input/PlayerMovementController.cs | 63 +++++++++++++--- src/AcDream.App/Rendering/GameWindow.cs | 20 +++++ .../Physics/InterpretedMotionFunnel.cs | 16 ++++ .../Physics/Motion/MotionTableDispatchSink.cs | 17 +++++ src/AcDream.Core/Physics/MotionInterpreter.cs | 23 +++++- .../Input/PlayerMoveToCutoverTests.cs | 74 ++++++++++++++++++- 6 files changed, 201 insertions(+), 12 deletions(-) diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 61530c2a..d60c82a2 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -344,6 +344,19 @@ public sealed class PlayerMovementController /// Update — heading reads/writes go through Yaw, not this). internal Quaternion BodyOrientation => _body.Orientation; + /// R4-V5 wedge fix — the P1 unpack store + /// (CPhysics::SetObjectMovement @00509730: + /// last_move_was_autonomous = arg7, written on the unpack path + /// for every applied movement event). GameWindow's local-player 0xF74C + /// branch stores the wire autonomous byte here (always false — the P1 + /// gate drops autonomous echoes); the speculative use install stores + /// false too (it models the wire mt-6 ACE sends moments later). Routes + /// the per-tick pump's A3 dual dispatch to the INTERPRETED branch + /// during a server moveto so apply_raw can't clobber the manager's + /// dispatched motions with the idle raw state. + internal void SetLastMoveWasAutonomous(bool autonomous) + => _body.LastMoveWasAutonomous = autonomous; + /// /// Wire the player's AnimationSequencer current cycle velocity into /// . When attached, @@ -497,6 +510,22 @@ public sealed class PlayerMovementController // §3 placement decision). MoveTo?.UseTime(); + // R4-V5 wedge fix (2026-07-03 live door bug), retail's per-tick + // completion slot: CPartArray::HandleMovement — a tailcall chain to + // MotionTableManager::CheckForCompletedMotions (0x00517d60 → + // 0x0051bfd0) — runs every tick right AFTER MovementManager::UseTime + // in UpdateObjectInternal (@005159a4). It flushes pending_animations + // entries whose animations already ran out so their AnimationDone → + // MotionDone pops land each tick even when no op fired that frame. + // (apply_current_movement is NOT part of the per-tick order — its + // retail callers are all event-driven: hold-key toggles, HitGround/ + // LeaveGround, ReportExhaustion.) Full tick ordering remains R6 + // scope; the companion fix is StopCompletely's previously-missing + // StopCompletely_Internal animation dispatch (see + // MotionInterpreter.StopCompletely), whose completable entry this + // slot drains. + _motion.CheckForCompletedMotions?.Invoke(); + // 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). @@ -582,6 +611,20 @@ public sealed class PlayerMovementController else if (!input.TurnRight && !input.TurnLeft && (_prevTurnRightHeld || _prevTurnLeftHeld)) { _motion.StopMotion(MotionCommand.TurnRight, p); motionEdgeFired = true; } + + // Retail stores last_move_was_autonomous = 1 at the CPhysicsObj + // INPUT boundary — CPhysicsObj::DoMotion @00510030 / + // CPhysicsObj::StopMotion @005100e0 (per call, before routing + // to MovementManager) and CommandInterpreter:: + // TakeControlFromServer @006b32f4. The MoveToManager's + // _DoMotion goes through CMotionInterp internals and NEVER + // touches the flag; the wire unpack path stores the wire byte + // (P1, 00509730). This edge block IS acdream's input boundary, + // so an edge firing is exactly retail's store site. The flag + // routes the per-tick pump's A3 dual dispatch (raw for + // input-driven motion, interpreted for server/manager-driven). + if (motionEdgeFired) + _body.LastMoveWasAutonomous = true; } _prevForwardHeld = input.Forward; @@ -655,16 +698,16 @@ public sealed class PlayerMovementController // that adjust_motion ran above. The hand-mirrored formulas are gone // (register TS-22 retired). var stateVel = _motion.get_state_velocity(); - // R3-W6: autonomous=true flags input-driven motion (the default - // false silently cleared LastMoveWasAutonomous and flipped the - // A3 dual dispatch to the interpreted branch). R4-V5: while the - // MoveToManager is driving, the last movement EVENT was the - // server's non-autonomous moveto — retail stores the wire - // autonomous byte on the unpack path (P1 pin, - // last_move_was_autonomous), so the per-frame stamp must not - // overwrite that with true mid-moveto. - bool autonomousMove = MoveTo is null || !MoveTo.IsMovingTo(); - _body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz), autonomous: autonomousMove); + // R4-V5 wedge fix: PRESERVE the flag — retail's + // last_move_was_autonomous is stored at EVENT boundaries only + // (input DoMotion/StopMotion edges above, the P1 wire-unpack + // store, LeaveGround's own autonomous=1 write), never re-stamped + // by the per-tick velocity write. V5's first cut stamped it per + // frame here, which mis-routed the pump's A3 dual dispatch on + // event-transition frames (apply_raw clobbering a just-armed + // moveto's dispatched motion with the raw Ready state). + _body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz), + autonomous: _body.LastMoveWasAutonomous); } // ── 3. Jump (charged) ───────────────────────────────────────────────── diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2ac789d0..85354ccf 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4690,6 +4690,19 @@ public sealed class GameWindow : IDisposable // R5/MovementManager scope — V0-pins.md P1 adjacent seam). if (_playerController is not null) { + // P1 tail (00509730): the unpack path stores the wire + // autonomous byte BEFORE unpack_movement — always false + // here (the gate above dropped autonomous events). This + // is what routes the controller's per-tick pump (A3 + // dual dispatch) to the INTERPRETED branch during a + // server moveto. LOCAL PLAYER ONLY for now: remotes' + // interps have no WeenieObj, which A3 treats as + // IsThePlayer — storing a remote player's autonomous + // byte would flip their per-tick apply onto the raw + // branch and clobber their funnel state; the remote + // store lands with real remote weenies (R5+). + _playerController.SetLastMoveWasAutonomous(update.IsAutonomous); + _playerController.Motion.InterruptCurrentMovement?.Invoke(); _playerController.Motion.UnstickFromObject?.Invoke(); @@ -12336,6 +12349,13 @@ public sealed class GameWindow : IDisposable ? AcDream.Core.Physics.MovementType.TurnToObject : AcDream.Core.Physics.MovementType.MoveToObject, }; + // Part of this install's AP-23 adaptation: store the autonomy flag + // exactly as the wire mt-6 this install anticipates would (the P1 + // unpack store, IsAutonomous=false) — without it the per-tick + // pump's A3 dispatch stays on the raw branch (the user's last input + // set it autonomous) and clobbers this moveto's dispatched motions + // with the idle raw state. + _playerController.SetLastMoveWasAutonomous(false); playerMoveTo.PerformMovement(ms); } diff --git a/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs b/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs index f276013d..8c0dea7e 100644 --- a/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs +++ b/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs @@ -56,6 +56,22 @@ public interface IInterpretedMotionSink /// — retail's CPhysicsObj::StopInterpretedMotion also returns a /// result that gates the post-stop add_to_queue + state-removal. bool StopMotion(uint motion); + + /// + /// R4-V5 wedge fix — retail CPhysicsObj::StopCompletely_Internal + /// (0x0050ead0) → CPartArray::StopCompletelyInternal (0x00518890) + /// → MotionTableManager::PerformMovement(MovementStruct{type=5}): + /// the ANIMATION-side full stop dispatched from the middle of + /// CMotionInterp::StopCompletely (raw @00527e90). The manager + /// queues its pending_animations entry UNCONDITIONALLY for type 5, and + /// that entry's AnimationDone → MotionDone is the matched pop for the A9 + /// pending_motions node StopCompletely enqueues right after this + /// call — without it the A9 node is an orphan and MotionsPending + /// never drains at idle (the 2026-07-03 moveto wedge). Default + /// implementation returns true (the null-sink "no animation + /// backend" posture — bare tests and sink-less interps are unaffected). + /// + bool StopCompletely() => true; } /// diff --git a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs index 55546fa1..8ffb1990 100644 --- a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs +++ b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs @@ -69,4 +69,21 @@ public sealed class MotionTableDispatchSink : IInterpretedMotionSink uint result = _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f)); return result == MotionTableManagerError.Success; } + + /// + /// R4-V5 wedge fix — retail CPartArray::StopCompletelyInternal + /// (0x00518890): MotionTableManager::PerformMovement(type 5). + /// The manager stops everything and queues its Ready-sentinel + /// pending_animations entry UNCONDITIONALLY — the completable partner + /// of the interp's A9 pending_motions node. A full stop ends any turn + /// cycle, so the ObservedOmega seam is notified like + /// 's turn-class branch. + /// + public bool StopCompletely() + { + TurnStopped?.Invoke(); + + uint result = _sequencer.PerformMovement(MotionTableMovement.StopCompletely()); + return result == MotionTableManagerError.Success; + } } diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index a0048089..fd6fed65 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -1221,7 +1221,28 @@ public sealed class MotionInterpreter : IMotionDoneSink InterpretedState.SideStepCommand = 0; InterpretedState.TurnCommand = 0; - // StopCompletely_Internal (0x0050f5a0) stand-in. + // StopCompletely_Internal — R4-V5 wedge-fix CORRECTION of the R3 + // misread: retail's body (0x0050ead0, tailcall + // CPartArray::StopCompletelyInternal 0x00518890) is + // MotionTableManager::PerformMovement(type 5) through the ANIMATION + // stack — NOT a physics-side velocity zero (the "0x0050f5a0" the R3 + // note cited is a different function's interior). The manager + // queues its type-5 pending_animations entry UNCONDITIONALLY, and + // that entry's AnimationDone → MotionDone is the matched pop for + // the A9 pending_motions node queued below. Without this dispatch + // every StopCompletely (login/teleport SetPosition + every + // MoveToManager PerformMovement head) left an orphan node, + // MotionsPending never drained at idle, and BeginTurnToHeading's + // wait-for-anims gate wedged every armed moveto (the 2026-07-03 + // live door bug — server walked the player, the local body never + // moved, rubber-band on the next input). + DefaultSink?.StopCompletely(); + + // Immediate velocity zero: kept from the R3 stand-in. Retail + // reaches zero through the Ready state's next velocity apply; + // acdream's controller altitude performs that apply in its grounded + // section-2 write, so the immediate zero preserves the established + // teleport/stop behavior for same-frame readers. PhysicsObj.set_velocity(Vector3.Zero); AddToQueue(contextId: 0, MotionCommand.Ready, (uint)jumpSnapshot); diff --git a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs index 22952dad..5ed5d54d 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs @@ -109,12 +109,15 @@ public class PlayerMoveToCutoverTests /// The EnterPlayerModeNow bind set, verbatim shape: real sink, /// Yaw-authoritative heading seams (P5 bridge), Contact bit, false /// isInterpolating, SimTimeSeconds clock, TS-36 interrupt binding. - 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,