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);
}
}