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 distanceToObject,
bool moveTowards,
float walkRunThreshold)
bool canCharge)
{
_autoWalkActive = true;
_autoWalkDestination = destinationWorld;
@ -438,35 +438,28 @@ public sealed class PlayerMovementController
_autoWalkDistanceToObject = distanceToObject;
_autoWalkMoveTowards = moveTowards;
// 2026-05-16 (retail-faithful) — one-shot walk/run decision
// 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.
// Issue #77 fix (2026-05-18) — retail-faithful walk-vs-run.
//
// The wire-supplied walkRunThreshold defaults to 15m at
// retail's MovementParameters constructor (0x005243b5) AND at
// ACE's wire-layer MoveToParameters.cs:51 — but the user-
// observed retail behaviour is "only walk when very close,"
// run from any non-trivial distance. ACE's own physics layer
// confirms: MovementParameters.cs:49-50 has the 15.0f default
// commented out with `Default_WalkRunThreshold = 1.0f`
// active, and Creature.cs:312 comments "default 15 distance
// seems too far" before halving it to 7.5f for CanCharge.
// We override the wire value with a retail-faithful 1.0m
// constant; the wire value (typically 15m from ACE) is
// ignored for the run/walk decision. _walkRunThreshold is in
// METERS of remaining-distance-to-use-radius.
const float RetailWalkRunThresholdMeters = 1.0f;
float dx = destinationWorld.X - _body.Position.X;
float dy = destinationWorld.Y - _body.Position.Y;
float initialDist = MathF.Sqrt(dx * dx + dy * dy);
float remainingAtStart = initialDist - distanceToObject;
_autoWalkInitiallyRunning = remainingAtStart >= RetailWalkRunThresholdMeters;
// Retail's MovementParameters::get_command (decomp 0x0052aa00)
// gates run on the CanCharge flag (bit 0x10 of
// MovementParameters). Cleared → fall through to the inner
// walk_run_threshold check, which ACE's 15 m wire default +
// 0.6 m use-radius makes practically always walk for any
// chase under 15.6 m. Set → unconditional HoldKey_Run.
//
// ACE's Creature.SetWalkRunThreshold sets CanCharge when
// (server-side player→target distance) >= WalkRunThreshold /
// 2 (= 7.5 m for the 15 m default), and clears it otherwise.
// The CanCharge bit IS the wire-side walk-vs-run answer; we
// just relay it.
//
// Previously we hardcoded a 1.0 m threshold against
// initialDist - distanceToObject, which forced run at any
// chase past ~1.6 m — including the 3-5 m "walk range" the
// user expected to walk in (issue #77 reproduction). Honoring
// CanCharge restores the retail bucket: walk under ~7.5 m,
// run beyond.
_autoWalkInitiallyRunning = canCharge;
}
/// <summary>
@ -698,12 +691,36 @@ public sealed class PlayerMovementController
if (!moveForward)
{
// Turn-in-place phase. The motion state needs to be Ready
// (or at least not RunForward) so we're not pretending to
// run while standing. _motion stays at whatever the user-
// input pipeline last set it to (typically Ready/null since
// the user isn't pressing W). Don't touch body velocity
// either — physics will handle gravity-only behaviour.
// Turn-in-place phase. Two sub-cases land here:
// (a) initial turn — body must rotate to face the target
// before we drive forward (walkAligned == false at chain
// start, body is stationary).
// (b) overshoot recovery — body crossed the destination, so
// 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;
}