using System; using System.Numerics; using AcDream.App.Input; using AcDream.App.Rendering; 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 MissileCombat = 0x8000003Fu; private const uint Ready = 0x41000003u; private const uint Reload = 0x40000016u; 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.StyleDefaults[(DRWMotionCommand)MissileCombat] = (DRWMotionCommand)Ready; mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u); mt.Cycles[(int)((MissileCombat << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u); mt.Cycles[(int)((MissileCombat << 16) | (Reload & 0xFFFFFFu))] = MakeMd(0x301u); 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); mt.Cycles[(int)((MissileCombat << 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 required EntityPhysicsHost PlayerHost; public required Dictionary Hosts; public int MoveToCompleteCount; public WeenieError LastCompleteError; public void AddStaticTarget(uint guid, Position position) { var target = new EntityPhysicsHost( guid, getPosition: () => position, getVelocity: () => Vector3.Zero, getRadius: () => 0.5f, inContact: () => true, minterpMaxSpeed: () => null, curTime: () => Controller.SimTimeSeconds, physicsTimerTime: () => Controller.SimTimeSeconds, getObjectA: id => Hosts.TryGetValue(id, out var host) ? host : null, handleUpdateTarget: _ => { }, interruptCurrentMovement: () => { }); Hosts[guid] = target; } } /// 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()); var seq = MakeSequencer(); seqOut = seq; // Production AnimatedEntity wiring installs the completion callback // before EnterPlayerModeNow binds the sink and calls SetPosition. // A synchronous zero-tick completion at the CPhysicsObj movement // boundary must therefore reach MotionInterpreter even during the // initial placement stop. seq.MotionDoneTarget = (m, ok) => controller.Motion.MotionDone(m, ok); // 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); // #174: production wiring — HandleEnterWorld (strip + drain), not // the bare sequence strip (which orphaned pending manager nodes). controller.Motion.RemoveLinkAnimations = () => seq.Manager.HandleEnterWorld(); 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 const uint selfGuid = 0x5000000Au; var hosts = new Dictionary(); EntityPhysicsHost playerHost = null!; var rig = new Rig { Controller = controller, MoveTo = null!, PlayerHost = null!, Hosts = hosts, }; var moveTo = new MoveToManager( controller.Motion, stopCompletely: () => controller.StopCompletelyAtPhysicsObjectBoundary(), 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: () => selfGuid, setTarget: (ctx, id, radius, quantum) => playerHost.SetTarget(ctx, id, radius, quantum), clearTarget: () => playerHost.ClearTarget(), getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(), setTargetQuantum: quantum => playerHost.TargetManager.SetTargetQuantum(quantum), curTime: () => controller.SimTimeSeconds); playerHost = new EntityPhysicsHost( selfGuid, getPosition: () => new Position( controller.CellId, controller.Position, controller.BodyOrientation), getVelocity: () => controller.BodyVelocity, getRadius: () => 0.5f, inContact: () => controller.BodyInContact, minterpMaxSpeed: () => controller.Motion.GetMaxSpeed(), curTime: () => controller.SimTimeSeconds, physicsTimerTime: () => controller.SimTimeSeconds, getObjectA: id => hosts.TryGetValue(id, out var host) ? host : null, handleUpdateTarget: info => moveTo.HandleUpdateTarget(info), interruptCurrentMovement: () => moveTo.CancelMoveTo(WeenieError.ActionCancelled)); hosts[selfGuid] = playerHost; moveTo.MoveToComplete = err => { rig.MoveToCompleteCount++; rig.LastCompleteError = err; }; controller.MoveTo = moveTo; controller.Motion.InterruptCurrentMovement = () => moveTo.CancelMoveTo(WeenieError.ActionCancelled); rig.MoveTo = moveTo; rig.PlayerHost = playerHost; 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 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; 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() { // 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; 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 ServerTurnToObject_AfterMissileReload_ProductionFeed_RotatesAndCompletes() { // Live bow repro (2026-07-15): ACE broadcasts MissileCombat/Reload, // then a type-8 TurnToObject carrying StopCompletely before it // launches the arrow. After target resolution, retail's object turn // is exactly one TurnToHeading node, so this pins the animation-queue // boundary without replacing TargetManager with a test-only poll. var rig = MakeRig(out var seq); var c = rig.Controller; for (int f = 0; f < 120; f++) { c.Update(1f / 60f, new MovementInput { Run = true }); seq.Advance(1f / 60f); } Assert.Equal(WeenieError.None, c.Motion.DoMotion( MissileCombat, new MovementParameters())); Assert.Equal(WeenieError.None, c.Motion.DoInterpretedMotion( Reload, new MovementParameters { Speed = 2f })); const uint targetGuid = 0x80001234u; rig.AddStaticTarget(targetGuid, new Position( c.CellId, new Vector3(88f, 96f, 50f), Quaternion.Identity)); rig.MoveTo.PerformMovement(new MovementStruct { Type = MovementType.TurnToObject, ObjectId = targetGuid, TopLevelId = targetGuid, Params = new MovementParameters { StopCompletelyFlag = true, Speed = 1f, }, }); float initialYaw = c.Yaw; for (int f = 0; f < 600 && rig.MoveTo.IsMovingTo(); f++) { c.Update(1f / 60f, new MovementInput { Run = true }); seq.Advance(1f / 60f); } string interpQueue = string.Join(", ", c.Motion.PendingMotions.Select(n => $"0x{n.Motion:X8}")); string animationQueue = string.Join(", ", seq.Manager.PendingAnimations.Select(n => $"0x{n.Motion:X8}/{n.NumAnims}")); Assert.True(initialYaw != c.Yaw, $"turn never changed yaw; moving={rig.MoveTo.IsMovingTo()} " + $"complete={rig.MoveToCompleteCount} error={rig.LastCompleteError} " + $"interp=[{interpQueue}] animation=[{animationQueue}]"); Assert.False(rig.MoveTo.IsMovingTo()); Assert.Equal(1, rig.MoveToCompleteCount); Assert.InRange(MoveToMath.HeadingFromYaw(c.Yaw), 269.99f, 270.01f); Assert.False(c.Motion.MotionsPending()); } [Fact] public void KeyboardTurnRelease_ThenAttackStop_DrainsBeforeServerTurnToObject() { // Live bow repro (2026-07-15): turn the character away with a player // input edge, release the key, begin an attack (MaybeStopCompletely), // then receive ACE's type-8 TurnToObject. Retail routes the input // DoMotion/StopMotion calls through CPhysicsObj -> MovementManager, // so every edge runs the zero-tick completion sweep and leaves no // orphan that can starve BeginTurnToHeading. var rig = MakeRig(out var seq); var c = rig.Controller; for (int f = 0; f < 90; f++) { c.Update(1f / 60f, new MovementInput { Run = true, TurnRight = true }); seq.Advance(1f / 60f); } c.Update(1f / 60f, new MovementInput { Run = true }); seq.Advance(1f / 60f); for (int f = 0; f < 120 && c.Motion.MotionsPending(); f++) { c.Update(1f / 60f, new MovementInput { Run = true }); seq.Advance(1f / 60f); } Assert.False(c.Motion.MotionsPending(), "keyboard turn release must leave no interpreter queue orphan"); Assert.Empty(seq.Manager.PendingAnimations); Assert.True(c.PrepareForAttackRequest()); Assert.False(c.Motion.MotionsPending(), "attack-entry StopCompletely must synchronously drain zero-tick Ready"); Assert.Empty(seq.Manager.PendingAnimations); const uint targetGuid = 0x80004321u; rig.AddStaticTarget(targetGuid, new Position( c.CellId, c.Position + new Vector3(12f, 0f, 0f), Quaternion.Identity)); float initialYaw = c.Yaw; rig.Controller.SetLastMoveWasAutonomous(false); rig.MoveTo.PerformMovement(new MovementStruct { Type = MovementType.TurnToObject, ObjectId = targetGuid, TopLevelId = targetGuid, Params = new MovementParameters { StopCompletelyFlag = true, Speed = 1f, }, }); for (int f = 0; f < 600 && rig.MoveTo.IsMovingTo(); f++) { c.Update(1f / 60f, new MovementInput { Run = true }); seq.Advance(1f / 60f); } Assert.NotEqual(initialYaw, c.Yaw); Assert.False(rig.MoveTo.IsMovingTo()); Assert.Equal(1, rig.MoveToCompleteCount); Assert.InRange(MoveToMath.HeadingFromYaw(c.Yaw), 89.99f, 90.01f); Assert.False(c.Motion.MotionsPending()); Assert.Empty(seq.Manager.PendingAnimations); } [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); } }