acdream/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
Erik e0d5d271f3 fix(retail): rotation rate, useability gate, retail toast strings
Two retail divergences fixed from the 2026-05-16 faithfulness audit
(Commit A of the plan at docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md).

1. Rotation rate ignored HoldKey.Run. Retail's CMotionInterp::
   apply_run_to_command (decomp 0x00527be0 line 305098) multiplies
   turn_speed by run_turn_factor (1.5, PDB-named symbol at 0x007c8914)
   when input is TurnRight/TurnLeft under HoldKey.Run. Effective
   running rotation is 50% faster (~135°/s vs walking ~90°/s).
   Our keyboard A/D and ApplyAutoWalkOverlay used a fixed walking
   rate.

   New: RemoteMoveToDriver.TurnRateFor(running) helper. Keyboard
   path passes input.Run; auto-walk overlay passes
   _autoWalkInitiallyRunning. The walking-rate base
   (BaseTurnRateRadPerSec = π/2) is unchanged; TurnRateRadPerSec
   constant is preserved as the walking-rate alias for callers
   that don't have run/walk state (NPC remotes).

2. IsUseableTarget gated on `useability & USEABLE_REMOTE (0x20)`,
   which was stricter than retail. Per ItemUses::IsUseable
   (acclient_2013_pseudo_c.txt:256455) cross-referenced with 4
   call sites, retail's IsUseable() semantic is `_useability != 0`.
   But visually retail's USEABLE_NO (1) entities don't approach
   either, because ACE never broadcasts MovementType=6 for them.
   Our client installs a speculative auto-walk BEFORE the server
   responds, so we'd visibly approach + face signs before the
   wire packet was rejected.

   Pragmatic fix: block USEABLE_UNDEF (0) AND USEABLE_NO (1) in
   IsUseableTarget — slightly stricter than retail's
   IsUseable but matches retail's user-visible behaviour
   ("R on sign does nothing"). Documented in the doc-comment so
   a future implementer knows the gap.

3. New IsPickupableTarget gate for F-key path — requires
   USEABLE_REMOTE (0x20) bit. Null-useability fallback for
   BF_CORPSE + small-item ItemTypes (preserves M1 ground-item
   pickup flow when ACE seed DB doesn't publish useability).

4. R-key (UseCurrentSelection) upfront gate now ALWAYS uses
   IsUseableTarget. R is conceptually "use" with smart-routing
   to pickup as a downstream optimization. F-key (SendPickUp)
   uses IsPickupableTarget directly.

5. Retail toast strings on block, centralised in new
   src/AcDream.Core/Ui/RetailMessages.cs:
   - "The X cannot be used" (data 0x007e2a70, sprintf 0x00588ea4)
     fires on UseCurrentSelection / SendUse gate block.
   - "The X can't be picked up!" (sprintf 0x00587353) fires on
     SendPickUp non-pickupable block.
   - "You cannot pick up creatures!" (data 0x007e22b4) fires on
     SendPickUp creature block (was previously silent).
   - Plus 4 inactive retail strings ready for future call sites:
     CannotBeUsedWith (two-target Use), CannotBePickedUp (formal
     pickup variant), CannotBeUsedWhileOnHook_HooksOff +
     CannotBeUsedWhileOnHook_NotOwner (housing). All cite their
     retail data addresses + runtime sprintf addresses.

6. ProbeUseabilityFallbackEnabled diagnostic (env var
   ACDREAM_PROBE_USEABILITY_FALLBACK=1) logs every time the
   null-useability fallback fires. Settles whether the
   fallback for creature + BF_DOOR/LIFESTONE/PORTAL/CORPSE
   entries in ACE's seed DB without useability is hot code
   or theoretical defense.

Test coverage:
- +3 RemoteMoveToDriverTests cover TurnRateFor walking/running/back-compat.
- +7 RetailMessagesTests cover each retail string with retail anchor.
- +1 CreateObjectTests TryParse_WeenieFlagsUsable_ReadsUseableNoValue
  pins parser correctness for USEABLE_NO=1.
- 294/294 Core.Net pass; 24/24 new+touched Core tests pass.
- Pre-existing baseline of 8 Physics test failures unchanged
  (BSPStepUp + MotionInterpreter regression noise from prior
  sessions; out of scope here).

Deferred to a separate session per user direction:
- Click area = indicator-rect retail fidelity. Retail's picker
  uses per-part CGfxObj.drawing_sphere + polygon refine
  (0x0054c740); ours uses single Setup.SelectionSphere ray-
  intersect. The rect corners are dead zones today. Three fix
  options analyzed: screen-space rectangle hit-test, sqrt(2)
  sphere inflation, polygon refine Stage B.

Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:17:54 +02:00

330 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Phase L.1c (2026-04-28). Covers <see cref="RemoteMoveToDriver"/> — the
/// per-tick steering port of retail
/// <c>MoveToManager::HandleMoveToPosition</c> for server-controlled remote
/// creatures.
/// </summary>
public class RemoteMoveToDriverTests
{
private const float Epsilon = 1e-3f;
private static float Yaw(Quaternion q)
{
var fwd = Vector3.Transform(new Vector3(0, 1, 0), q);
return MathF.Atan2(-fwd.X, fwd.Y);
}
[Fact]
public void Drive_AlreadyAtTarget_ReportsArrived()
{
var bodyPos = new Vector3(10f, 20f, 0f);
var bodyRot = Quaternion.Identity;
var dest = new Vector3(10f, 20.3f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0.5f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
Assert.Equal(bodyRot, newOrient); // orientation untouched
}
[Fact]
public void Drive_AceMeleePacket_UsesDistanceToObjectAsArrival()
{
// ACE chase packet: MinDistance=0, DistanceToObject=0.6 (melee).
// Body at 0.5m from target should ARRIVE — not keep oscillating
// around the target the way it did pre-fix when only MinDistance
// was the gate. This is the "monster keeps running in different
// directions when it should be attacking" regression fix.
var bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, 0.5f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out _);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
}
[Fact]
public void Drive_FleeArrival_UsesMinDistance()
{
// Flee branch (moveTowards=false): arrival when dist >= MinDistance.
// Retail / ACE both use MinDistance for the flee-arrival threshold.
var bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, 6f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 5.0f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: false,
out _);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
}
[Fact]
public void Drive_ChaseDoesNotArriveAtMinDistanceFloor()
{
// Regression: my earlier max(MinDistance, DistanceToObject) port
// would have arrived here because dist (1.5) <= MinDistance (2.0).
// Retail uses DistanceToObject for chase arrival, so a chase at
// dist=1.5 with DistanceToObject=0.6 should still STEER, not arrive.
var bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, 1.5f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 2.0f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out _);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
}
[Fact]
public void Drive_ChasingButNotInRange_ReportsSteering()
{
var bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity; // facing +Y
var dest = new Vector3(0f, 50f, 0f); // straight ahead
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0f,
dt: 0.016f, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
// Already facing target → snap branch keeps yaw at 0.
Assert.InRange(Yaw(newOrient), -Epsilon, Epsilon);
}
[Fact]
public void Drive_TargetSlightlyOffAxis_SnapsWithinTolerance()
{
// Body facing +Y; target at (1, 10, 0) — that's a small angle
// (about 5.7°), well within the 20° snap tolerance.
var bodyPos = Vector3.Zero;
var bodyRot = Quaternion.Identity;
var dest = new Vector3(1f, 10f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0f,
dt: 0.016f, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
// Snap should land us pointing at (1, 10): yaw = atan2(-1, 10) ≈ -0.0997 rad.
float expectedYaw = MathF.Atan2(-1f, 10f);
Assert.InRange(Yaw(newOrient), expectedYaw - Epsilon, expectedYaw + Epsilon);
// Verify orientation actually transforms +Y onto the (1,10) line.
var worldFwd = Vector3.Transform(new Vector3(0, 1, 0), newOrient);
Assert.InRange(worldFwd.X / worldFwd.Y, 0.1f - 1e-3f, 0.1f + 1e-3f);
}
[Fact]
public void Drive_TargetBeyondTolerance_RotatesByLimitedStep()
{
// Body facing +Y; target at (-10, 0) — that's 90° to the left
// (well beyond the 20° snap tolerance), so we turn by at most
// TurnRateRadPerSec * dt this tick rather than snapping.
var bodyPos = Vector3.Zero;
var bodyRot = Quaternion.Identity; // yaw = 0
var dest = new Vector3(-10f, 0f, 0f); // yaw = +π/2 (left)
const float dt = 0.1f;
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0f,
dt: dt, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
// We should turn LEFT (positive yaw) toward the target.
Assert.InRange(Yaw(newOrient), expectedStep - Epsilon, expectedStep + Epsilon);
}
[Fact]
public void Drive_TargetBehind_TurnsRightOrLeftViaShortestPath()
{
// Body facing +Y; target directly behind at (0, -10, 0).
// |delta| = π, equally close either way; the implementation
// picks one (sign depends on float wobble) — just assert
// we made progress (yaw changed by exactly TurnRate * dt).
var bodyPos = Vector3.Zero;
var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, -10f, 0f);
const float dt = 0.1f;
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0f,
dt: dt, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
Assert.InRange(MathF.Abs(Yaw(newOrient)), expectedStep - Epsilon, expectedStep + Epsilon);
}
[Fact]
public void Drive_PreservesOrientationAtArrival()
{
var bodyPos = new Vector3(5f, 5f, 0f);
var bodyRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.234f);
var dest = new Vector3(5.01f, 5.01f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0.5f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
// Caller would zero velocity; orientation should be untouched
// so the body settles facing whatever direction it was already.
Assert.Equal(bodyRot, newOrient);
}
[Fact]
public void ClampApproachVelocity_NoOverShoot_LandsExactlyAtThreshold()
{
// Body 1 m from destination, running at 4 m/s, dt = 0.1 s.
// Naive advance = 0.4 m → would end at 0.6 m from dest, exactly
// on the threshold. With threshold=0.6 and remaining=0.4, the
// clamp should let the full velocity through (advance == remaining).
var bodyPos = new Vector3(0f, 0f, 0f);
var dest = new Vector3(0f, 1f, 0f);
var vel = new Vector3(0f, 4f, 0f);
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.1f, moveTowards: true);
// Within float-precision: 4 m/s × 0.1 s = 0.4 m, exactly the
// remaining distance. The clamp may apply a 0.99999×-style
// tiny scale due to FP rounding — accept anything ≥ 99.9% of
// the input as "no meaningful overshoot prevention applied."
Assert.InRange(clamped.Y, 4f * 0.999f, 4f);
Assert.Equal(0f, clamped.X);
Assert.Equal(0f, clamped.Z);
}
[Fact]
public void ClampApproachVelocity_WouldOverShoot_ScalesDownToExactLanding()
{
// Body 1 m from destination, running at 4 m/s, dt = 0.2 s.
// Naive advance = 0.8 m → would overshoot 0.6 m threshold by 0.4 m.
// remaining = 0.4 m, advance = 0.8 m → scale = 0.5.
// Velocity should be halved → 2 m/s.
var bodyPos = new Vector3(0f, 0f, 0f);
var dest = new Vector3(0f, 1f, 0f);
var vel = new Vector3(0f, 4f, 0f);
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.2f, moveTowards: true);
Assert.InRange(clamped.Y, 2f - Epsilon, 2f + Epsilon);
Assert.Equal(0f, clamped.X);
}
[Fact]
public void ClampApproachVelocity_AlreadyAtThreshold_ZeroesHorizontal()
{
// Body exactly 0.6 m from dest with threshold 0.6 → remaining ≈ 0.
// Any horizontal velocity would overshoot; clamp must zero it.
var bodyPos = new Vector3(0f, 0f, 0f);
var dest = new Vector3(0f, 0.6f, 0f);
var vel = new Vector3(0f, 4f, 0.5f); // some Z to confirm Z is preserved
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.016f, moveTowards: true);
Assert.Equal(0f, clamped.X);
Assert.Equal(0f, clamped.Y);
Assert.Equal(0.5f, clamped.Z); // gravity / Z handling unaffected
}
[Fact]
public void ClampApproachVelocity_FleeBranch_NoOp()
{
// moveTowards=false (flee): no overshoot risk, return velocity unchanged.
var bodyPos = Vector3.Zero;
var dest = new Vector3(0f, 1f, 0f);
var vel = new Vector3(0f, -4f, 0f);
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
bodyPos, vel, dest, arrivalThreshold: 5f, dt: 0.5f, moveTowards: false);
Assert.Equal(vel, clamped);
}
[Fact]
public void OriginToWorld_AppliesLandblockGridShift()
{
// Cell ID 0xA8B4000E → landblock x=0xA8, y=0xB4. With live center
// at (0xA9, 0xB4), that's one landblock west and zero north,
// so origin (10, 20, 0) inside that landblock should map to
// (10 - 192, 20 + 0, 0) = (-182, 20, 0) in render-world space.
var w = RemoteMoveToDriver.OriginToWorld(
originCellId: 0xA8B4000Eu,
originX: 10f, originY: 20f, originZ: 0f,
liveCenterLandblockX: 0xA9, liveCenterLandblockY: 0xB4);
Assert.Equal(-182f, w.X);
Assert.Equal(20f, w.Y);
Assert.Equal(0f, w.Z);
}
[Fact]
public void TurnRateFor_WalkingReturnsBaseRate()
{
// Retail: omega.z = ±π/2 × turn_speed (1.0) = π/2 rad/s ≈ 90°/s
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
// CMotionInterp::apply_run_to_command 0x00527be0 only
// multiplies under HoldKey.Run — walking is unscaled.
float rate = RemoteMoveToDriver.TurnRateFor(running: false);
Assert.Equal(MathF.PI / 2.0f, rate, precision: 5);
}
[Fact]
public void TurnRateFor_RunningAppliesRunTurnFactor()
{
// Retail: omega.z = ±π/2 × turn_speed × run_turn_factor
// run_turn_factor = 1.5f at 0x007c8914 (PDB-named).
// apply_run_to_command (acclient_2013_pseudo_c.txt:305098)
// multiplies turn_speed by 1.5f when input is TurnRight
// under HoldKey.Run.
float rate = RemoteMoveToDriver.TurnRateFor(running: true);
Assert.Equal(MathF.PI / 2.0f * 1.5f, rate, precision: 5);
}
[Fact]
public void TurnRateRadPerSec_BackCompatStillResolvesToWalkingRate()
{
// Existing call sites that haven't yet migrated to TurnRateFor
// (e.g., RemoteMoveToDriver.Drive's TurnSpeed=1.0 callers) still
// see the walking-rate constant. Same numerical value as
// BaseTurnRateRadPerSec.
Assert.Equal(RemoteMoveToDriver.BaseTurnRateRadPerSec,
RemoteMoveToDriver.TurnRateRadPerSec, precision: 5);
}
}