fix(physics): close #77 — auto-walk honors ACE CanCharge bit; zero velocity in turn-in-place

Two related close-range bugs reported in #77 share a root in
PlayerMovementController.DriveServerAutoWalk + BeginServerAutoWalk:

1. **Walk-vs-run misclassification.** BeginServerAutoWalk decided
   `_autoWalkInitiallyRunning = (initialDist - distanceToObject) >= 1.0f`,
   forcing run at any chase past ~1.6 m. ACE's wire-level walk-vs-run
   answer is the MovementParameters CanCharge bit (0x10), which
   Creature.SetWalkRunThreshold sets when server-side player→target
   distance >= WalkRunThreshold/2 (= 7.5 m default). Retail's
   MovementParameters::get_command (decomp 0x0052aa00) gates the run
   path on CanCharge first; the inner walk_run_threshold check
   practically always walks given ACE's 15 m default. The hardcoded
   1.0 m threshold pushed run into the 3-5 m walk-range the user
   reported should walk.

2. **Velocity leak in turn-in-place phase.** When the auto-walked body
   crossed the destination, desiredYaw flipped ~180°, walkAligned
   dropped to false, and the `if (!moveForward) return true;` branch
   returned without zeroing body velocity. The body kept the prior
   frame's running velocity (RunAnimSpeed × runRate ≈ 11 m/s) and
   slid 4-5 m past the target before the turn-around rotation
   completed — the "runs and slides away, runs back, picks up"
   symptom in #77 bug B.

Changes:

- `CreateObject.ServerMotionState.CanCharge`: new bool prop reading
  bit 0x10 of MoveToParameters. Cross-ref ACE
  MovementParams.CanCharge = 0x10.
- `PlayerMovementController.BeginServerAutoWalk`: replaces the unused
  `walkRunThreshold` parameter with `bool canCharge`; sets
  `_autoWalkInitiallyRunning = canCharge`.
- `PlayerMovementController.DriveServerAutoWalk` turn-in-place branch:
  calls `_motion.DoMotion(Ready, 1.0)` and zeros body horizontal
  velocity (preserving Z for gravity). No-op for case (a) initial-turn
  with stationary body; fixes (b) overshoot recovery and (c) settling
  cases.
- `GameWindow.OnLiveMotionUpdated`: passes
  `update.MotionState.CanCharge` through; [autowalk-begin] trace
  shows `canCharge=` instead of `walkRunThresh=`.
- `GameWindow.InstallSpeculativeTurnToTarget`: predicts ACE's
  CanCharge from local distance using ACE's exact 7.5 m rule, so the
  speculative install agrees with the wire-triggered overwrite that
  arrives moments later.

Visual-verified at Holtburg 2026-05-18: walk-range NPC click walks +
fires Use, walk-range F-key pickup walks + no overshoot, far-range
(8-10 m) pickup still runs. Test baseline unchanged (8 Core pre-existing
failures, 0 net-new failures across Core/Net/UI/App suites).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-18 09:33:33 +02:00
parent 677266d477
commit 3be700020b
3 changed files with 93 additions and 47 deletions

View file

@ -430,7 +430,7 @@ public sealed class PlayerMovementController
float minDistance, float minDistance,
float distanceToObject, float distanceToObject,
bool moveTowards, bool moveTowards,
float walkRunThreshold) bool canCharge)
{ {
_autoWalkActive = true; _autoWalkActive = true;
_autoWalkDestination = destinationWorld; _autoWalkDestination = destinationWorld;
@ -438,35 +438,28 @@ public sealed class PlayerMovementController
_autoWalkDistanceToObject = distanceToObject; _autoWalkDistanceToObject = distanceToObject;
_autoWalkMoveTowards = moveTowards; _autoWalkMoveTowards = moveTowards;
// 2026-05-16 (retail-faithful) — one-shot walk/run decision // Issue #77 fix (2026-05-18) — retail-faithful walk-vs-run.
// using retail's get_command formula (decomp 0x0052aa00):
// running = (initialDist - distance_to_object) >= walk_run_threshhold
// Subtract the use-radius from the raw distance so the
// discriminator is "distance LEFT to walk" — same shape as
// retail's `arg2 - distance_to_object` term. Held for the
// rest of the auto-walk so the body runs all the way to the
// target (or walks all the way), matching observed retail
// behaviour.
// //
// The wire-supplied walkRunThreshold defaults to 15m at // Retail's MovementParameters::get_command (decomp 0x0052aa00)
// retail's MovementParameters constructor (0x005243b5) AND at // gates run on the CanCharge flag (bit 0x10 of
// ACE's wire-layer MoveToParameters.cs:51 — but the user- // MovementParameters). Cleared → fall through to the inner
// observed retail behaviour is "only walk when very close," // walk_run_threshold check, which ACE's 15 m wire default +
// run from any non-trivial distance. ACE's own physics layer // 0.6 m use-radius makes practically always walk for any
// confirms: MovementParameters.cs:49-50 has the 15.0f default // chase under 15.6 m. Set → unconditional HoldKey_Run.
// commented out with `Default_WalkRunThreshold = 1.0f` //
// active, and Creature.cs:312 comments "default 15 distance // ACE's Creature.SetWalkRunThreshold sets CanCharge when
// seems too far" before halving it to 7.5f for CanCharge. // (server-side player→target distance) >= WalkRunThreshold /
// We override the wire value with a retail-faithful 1.0m // 2 (= 7.5 m for the 15 m default), and clears it otherwise.
// constant; the wire value (typically 15m from ACE) is // The CanCharge bit IS the wire-side walk-vs-run answer; we
// ignored for the run/walk decision. _walkRunThreshold is in // just relay it.
// METERS of remaining-distance-to-use-radius. //
const float RetailWalkRunThresholdMeters = 1.0f; // Previously we hardcoded a 1.0 m threshold against
float dx = destinationWorld.X - _body.Position.X; // initialDist - distanceToObject, which forced run at any
float dy = destinationWorld.Y - _body.Position.Y; // chase past ~1.6 m — including the 3-5 m "walk range" the
float initialDist = MathF.Sqrt(dx * dx + dy * dy); // user expected to walk in (issue #77 reproduction). Honoring
float remainingAtStart = initialDist - distanceToObject; // CanCharge restores the retail bucket: walk under ~7.5 m,
_autoWalkInitiallyRunning = remainingAtStart >= RetailWalkRunThresholdMeters; // run beyond.
_autoWalkInitiallyRunning = canCharge;
} }
/// <summary> /// <summary>
@ -698,12 +691,36 @@ public sealed class PlayerMovementController
if (!moveForward) if (!moveForward)
{ {
// Turn-in-place phase. The motion state needs to be Ready // Turn-in-place phase. Two sub-cases land here:
// (or at least not RunForward) so we're not pretending to // (a) initial turn — body must rotate to face the target
// run while standing. _motion stays at whatever the user- // before we drive forward (walkAligned == false at chain
// input pipeline last set it to (typically Ready/null since // start, body is stationary).
// the user isn't pressing W). Don't touch body velocity // (b) overshoot recovery — body crossed the destination, so
// either — physics will handle gravity-only behaviour. // desiredYaw flipped ~180° and walkAligned dropped to
// false; body needs to turn around before walking back.
// (c) settling — body is within use-radius but not aligned
// enough to fire arrival (withinArrival == true,
// !aligned); body holds position while finishing rotation
// so the arrival predicate fires on the next tick.
//
// Issue #77 fix: explicitly zero horizontal velocity. Without
// this, in case (b) the body keeps the prior frame's running
// velocity (RunAnimSpeed × runRate ≈ 11 m/s) and slides past
// the destination by several meters before the turn-around
// rotation completes — the "runs and slides away, runs back,
// picks up" symptom reported in issue #77 / bug B. Cases (a)
// and (c) zero a velocity that's already zero, so the change
// is a no-op there.
//
// The motion-interpreter state also has to step out of
// WalkForward so get_state_velocity (used downstream) reports
// standing-velocity, not the prior frame's run-speed.
_motion.DoMotion(MotionCommand.Ready, 1.0f);
if (_body.OnWalkable)
{
float savedWorldVz = _body.Velocity.Z;
_body.set_local_velocity(new Vector3(0f, 0f, savedWorldVz));
}
return true; return true;
} }

View file

@ -3396,16 +3396,17 @@ public sealed class GameWindow : IDisposable
pathData.OriginZ, pathData.OriginZ,
_liveCenterX, _liveCenterX,
_liveCenterY); _liveCenterY);
bool canCharge = update.MotionState.CanCharge;
_playerController.BeginServerAutoWalk( _playerController.BeginServerAutoWalk(
destWorld, destWorld,
pathData.MinDistance, pathData.MinDistance,
pathData.DistanceToObject, pathData.DistanceToObject,
update.MotionState.MoveTowards, update.MotionState.MoveTowards,
pathData.WalkRunThreshold); canCharge);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{ {
Console.WriteLine(System.FormattableString.Invariant( Console.WriteLine(System.FormattableString.Invariant(
$"[autowalk-begin] dest=({destWorld.X:F2},{destWorld.Y:F2},{destWorld.Z:F2}) minDist={pathData.MinDistance:F2} objDist={pathData.DistanceToObject:F2} walkRunThresh={pathData.WalkRunThreshold:F2} towards={update.MotionState.MoveTowards}")); $"[autowalk-begin] dest=({destWorld.X:F2},{destWorld.Y:F2},{destWorld.Z:F2}) minDist={pathData.MinDistance:F2} objDist={pathData.DistanceToObject:F2} canCharge={canCharge} towards={update.MotionState.MoveTowards}"));
} }
} }
// Note: do NOT cancel auto-walk on a non-MoveTo motion // Note: do NOT cancel auto-walk on a non-MoveTo motion
@ -9412,21 +9413,27 @@ public sealed class GameWindow : IDisposable
if ((odf & LargeFlatMask) != 0) useRadius = 2.0f; if ((odf & LargeFlatMask) != 0) useRadius = 2.0f;
} }
// Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit
// from local distance so the speculative auto-walk uses the
// same walk/run as the wire-triggered overwrite that arrives
// moments later. ACE's Creature.SetWalkRunThreshold sets
// CanCharge when player→target distance >= WalkRunThreshold /
// 2 = 7.5 m (the 15 m wire default halved). Match exactly so
// the speculative install doesn't flip walk↔run when ACE's
// MoveToObject broadcast overwrites it.
const float AceCanChargeDistance = 7.5f;
var bodyPos = _playerController.Position;
float ddx = entity.Position.X - bodyPos.X;
float ddy = entity.Position.Y - bodyPos.Y;
float distToTarget = MathF.Sqrt(ddx * ddx + ddy * ddy);
bool speculativeCanCharge = distToTarget >= AceCanChargeDistance;
_playerController.BeginServerAutoWalk( _playerController.BeginServerAutoWalk(
destinationWorld: entity.Position, destinationWorld: entity.Position,
minDistance: 0f, minDistance: 0f,
distanceToObject: useRadius, distanceToObject: useRadius,
moveTowards: true, moveTowards: true,
// 15 m matches ACE's MoveToParameters.SetDefaults canCharge: speculativeCanCharge);
// WalkRunThreshold for non-combat Use/PickUp paths.
// Using 9999 here forced walk-mode for the brief window
// between this speculative install and ACE's MovementType=6
// overwrite — far targets briefly walked before switching
// to run, which the user observed as "we only walk, not
// running from the correct threshold". 15.0 lines up with
// what ACE will send anyway, so the initial decision and
// the overwrite agree.
walkRunThreshold: 15.0f);
} }
private uint? SelectClosestCombatTarget(bool showToast) private uint? SelectClosestCombatTarget(bool showToast)

View file

@ -203,6 +203,28 @@ public static class CreateObject
/// </summary> /// </summary>
public bool MoveTowards => MoveToParameters.HasValue public bool MoveTowards => MoveToParameters.HasValue
&& (MoveToParameters.Value & 0x200u) != 0; && (MoveToParameters.Value & 0x200u) != 0;
/// <summary>
/// MovementParameters bit 4 (mask 0x10) — set when the mover should
/// charge (run) rather than walk. ACE's
/// <c>Creature.SetWalkRunThreshold</c> sets this when the player-to-
/// target distance is at least <c>WalkRunThreshold / 2</c> (7.5 m
/// for the 15 m default), and clears it for shorter chases — so this
/// bit IS the wire-side walk-vs-run decision.
/// <para>
/// Retail's <c>MovementParameters::get_command</c>
/// (<c>0x0052aa00</c>) gates the run path on this bit: cleared →
/// fall through to the inner walk_run_threshold check (which ACE's
/// 15 m default + 0.6 m use-radius makes practically always walk for
/// any &lt; 15.6 m chase); set → unconditional <c>HoldKey_Run</c>.
/// </para>
/// <para>
/// Cross-ref: ACE <c>MovementParams.CanCharge = 0x10</c>
/// (<c>ACE.Entity/Enum/MovementParams.cs:12</c>).
/// </para>
/// </summary>
public bool CanCharge => MoveToParameters.HasValue
&& (MoveToParameters.Value & 0x10u) != 0;
} }
/// <summary> /// <summary>