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>
340 lines
15 KiB
C#
340 lines
15 KiB
C#
using System;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.Core.Physics;
|
||
|
||
/// <summary>
|
||
/// Per-tick steering for server-controlled remote creatures while a
|
||
/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet
|
||
/// is the active locomotion source.
|
||
///
|
||
/// <para>
|
||
/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo"
|
||
/// stabilizer. With the full MoveTo path payload now captured on
|
||
/// <see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>,
|
||
/// the body solver has the destination + heading + thresholds it needs to
|
||
/// run the retail per-tick loop instead of waiting for sparse
|
||
/// UpdatePosition snap corrections.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Retail references:
|
||
/// <list type="bullet">
|
||
/// <item><description>
|
||
/// <c>MoveToManager::HandleMoveToPosition</c> (<c>0x00529d80</c>) — the
|
||
/// per-tick driver. Computes heading-to-target, fires an aux
|
||
/// <c>TurnLeft</c>/<c>TurnRight</c> command when |delta| > 20°, snaps
|
||
/// orientation when within tolerance, and tests arrival via
|
||
/// <c>dist <= min_distance</c> (chase) or
|
||
/// <c>dist >= distance_to_object</c> (flee).
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// <c>MoveToManager::_DoMotion</c> / <c>_StopMotion</c> route turn
|
||
/// commands through <c>CMotionInterp::DoInterpretedMotion</c> — i.e.
|
||
/// MoveToManager itself does NOT touch the body. The body's actual
|
||
/// velocity comes from <c>CMotionInterp::apply_current_movement</c>
|
||
/// reading <c>InterpretedState.ForwardCommand = RunForward</c> and
|
||
/// emitting <c>velocity.Y = RunAnimSpeed × speedMod</c>, transformed by
|
||
/// the body's orientation.
|
||
/// </description></item>
|
||
/// </list>
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Acdream port scope: minimum viable subset. We skip target re-tracking
|
||
/// (server re-emits MoveTo every ~1 s with refreshed Origin), sticky/
|
||
/// StickTo, fail-distance progress detector, and the sphere-cylinder
|
||
/// distance variant — all server-side concerns the local body doesn't need
|
||
/// to model. We DO port heading-to-target, the ±20° aux-turn tolerance
|
||
/// (with ACE's <c>set_heading(true)</c> snap-on-aligned fudge), and
|
||
/// arrival detection via <c>min_distance</c>.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// ACE divergence: ACE swaps the chase/flee arrival predicates
|
||
/// (<c>dist <= DistanceToObject</c> vs retail's <c>dist <= MinDistance</c>).
|
||
/// We follow retail.
|
||
/// </para>
|
||
/// </summary>
|
||
public static class RemoteMoveToDriver
|
||
{
|
||
/// <summary>
|
||
/// Heading tolerance below which we snap orientation directly to the
|
||
/// target heading (ACE's <c>set_heading(target, true)</c>
|
||
/// server-tic-rate fudge). Above tolerance we rotate at
|
||
/// <see cref="TurnRateRadPerSec"/>. Retail value (line 307251 of
|
||
/// <c>acclient_2013_pseudo_c.txt</c>) is 20°.
|
||
/// </summary>
|
||
public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f;
|
||
|
||
/// <summary>
|
||
/// Default angular rate for in-motion heading correction when delta
|
||
/// exceeds <see cref="HeadingSnapToleranceRad"/>. Picked to match
|
||
/// ACE's <c>TurnSpeed</c> default of <c>π/2</c> rad/s for monsters;
|
||
/// when the per-creature value differs, the future port can wire it
|
||
/// in via the <c>TurnSpeed</c> field on InterpretedMotionState.
|
||
/// </summary>
|
||
public const float TurnRateRadPerSec = MathF.PI / 2.0f;
|
||
|
||
/// <summary>
|
||
/// Retail base turn rate for the player Humanoid when turn_speed
|
||
/// scalar = 1.0. Convention default <c>omega.z = ±π/2 rad/s</c>
|
||
/// derived from <c>add_motion</c> at <c>0x005224b0</c> + the
|
||
/// HasOmega-cleared MotionData fallback documented in
|
||
/// <c>AnimationSequencer.cs:734-741</c>. ~90°/s.
|
||
/// </summary>
|
||
public const float BaseTurnRateRadPerSec = MathF.PI / 2.0f;
|
||
|
||
/// <summary>
|
||
/// Retail's <c>run_turn_factor</c> constant at <c>0x007c8914</c>
|
||
/// (PDB-named). <c>CMotionInterp::apply_run_to_command</c>
|
||
/// equivalent (decomp <c>0x00527be0</c>, line 305098 of
|
||
/// <c>acclient_2013_pseudo_c.txt</c>) multiplies <c>turn_speed</c>
|
||
/// by 1.5 when <c>HoldKey.Run</c> is active on a TurnRight/TurnLeft
|
||
/// command. Effect: running rotation is 50 % faster than walking.
|
||
/// </summary>
|
||
public const float RunTurnFactor = 1.5f;
|
||
|
||
/// <summary>
|
||
/// Retail-faithful local-player turn rate.
|
||
/// <list type="bullet">
|
||
/// <item><b>Walking</b>: <c>BaseTurnRateRadPerSec</c> ≈ 90°/s.</item>
|
||
/// <item><b>Running</b>: <c>BaseTurnRateRadPerSec × RunTurnFactor</c>
|
||
/// ≈ 135°/s.</item>
|
||
/// </list>
|
||
/// Replaces the fixed <c>TurnRateRadPerSec</c> for paths that have
|
||
/// access to the player's run/walk state (keyboard A/D, auto-walk
|
||
/// overlay turn-first). NPC/monster remotes that lack the
|
||
/// information continue to use the constant which equals
|
||
/// <c>BaseTurnRateRadPerSec</c>.
|
||
/// </summary>
|
||
public static float TurnRateFor(bool running)
|
||
=> running ? BaseTurnRateRadPerSec * RunTurnFactor
|
||
: BaseTurnRateRadPerSec;
|
||
|
||
/// <summary>
|
||
/// Float-comparison slack for the arrival predicate. With
|
||
/// <c>min_distance == 0</c> in a chase packet, exact equality is
|
||
/// unreachable due to integration wobble; this epsilon prevents the
|
||
/// driver from over-shooting by a sub-meter and snap-flipping back.
|
||
/// </summary>
|
||
public const float ArrivalEpsilon = 0.05f;
|
||
|
||
/// <summary>
|
||
/// Maximum staleness (seconds) of the most recent MoveTo packet
|
||
/// before the driver gives up steering. ACE re-emits MoveTo at ~1 Hz
|
||
/// during active chase; if no fresh packet arrives for this long,
|
||
/// the entity has likely either left our streaming view, switched
|
||
/// to a non-MoveTo motion the server's broadcast didn't reach us
|
||
/// for, or had its move cancelled server-side without our seeing
|
||
/// the cancel UM. In any of those cases, continuing to drive the
|
||
/// body toward a stale destination produces the "monster runs in
|
||
/// place after popping back into view" symptom (2026-04-28).
|
||
/// 1.5 s gives us comfortable margin over the ~1 s emit cadence
|
||
/// while still failing fast on real loss-of-state.
|
||
/// </summary>
|
||
public const double StaleDestinationSeconds = 1.5;
|
||
|
||
public enum DriveResult
|
||
{
|
||
/// <summary>Within arrival window — caller should zero velocity.</summary>
|
||
Arrived,
|
||
/// <summary>Steering active — caller should let
|
||
/// <c>apply_current_movement</c> set body velocity from the cycle.</summary>
|
||
Steering,
|
||
}
|
||
|
||
/// <summary>
|
||
/// Steer body orientation toward <paramref name="destinationWorld"/>
|
||
/// and report whether the body has arrived or should keep running.
|
||
/// Pure function — emits the updated orientation via
|
||
/// <paramref name="newOrientation"/> (the input is not mutated; the
|
||
/// caller assigns the new value back to its body).
|
||
/// </summary>
|
||
/// <param name="minDistance">
|
||
/// <c>min_distance</c> from the wire's MovementParameters block —
|
||
/// retail's <c>HandleMoveToPosition</c> chase-arrival threshold.
|
||
/// </param>
|
||
/// <param name="distanceToObject">
|
||
/// <c>distance_to_object</c> from the wire — ACE's chase-arrival
|
||
/// threshold (default 0.6 m, the melee range). The actual arrival
|
||
/// gate is <c>max(minDistance, distanceToObject)</c>: retail-faithful
|
||
/// when retail sends <c>min_distance</c> > 0, ACE-compatible when
|
||
/// ACE puts the value in <c>distance_to_object</c> with
|
||
/// <c>min_distance == 0</c>. Without this, ACE's <c>min_distance==0</c>
|
||
/// chase packets never arrive — the body keeps re-targeting around
|
||
/// the player at melee range and visibly oscillates between facings,
|
||
/// which is the user-reported "monster keeps running in different
|
||
/// directions when it should be attacking" symptom (2026-04-28).
|
||
/// </param>
|
||
public static DriveResult Drive(
|
||
Vector3 bodyPosition,
|
||
Quaternion bodyOrientation,
|
||
Vector3 destinationWorld,
|
||
float minDistance,
|
||
float distanceToObject,
|
||
float dt,
|
||
bool moveTowards,
|
||
out Quaternion newOrientation)
|
||
{
|
||
// Horizontal distance only — server owns Z, our body Z is
|
||
// hard-snapped to the latest UpdatePosition.
|
||
float dx = destinationWorld.X - bodyPosition.X;
|
||
float dy = destinationWorld.Y - bodyPosition.Y;
|
||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
||
|
||
// Arrival predicate per retail MoveToManager::HandleMoveToPosition
|
||
// (acclient_2013_pseudo_c.txt:307289-307320) and ACE
|
||
// MoveToManager.cs:476:
|
||
//
|
||
// chase (MoveTowards): dist <= distance_to_object
|
||
// flee (MoveAway): dist >= min_distance
|
||
//
|
||
// (My earlier <c>max(MinDistance, DistanceToObject)</c> was a
|
||
// defensive guess; cross-checked with two independent research
|
||
// agents against the named retail decomp + ACE port + holtburger,
|
||
// the chase threshold is unambiguously DistanceToObject —
|
||
// MinDistance is the FLEE arrival threshold. ACE's wire defaults
|
||
// give MinDistance=0, DistanceToObject=0.6 — the body should stop
|
||
// at melee range, not run to zero.)
|
||
float arrivalThreshold = moveTowards ? distanceToObject : minDistance;
|
||
if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon)
|
||
{
|
||
newOrientation = bodyOrientation;
|
||
return DriveResult.Arrived;
|
||
}
|
||
if (!moveTowards && dist >= arrivalThreshold - ArrivalEpsilon)
|
||
{
|
||
newOrientation = bodyOrientation;
|
||
return DriveResult.Arrived;
|
||
}
|
||
|
||
// Degenerate — already on target horizontally; preserve heading.
|
||
if (dist < 1e-4f)
|
||
{
|
||
newOrientation = bodyOrientation;
|
||
return DriveResult.Steering;
|
||
}
|
||
|
||
// Body's local-forward is +Y (see MotionInterpreter.get_state_velocity
|
||
// at line 605-616: velocity.Y = (Walk/Run)AnimSpeed × ForwardSpeed).
|
||
// World forward = Transform((0,1,0), orientation). Yaw extracted
|
||
// via atan2(-worldFwd.X, worldFwd.Y) so yaw = 0 ↔ orientation = Identity.
|
||
var localForward = new Vector3(0f, 1f, 0f);
|
||
var worldForward = Vector3.Transform(localForward, bodyOrientation);
|
||
float currentYaw = MathF.Atan2(-worldForward.X, worldForward.Y);
|
||
|
||
// Desired heading: face the target. (dx, dy) is the world-space
|
||
// offset to the target. With local-forward=+Y we want yaw such
|
||
// that Transform((0,1,0), R_Z(yaw)) = (dx, dy)/dist; that solves
|
||
// to yaw = atan2(-dx, dy).
|
||
float desiredYaw = MathF.Atan2(-dx, dy);
|
||
float delta = WrapPi(desiredYaw - currentYaw);
|
||
|
||
if (MathF.Abs(delta) <= HeadingSnapToleranceRad)
|
||
{
|
||
// ACE's set_heading(target, true) — sync to server-tic-rate.
|
||
// We have the same sparse-UP problem ACE does, so the same
|
||
// fudge applies.
|
||
newOrientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, desiredYaw);
|
||
}
|
||
else
|
||
{
|
||
// Retail BeginTurnToHeading / HandleMoveToPosition aux turn:
|
||
// rotate at TurnRate clamped to dt, in the shorter direction.
|
||
float maxStep = TurnRateRadPerSec * dt;
|
||
float step = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
|
||
// Apply incremental yaw around world +Z (preserving any
|
||
// server-supplied pitch/roll from the latest UpdatePosition).
|
||
var deltaQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, step);
|
||
newOrientation = Quaternion.Normalize(deltaQuat * bodyOrientation);
|
||
}
|
||
|
||
return DriveResult.Steering;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Convert a landblock-local Origin from a MoveTo packet
|
||
/// (<see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>)
|
||
/// into acdream's render world space using the same arithmetic as
|
||
/// <c>OnLivePositionUpdated</c>: shift by the landblock-grid offset
|
||
/// from the live-mode center.
|
||
/// </summary>
|
||
public static Vector3 OriginToWorld(
|
||
uint originCellId,
|
||
float originX,
|
||
float originY,
|
||
float originZ,
|
||
int liveCenterLandblockX,
|
||
int liveCenterLandblockY)
|
||
{
|
||
int lbX = (int)((originCellId >> 24) & 0xFFu);
|
||
int lbY = (int)((originCellId >> 16) & 0xFFu);
|
||
return new Vector3(
|
||
originX + (lbX - liveCenterLandblockX) * 192f,
|
||
originY + (lbY - liveCenterLandblockY) * 192f,
|
||
originZ);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Cap horizontal velocity so the body lands exactly at
|
||
/// <paramref name="arrivalThreshold"/> rather than overshooting past
|
||
/// it during the final tick of approach. Without this clamp, a body
|
||
/// running at <c>RunAnimSpeed × speedMod ≈ 4 m/s</c> can overshoot
|
||
/// the 0.6 m arrival window by up to one tick's advance (~6 cm at
|
||
/// 60 fps) — visible as the creature "running slightly through" the
|
||
/// player it's about to attack (user-reported 2026-04-28).
|
||
///
|
||
/// <para>
|
||
/// The clamp is a strict scale-down of the horizontal component
|
||
/// (X/Y); the vertical component (Z) is left to gravity / terrain
|
||
/// handling. <paramref name="moveTowards"/> false (flee branch) is a
|
||
/// no-op since fleeing has no overshoot risk — the body wants to
|
||
/// move AWAY from the destination.
|
||
/// </para>
|
||
/// </summary>
|
||
public static Vector3 ClampApproachVelocity(
|
||
Vector3 bodyPosition,
|
||
Vector3 currentVelocity,
|
||
Vector3 destinationWorld,
|
||
float arrivalThreshold,
|
||
float dt,
|
||
bool moveTowards)
|
||
{
|
||
if (!moveTowards || dt <= 0f) return currentVelocity;
|
||
|
||
float dx = destinationWorld.X - bodyPosition.X;
|
||
float dy = destinationWorld.Y - bodyPosition.Y;
|
||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
||
float remaining = MathF.Max(0f, dist - arrivalThreshold);
|
||
|
||
float vxy = MathF.Sqrt(currentVelocity.X * currentVelocity.X
|
||
+ currentVelocity.Y * currentVelocity.Y);
|
||
if (vxy < 1e-3f) return currentVelocity;
|
||
|
||
float advance = vxy * dt;
|
||
if (advance <= remaining) return currentVelocity;
|
||
|
||
// Already inside or right at the threshold: zero horizontal
|
||
// velocity, keep Z. (The arrival predicate in Drive() should
|
||
// have fired this tick, but this is the belt-and-braces guard.)
|
||
if (remaining < 1e-3f)
|
||
return new Vector3(0f, 0f, currentVelocity.Z);
|
||
|
||
float scale = remaining / advance;
|
||
return new Vector3(
|
||
currentVelocity.X * scale,
|
||
currentVelocity.Y * scale,
|
||
currentVelocity.Z);
|
||
}
|
||
|
||
/// <summary>Wrap an angle in radians to [-π, π].</summary>
|
||
private static float WrapPi(float r)
|
||
{
|
||
const float TwoPi = MathF.PI * 2f;
|
||
r %= TwoPi;
|
||
if (r > MathF.PI) r -= TwoPi;
|
||
if (r < -MathF.PI) r += TwoPi;
|
||
return r;
|
||
}
|
||
}
|