Member-wise deletion of the three legacy resolver members named in docs/research/2026-08-05-c5a-contract.md: PhysicsEngine.Resolve, PhysicsEngine.HasCellSurface, and PhysicsEngine.ResolvePlacement. An exhaustive receiver census over src/ found zero production callers of any of the three — every production placement writer already reaches the canonical PhysicsEngine.SetPosition transaction exclusively through RuntimeSetPositionState (three call sites total). The deletion is purely member-wise: IsSpawnCellReady and AdjustPosition, which shared the same source region as the deleted members, are preserved byte-identical — every remaining production caller of either (including PhysicsCameraCollisionProbe, AdjustPosition's sole surviving production caller) is unaffected. Companion changes: - PlayerMovementController's 3-argument SetPosition test overload is renamed to SeedPlacementForTest (internal) and CommitPreparedPosition is deleted; 83 call sites across 19 test files were mechanically renamed to match. - Seven pinned test dispositions from the contract are executed: 3.1 (PhysicsEngineTests.cs: 11 legacy-resolver tests deleted, 6 ResolveWithTransition tests kept), 3.2/3.3/3.4 (re-point to canonical SetPosition, with TransitionScratchDifferentialTests.cs additionally gaining positive IsCommitted assertions after each bitwise comparison so the differential proves a placement actually committed, not just that two possibly-uncommitted results match), 3.5 (Runtime rename), and 3.6 (PlayerMovementPlacementTransactionTests.cs rewritten — its xmldoc now states plainly that the render-root publish moved to RuntimeSetPositionState.cs, but the sticky-release relocation claim was false and is retracted; this disposition's coverage loss is the sticky release path, not silently absorbed elsewhere). - Stale `PhysicsEngine.Resolve`/`Resolve` doc citations in CellTransit.cs, PlayerMovementController.cs, and HeadlessSessionWorldProjection.cs are corrected to name the surviving canonical entry points by symbol (SetPosition, AdjustSetPosition/AdjustPosition, ResolveWithTransition) rather than fragile line numbers. Retires AP-1 and AD-1 in docs/architecture/retail-divergence-register.md: both rows described production zero-delta placement routing remaining on the legacy resolver pending the Slice 4B2/4B route cutover; that resolver no longer exists, so the condition each row tracked is now structurally false rather than merely narrowed. AP-145 (routed through the prior commit) and this commit's AP-1/AD-1 together bring the section counts to 101 AP / 47 AD active rows. Builds on the AP-145 fix (previous commit) — this commit's staged tree was independently rebuilt and its four suites independently rerun on top of that commit before this commit was created, in addition to the combined rebuild/rerun below. Full-solution build: 0 errors (21 pre-existing warnings, all unrelated). Suite results (combined tree): Core 4270/4271 passed (1 skip; the single DatSoundCacheTests concurrent-decode-dedup failure is a known load-sensitive race, confirmed passing standalone and unrelated to this change), Runtime 1176/1176, Headless 86/86, App 4132/4135 (3 skips). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
202 lines
8.6 KiB
C#
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
|
|
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);
|
|
}
|
|
}
|