using System; using System.Numerics; using AcDream.App.Input; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; using Position = AcDream.Core.Physics.Position; namespace AcDream.Core.Tests.Input; /// /// R4-V5 — local-player MoveTo cutover: a bound /// to the controller exactly the way GameWindow.EnterPlayerModeNow /// binds it (Yaw-authoritative heading seams via the P5 bridge, Contact /// transient bit, SimTimeSeconds clock) drives the player's body through /// with NO user input — the /// manager's _DoMotion dispatches land in InterpretedState and the /// controller's per-frame sections turn them into Yaw rotation + body /// velocity. Also pins the TS-36 retirement: any input edge (movement key, /// jump) cancels the moveto through the retail /// InterruptCurrentMovement → CancelMoveTo chain, and a cancel never fires /// the MoveToComplete completion seam (AD-27's deferred-Use contract). /// public class PlayerMoveToCutoverTests { private const uint NC = 0x8000003Du; private const uint Ready = 0x41000003u; private const uint Walk = 0x45000005u; private const uint Run = 0x44000007u; private const uint TurnRight = 0x6500000Du; private sealed class Loader : IAnimationLoader { private readonly System.Collections.Generic.Dictionary _anims = new(); public void Register(uint id, Animation anim) => _anims[id] = anim; public Animation? LoadAnimation(uint id) => _anims.TryGetValue(id, out var a) ? a : null; } private static Animation MakeAnim(int frames) { var anim = new Animation(); for (int f = 0; f < frames; f++) { var pf = new AnimationFrame(1); pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }); anim.PartFrames.Add(pf); } return anim; } private static MotionData MakeMd(uint animId) { var md = new MotionData(); QualifiedDataId qid = animId; md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 30f }); return md; } private static AnimationSequencer MakeSequencer() { var setup = new Setup(); setup.Parts.Add(0x01000000u); setup.DefaultScale.Add(Vector3.One); var loader = new Loader(); loader.Register(0x300u, MakeAnim(4)); loader.Register(0x301u, MakeAnim(6)); loader.Register(0x302u, MakeAnim(6)); loader.Register(0x303u, MakeAnim(6)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC }; mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready; mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u); mt.Cycles[(int)((NC << 16) | (Walk & 0xFFFFFFu))] = MakeMd(0x301u); mt.Cycles[(int)((NC << 16) | (Run & 0xFFFFFFu))] = MakeMd(0x302u); // The MoveToManager's aux-turn steering / TurnToHeading nodes // dispatch TurnRight (adjust_motion normalizes TurnLeft into it) — // without a table cycle the real sink's false return becomes a // _DoMotion error and the manager cancels the moveto (retail // dispatch-failure semantics), so the fixture needs one. mt.Cycles[(int)((NC << 16) | (TurnRight & 0xFFFFFFu))] = MakeMd(0x303u); return new AnimationSequencer(setup, mt, loader); } private static PhysicsEngine MakeFlatEngine() { var engine = new PhysicsEngine(); var heights = new byte[81]; Array.Fill(heights, (byte)50); var heightTable = new float[256]; for (int i = 0; i < 256; i++) heightTable[i] = i * 1f; var terrain = new TerrainSurface(heights, heightTable); engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); return engine; } private sealed class Rig { public required PlayerMovementController Controller; public required MoveToManager MoveTo; public int MoveToCompleteCount; public WeenieError LastCompleteError; } /// 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() => 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(); controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions; var rig = new Rig { Controller = controller, MoveTo = null! }; var moveTo = new MoveToManager( controller.Motion, stopCompletely: () => controller.Motion.StopCompletely(), getPosition: () => new Position( controller.CellId, controller.Position, controller.BodyOrientation), getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw), setHeading: (h, _) => controller.Yaw = MoveToMath.YawFromHeading(h), getOwnRadius: () => 0f, getOwnHeight: () => 0f, contact: () => controller.BodyInContact, isInterpolating: () => false, getVelocity: () => controller.BodyVelocity, getSelfId: () => 0x5000000Au, setTarget: (_, _, _, _) => { }, clearTarget: () => { }, getTargetQuantum: () => 0.0, setTargetQuantum: _ => { }, curTime: () => controller.SimTimeSeconds); moveTo.MoveToComplete = err => { rig.MoveToCompleteCount++; rig.LastCompleteError = err; }; controller.MoveTo = moveTo; controller.Motion.InterruptCurrentMovement = () => moveTo.CancelMoveTo(WeenieError.ActionCancelled); rig.MoveTo = moveTo; return rig; } /// Stands in for the player sequencer's MotionDone feed (bound /// in production via the R3-W2 MotionTableManager seam) — without it, /// pending_motions never drains in this fixture and the manager's /// wait-for-anims gates wedge. private static void Drain(PlayerMovementController c) { while (c.Motion.MotionsPending()) c.Motion.MotionDone(0, true); } [Fact] public void ServerMoveToPosition_WalksToArrival_WithNoUserInput() { var rig = MakeRig(); 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, Pos = new Position(c.CellId, dest, Quaternion.Identity), Params = new MovementParameters { UseSpheres = false, DistanceToObject = 0.6f }, }); Drain(c); Assert.True(rig.MoveTo.IsMovingTo()); for (int f = 0; f < 1200 && rig.MoveTo.IsMovingTo(); f++) { var result = c.Update(1f / 60f, new MovementInput()); // The #75 invariant, now by construction: manager-driven motion // never registers as a user motion-state change, so no outbound // MoveToState is built mid-moveto. Assert.False(result.MotionStateChanged, $"manager-driven frame {f} must not flag MotionStateChanged"); Drain(c); } Assert.False(rig.MoveTo.IsMovingTo(), "moveto must arrive within the frame budget"); float distLeft = Vector2.Distance( new Vector2(c.Position.X, c.Position.Y), new Vector2(dest.X, dest.Y)); Assert.True(distLeft <= 1.0f, $"body must stop at the arrival radius; {distLeft:F2} m left"); Assert.Equal(1, rig.MoveToCompleteCount); Assert.Equal(WeenieError.None, rig.LastCompleteError); Assert.Equal(Ready, c.Motion.InterpretedState.ForwardCommand); } [Fact] public void ServerTurnToHeading_RotatesYaw_AndSnapsExact() { 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, Params = new MovementParameters { DesiredHeading = 180f }, // South = -Y }); Drain(c); Assert.True(rig.MoveTo.IsMovingTo()); for (int f = 0; f < 600 && rig.MoveTo.IsMovingTo(); f++) { c.Update(1f / 60f, new MovementInput()); Drain(c); } Assert.False(rig.MoveTo.IsMovingTo(), "turn-to must complete within the frame budget"); // HandleTurnToHeading's arrival snap (the ONE heading snap in the // family, 0052a146) writes through the Yaw seam: heading 180 → yaw // -π/2 per the P5 bridge. Assert.Equal(MoveToMath.YawFromHeading(180f), c.Yaw, 3); Assert.Equal(1, rig.MoveToCompleteCount); } [Fact] public void MovementKeyEdge_CancelsMoveTo_WithoutFiringComplete() { 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, Pos = new Position(c.CellId, new Vector3(150f, 96f, 50f), Quaternion.Identity), Params = new MovementParameters { UseSpheres = false }, }); Drain(c); Assert.True(rig.MoveTo.IsMovingTo()); c.Update(1f / 60f, new MovementInput()); Drain(c); Assert.True(rig.MoveTo.IsMovingTo()); // W press edge → DoMotion(ctor-default params, CancelMoveTo bit set) // → InterruptCurrentMovement → CancelMoveTo(ActionCancelled). var result = c.Update(1f / 60f, new MovementInput { Forward = true }); Assert.False(rig.MoveTo.IsMovingTo(), "user input must cancel the moveto (TS-36)"); Assert.Equal(0, rig.MoveToCompleteCount); // The cancel-frame's state change IS user intent — it must go on // the wire (the former !IsServerAutoWalking guard is gone). 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, Pos = new Position(c.CellId, new Vector3(150f, 96f, 50f), Quaternion.Identity), Params = new MovementParameters { UseSpheres = false }, }); Drain(c); Assert.True(rig.MoveTo.IsMovingTo()); // Charge one frame, release the next — jump() fires on the release // edge and its interrupt site cancels the moveto (the exact // silent-double-motion failure the TS-36 register row predicted). c.Update(1f / 60f, new MovementInput { Jump = true }); c.Update(1f / 60f, new MovementInput()); Assert.False(rig.MoveTo.IsMovingTo(), "jump must cancel the moveto (TS-36)"); Assert.Equal(0, rig.MoveToCompleteCount); } }