acdream/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs
Erik b54555da62 fix #174: RemoveLinkAnimations seam is HandleEnterWorld (strip + drain)
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>
2026-07-05 16:33:41 +02:00

202 lines
8.6 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;
namespace AcDream.Core.Tests.Input;
/// <summary>
/// R3-W6 regression suite for the edge-driven local player — specifically
/// the "press W and stop instantly" bug (2026-07-03): the funnel's apply
/// pass let its own style dispatch's <c>ApplyMotion(style)</c> state-write
/// reset forward to Ready (raw 0051ea6c). The original W6 fix entry-cached
/// the axis fields; #161 replaced that with the TRUE retail mechanism —
/// the apply pass's <c>MovementParameters</c> carry
/// <c>ModifyInterpretedState = false</c> (retail's smeared bitfield store
/// at raw 305778, mask 0x37ff; ACE MotionInterp.cs:447), so NO dispatch in
/// the pass can write <c>InterpretedState</c> at all and live field reads
/// are retail semantics. These tests pin the user-visible invariant either
/// way: pressing W keeps you moving.
///
/// These tests bind the REAL <see cref="MotionTableDispatchSink"/> over a
/// real sequencer (a fake sink's return values can mask state-write gating
/// — the lesson that created this file).
/// </summary>
public class W6EdgeDrivenMovementTests
{
private const uint NC = 0x8000003Du;
private const uint Ready = 0x41000003u;
private const uint Walk = 0x45000005u;
private const uint Run = 0x44000007u;
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));
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);
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 static PlayerMovementController MakeControllerWithRealSink(out AnimationSequencer seq)
{
var controller = new PlayerMovementController(MakeFlatEngine());
seq = MakeSequencer();
// The full W6 GameWindow bind set (EnterPlayerModeNow equivalent) —
// sink binds BEFORE SetPosition, matching the R4-V5 stall-fix order
// (SetPosition → StopCompletely needs the sink for its type-5
// dispatch, else its A9 pending_motions node is orphaned).
controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
var s = seq;
// #174: production wiring — HandleEnterWorld (strip + drain), not
// the bare sequence strip (which orphaned pending manager nodes).
controller.Motion.RemoveLinkAnimations = () => s.Manager.HandleEnterWorld();
controller.Motion.InitializeMotionTables = () => s.Manager.InitializeState();
controller.Motion.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions;
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
return controller;
}
/// <summary>
/// The full apply pass the W6b live bug rode in on. Pre-R4-V5 the
/// trigger was <c>ApplyServerRunRate</c> (the ACE autonomous-echo tap);
/// V5's P1 gate drops that echo before it reaches the player, and the
/// tap is deleted — but the regression these tests pin lives in
/// <c>ApplyInterpretedMovement</c>'s pass-params state protection
/// (ModifyInterpretedState=false since #161), which any
/// apply_current_movement pass (HitGround re-apply, future R6 per-tick
/// order) still exercises. Same two statements the deleted tap ran.
/// </summary>
private static void RunApplyPass(PlayerMovementController controller, float forwardSpeed)
{
controller.Motion.InterpretedState.ForwardSpeed = forwardSpeed;
controller.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
}
[Fact]
public void ApplyPass_WithRealSink_ForwardSelfHeals()
{
// The distilled bug: a full apply pass (style dispatch included)
// against the REAL sink must leave the interpreted forward exactly
// where it started — the style apply's Ready reset is re-applied
// over by the entry-cached fwd dispatch (retail self-heal).
var controller = MakeControllerWithRealSink(out _);
var input = new MovementInput { Forward = true, Run = true };
controller.Update(1f / 60f, input); // W press edge -> RunForward
Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand);
// The live killer: a full apply pass mid-hold (was the ~10Hz
// UM-echo tap pre-V5; see RunApplyPass).
RunApplyPass(controller, 4.5f);
Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand);
Assert.True(controller.Motion.get_state_velocity().Length() > 1f,
"state velocity must survive the apply pass");
}
[Fact]
public void HoldW_WithRealSink_AndEchoes_BodyKeepsMoving()
{
var controller = MakeControllerWithRealSink(out _);
var input = new MovementInput { Forward = true, Run = true };
float startX = 96f;
Vector3 pos = new(startX, 96f, 50f);
for (int f = 0; f < 120; f++)
{
if (f % 6 == 3)
RunApplyPass(controller, 4.5f); // ex-ACE-echo cadence
pos = controller.Update(1f / 60f, input).Position;
}
// 2 seconds of held-W running (post-fix ~9.5 m/s) must cover
// meters, not centimeters. Pre-fix this stalled at ~one tick of
// travel (the echo pass reset forward to Ready).
Assert.True(pos.X - startX > 5f,
$"expected sustained forward motion, got {pos.X - startX:F2} m");
}
[Fact]
public void ShiftToggle_MidHold_WalkRunTransitionSurvivesEcho()
{
var controller = MakeControllerWithRealSink(out _);
// Hold W at run for 30 frames.
for (int f = 0; f < 30; f++)
controller.Update(1f / 60f, new MovementInput { Forward = true, Run = true });
Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand);
// Shift pressed (walk) — the set_hold_run edge demotes to walk.
for (int f = 0; f < 30; f++)
{
if (f == 10) RunApplyPass(controller, 1.0f); // apply pass mid-walk
controller.Update(1f / 60f, new MovementInput { Forward = true, Run = false });
}
Assert.Equal(Walk, controller.Motion.InterpretedState.ForwardCommand);
// Shift released — promote back to run; survives another echo.
for (int f = 0; f < 30; f++)
{
if (f == 10) RunApplyPass(controller, 4.5f);
controller.Update(1f / 60f, new MovementInput { Forward = true, Run = true });
}
Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand);
}
}