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>
208 lines
8.6 KiB
C#
208 lines
8.6 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.App.Input;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.UI.Abstractions.Input;
|
|
using Silk.NET.Input;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Input;
|
|
|
|
/// <summary>
|
|
/// K.1b integration: drive the <see cref="InputDispatcher"/> via the
|
|
/// public API a fake keyboard would, build a <see cref="MovementInput"/>
|
|
/// from <c>IsActionHeld</c> queries (the new K.1b code path), and confirm
|
|
/// <see cref="PlayerMovementController.Update"/> produces the same result
|
|
/// as feeding it the equivalent <c>MovementInput</c> directly. This is
|
|
/// the regression-prevention test for the "preserve the boundary" rule
|
|
/// in the K.1b plan: <c>MovementInput</c> stays as the contract; only the
|
|
/// SOURCE of input changes.
|
|
/// </summary>
|
|
public class DispatcherToMovementIntegrationTests
|
|
{
|
|
/// <summary>Test double — the same fake the UI.Abstractions tests use,
|
|
/// duplicated here because it's <c>internal</c> in that assembly.</summary>
|
|
private sealed class FakeKb : IKeyboardSource
|
|
{
|
|
public event Action<Key, ModifierMask>? KeyDown;
|
|
public event Action<Key, ModifierMask>? KeyUp;
|
|
private readonly System.Collections.Generic.HashSet<Key> _held = new();
|
|
public ModifierMask CurrentModifiers { get; set; } = ModifierMask.None;
|
|
public bool IsHeld(Key k) => _held.Contains(k);
|
|
public void Press(Key k, ModifierMask mods = ModifierMask.None)
|
|
{
|
|
CurrentModifiers = mods;
|
|
_held.Add(k);
|
|
KeyDown?.Invoke(k, mods);
|
|
}
|
|
public void Release(Key k, ModifierMask mods = ModifierMask.None)
|
|
{
|
|
CurrentModifiers = mods;
|
|
_held.Remove(k);
|
|
KeyUp?.Invoke(k, mods);
|
|
}
|
|
}
|
|
|
|
#pragma warning disable CS0067 // events declared on the interface but unused in this fake
|
|
private sealed class FakeMouse : IMouseSource
|
|
{
|
|
public event Action<MouseButton, ModifierMask>? MouseDown;
|
|
public event Action<MouseButton, ModifierMask>? MouseUp;
|
|
public event Action<float, float>? MouseMove;
|
|
public event Action<float>? Scroll;
|
|
public bool IsHeld(MouseButton b) => false;
|
|
public bool WantCaptureMouse { get; set; }
|
|
public bool WantCaptureKeyboard { get; set; }
|
|
}
|
|
#pragma warning restore CS0067
|
|
|
|
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 MovementInput BuildInputFromDispatcher(InputDispatcher d) =>
|
|
new MovementInput(
|
|
Forward: d.IsActionHeld(InputAction.MovementForward),
|
|
Backward: d.IsActionHeld(InputAction.MovementBackup),
|
|
StrafeLeft: d.IsActionHeld(InputAction.MovementStrafeLeft),
|
|
StrafeRight: d.IsActionHeld(InputAction.MovementStrafeRight),
|
|
TurnLeft: d.IsActionHeld(InputAction.MovementTurnLeft),
|
|
TurnRight: d.IsActionHeld(InputAction.MovementTurnRight),
|
|
Run: d.IsActionHeld(InputAction.MovementRunLock),
|
|
MouseDeltaX: 0f, // K.1b: mouse never drives character yaw
|
|
Jump: d.IsActionHeld(InputAction.MovementJump));
|
|
|
|
[Fact]
|
|
public void Dispatcher_W_held_produces_forward_motion()
|
|
{
|
|
var engine = MakeFlatEngine();
|
|
var controller = new PlayerMovementController(engine);
|
|
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
|
|
controller.Yaw = 0f; // facing +X
|
|
|
|
var kb = new FakeKb();
|
|
var mouse = new FakeMouse();
|
|
var bindings = KeyBindings.AcdreamCurrentDefaults();
|
|
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
|
dispatcher.Attach();
|
|
|
|
kb.Press(Key.W);
|
|
|
|
var input = BuildInputFromDispatcher(dispatcher);
|
|
Assert.True(input.Forward);
|
|
Assert.False(input.Run);
|
|
|
|
// L.5 physics-tick gate (235de33, 2026-04-30): Update() integrates
|
|
// only one MaxQuantum (~0.1s) physics step per call, matching
|
|
// retail's 30Hz physics gate. Drive the controller one MaxQuantum
|
|
// at a time for ~1s to accumulate real forward motion.
|
|
MovementResult result = default;
|
|
int ticks = (int)MathF.Ceiling(1.0f / PhysicsBody.MaxQuantum) + 1; // ~11 ticks
|
|
for (int i = 0; i < ticks; i++)
|
|
result = controller.Update(PhysicsBody.MaxQuantum, input);
|
|
Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward");
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispatcher_W_then_Shift_gives_running_motion()
|
|
{
|
|
var engine = MakeFlatEngine();
|
|
var controller = new PlayerMovementController(engine);
|
|
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
|
|
controller.Yaw = 0f;
|
|
|
|
var kb = new FakeKb();
|
|
var mouse = new FakeMouse();
|
|
var bindings = KeyBindings.AcdreamCurrentDefaults();
|
|
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
|
dispatcher.Attach();
|
|
|
|
kb.Press(Key.W);
|
|
// Shift pressed alongside W — real keyboard delivers KeyDown(Shift,
|
|
// mods=Shift) and CurrentModifiers reflects Shift held.
|
|
kb.Press(Key.ShiftLeft, ModifierMask.Shift);
|
|
|
|
var input = BuildInputFromDispatcher(dispatcher);
|
|
Assert.True(input.Forward); // duplicate (W, Shift) binding catches this
|
|
Assert.True(input.Run); // (ShiftLeft, Shift) binding
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispatcher_W_release_clears_forward()
|
|
{
|
|
var kb = new FakeKb();
|
|
var mouse = new FakeMouse();
|
|
var bindings = KeyBindings.AcdreamCurrentDefaults();
|
|
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
|
dispatcher.Attach();
|
|
|
|
kb.Press(Key.W);
|
|
Assert.True(BuildInputFromDispatcher(dispatcher).Forward);
|
|
|
|
kb.Release(Key.W);
|
|
Assert.False(BuildInputFromDispatcher(dispatcher).Forward);
|
|
}
|
|
|
|
[Fact]
|
|
public void MovementInput_with_mouse_delta_zero_matches_input_with_mouse_delta_nonzero()
|
|
{
|
|
// K.1b regression-prevention: mouse delta no longer drives character
|
|
// yaw. Two MovementInputs identical except for MouseDeltaX produce
|
|
// identical motion-command bytes (ForwardCommand / ForwardSpeed /
|
|
// SidestepCommand / TurnCommand). Yaw still changes — but only by
|
|
// a hair from MouseDeltaX, which is dropped in K.1b.
|
|
//
|
|
// We construct the controller twice (separate state) so the previous
|
|
// frame's MouseDeltaX doesn't leak into the second run via Yaw.
|
|
var engineA = MakeFlatEngine();
|
|
var ctrlA = new PlayerMovementController(engineA);
|
|
ctrlA.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
|
|
ctrlA.Yaw = 0f;
|
|
|
|
var engineB = MakeFlatEngine();
|
|
var ctrlB = new PlayerMovementController(engineB);
|
|
ctrlB.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
|
|
ctrlB.Yaw = 0f;
|
|
|
|
var inputZero = new MovementInput(Forward: true, MouseDeltaX: 0f);
|
|
var inputJittered = new MovementInput(Forward: true, MouseDeltaX: 47.3f);
|
|
|
|
var rA = ctrlA.Update(0.05f, inputZero);
|
|
var rB = ctrlB.Update(0.05f, inputJittered);
|
|
|
|
Assert.Equal(rA.ForwardCommand, rB.ForwardCommand);
|
|
Assert.Equal(rA.SidestepCommand, rB.SidestepCommand);
|
|
Assert.Equal(rA.TurnCommand, rB.TurnCommand);
|
|
Assert.Equal(rA.ForwardSpeed, rB.ForwardSpeed);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispatcher_no_keys_held_produces_idle_input()
|
|
{
|
|
var kb = new FakeKb();
|
|
var mouse = new FakeMouse();
|
|
var bindings = KeyBindings.AcdreamCurrentDefaults();
|
|
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
|
|
dispatcher.Attach();
|
|
|
|
var input = BuildInputFromDispatcher(dispatcher);
|
|
Assert.False(input.Forward);
|
|
Assert.False(input.Backward);
|
|
Assert.False(input.StrafeLeft);
|
|
Assert.False(input.StrafeRight);
|
|
Assert.False(input.TurnLeft);
|
|
Assert.False(input.TurnRight);
|
|
Assert.False(input.Run);
|
|
Assert.False(input.Jump);
|
|
Assert.Equal(0f, input.MouseDeltaX);
|
|
}
|
|
}
|