Retail CPhysicsObj::RemoveLinkAnimations (0x0050fe20) is a tailcall to CPartArray::HandleEnterWorld (0x00517d70) -> MotionTableManager:: HandleEnterWorld (0x0051bdd0): remove_all_link_animations PLUS a full pending_animations drain (while (head) AnimationDone(0)), each pop relaying MotionDone so CMotionInterp pops its pending_motions node in lockstep. acdream bound the seam to the bare sequence strip, so every jump's LeaveGround removed the animations that queued manager nodes were counting down on — orphaning them (NumAnims>0, anims gone) and permanently damming BOTH queues. MotionsPending() then never drained (probe round: last player pending=False at the first MovementJump press; old jump motions still completing at rest minutes later) and BeginTurnToHeading/BeginMoveForward's verbatim motions_pending gates starved every armed moveto: ACE's mt-6 walk-to-door armed but the body never walked (wire-proven, seqs 98-101); the close-range use turn never completed so the deferred action was silently eaten. Doors only worked on a fresh session (shallow queue). Rebind both production sites (remote EnsureRemoteMotionBindings + the player's EnterPlayerModeNow block) to Manager.HandleEnterWorld(); the sequencer wrapper was a pure passthrough so the manager call is a strict superset. All six interp seam sites (LeaveGround, HitGround, Dead, and the detached-object guards) are the same retail chain. Harness mirrors updated; pins: Issue174LinkStripDrainTests (the seam drains both queues; fresh motions queue and complete after). Suites: Core 2535 / App 713 / UI 425 / Net 385 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
386 lines
17 KiB
C#
386 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// R4-V5 — local-player MoveTo cutover: a <see cref="MoveToManager"/> bound
|
|
/// to the controller exactly the way <c>GameWindow.EnterPlayerModeNow</c>
|
|
/// binds it (Yaw-authoritative heading seams via the P5 bridge, Contact
|
|
/// transient bit, SimTimeSeconds clock) drives the player's body through
|
|
/// <see cref="PlayerMovementController.Update"/> with NO user input — the
|
|
/// manager's <c>_DoMotion</c> 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).
|
|
/// </summary>
|
|
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<uint, Animation> _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<Animation> 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<CellSurface>(),
|
|
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
|
|
return engine;
|
|
}
|
|
|
|
private sealed class Rig
|
|
{
|
|
public required PlayerMovementController Controller;
|
|
public required MoveToManager MoveTo;
|
|
public int MoveToCompleteCount;
|
|
public WeenieError LastCompleteError;
|
|
}
|
|
|
|
/// <summary>The EnterPlayerModeNow bind set, verbatim shape: real sink,
|
|
/// Yaw-authoritative heading seams (P5 bridge), Contact bit, false
|
|
/// isInterpolating, SimTimeSeconds clock, TS-36 interrupt binding.</summary>
|
|
private static Rig MakeRig() => MakeRig(out _);
|
|
|
|
private static Rig MakeRig(out AnimationSequencer seqOut)
|
|
{
|
|
var controller = new PlayerMovementController(MakeFlatEngine());
|
|
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);
|
|
// #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
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
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;
|
|
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()
|
|
{
|
|
// 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);
|
|
}
|
|
}
|