feat(R4-V5): LOCAL PLAYER cutover - B.6 auto-walk DELETED; P1 autonomous gate ported; TS-36 bound (closes M1-local, M9, M10, M17; retires TS-36, AD-26; re-anchors AD-27, AP-23)

The local player now runs server MoveTos through the same verbatim
MoveToManager remotes got in V4. One commit, GameWindow + controller,
per the no-fan-out rule for coupled slices.

P1 gate (V0-pins.md P1, ported verbatim): CPhysics::SetObjectMovement
(0x00509690 @0050972e) drops any movement event whose wire autonomous
byte is set when the addressed object IsThePlayer - ACE's self-addressed
MoveToState reflection (MovementData.cs:162 IsAutonomous=1) never
reaches unpack_movement, which is what makes the retail unconditional
unpack-head interrupt safe. Gate placed AFTER the sequence gates
(retail order). The stale "ACE follows every mt=0x06 with an mt=0x00"
comment block dies with the code it excused (its causal story was
pre-#75; refuted in V0-pins P1).

Run-rate re-anchor (P1 contingency NOT needed - no AD row): the echo
tap ApplyServerRunRate is deleted outright. Both retail feeds already
exist: PlayerDescription skills via SetCharacterSkills (K-fix7) into
InqRunRate (preferred by apply_run_to_command/get_state_velocity), and
the mt-6/7 my_run_rate wire write (M13) now performed for the player by
the shared RouteServerMoveTo. The tap's InterpretedState.ForwardSpeed
overwrite was a pre-R3 mechanism that fought the ported machinery.

B.6 auto-walk deleted wholesale (~330 lines): fields, Begin/End/
DriveServerAutoWalk, IsServerAutoWalking, AutoWalkArrived, the
autoWalkConsumedMotion gates, the #69 turn-dir edge synthesizer, and
the relocated AutoWalkArrivalEpsilon/AutoWalkTurnRateFor constants
(AD-26 retired - the invented 5/30-degree bands are gone; arrival is
retail's distance predicate; turn-first is the TurnToHeading node).

TS-36 retired: Motion.InterruptCurrentMovement binds to
MoveTo.CancelMoveTo(ActionCancelled). Movement-key edges (ctor-default
params carry the 0x8000 CancelMoveTo bit), Shift (set_hold_run
interrupt:true), jump(), StopCompletely, and teleport all cancel a
running moveto through the retail chain - verified by controller-level
tests, not assumed.

MoveToComplete seam WIDENED to natural completion (Core): retail's
BeginNextNode empty-queue completion is inline CleanUp+StopCompletely
(raw @00529d47) and notifies nothing - the client-addition seam had to
fire there (both sticky and non-sticky exits) or AD-27's deferred
close-range Use/PickUp re-send never fires. Never fires on CancelMoveTo.
AD-27 re-anchored from the deleted AutoWalkArrived event.

InstallSpeculativeTurnToTarget rewired through the player's manager
(retail 9a/9b client-initiated shape): close-range -> TurnToObject,
far -> MoveToObject; AP-23 radius buckets + the #77 CanCharge
prediction survive as the params source (row re-anchored).

Per-tick: MoveTo.UseTime() at the old DriveServerAutoWalk slot
(provisional until R6, per the plan's placement decision); the P4
player-side TargetTracker twin (fields + pre-Update feed) mirrors the
remote adapter (AP-79 row widened). HitGround dual-call added on the
player landing edge AND the remote landing site - the latter closes a
V4 wiring-contract gap the adversarial review caught (retail 2d order:
minterp then moveto; without it a landing NPC's moveto never re-arms).

Also from the adversarial review: the mt-8 unresolvable-target degrade
now performs retail's params.desired_heading = wire_heading
substitution (decomp 2f case 8; invisible against ACE per P6, required
for the verbatim degrade); the V4 remote MoveToManager binding gets a
real curTime clock (the ctor stub advanced 1/30s per READ, skewing the
progress/fail-distance windows - note: the pending V4 NPC smoke ran on
the skewed clock); TS-33 row extended with the orientation-diff gap
(ApproxPositionEqual vs retail Frame::is_equal full-frame compare - a
stationary heading snap does not reach the wire; masked against ACE,
R7 outbound scope owns the fix).

MoveToMath gains HeadingFromYaw/YawFromHeading (the P5 scalar bridge
for yaw-authoritative bodies - the player's heading snap must write
Yaw, not the quaternion the controller re-derives every frame).

Tests: 3,956 green (+8). New: MoveToManagerCompletionSeamTests (arrival
fires once with None, no refire, cancel never fires, sticky handoff
order) + PlayerMoveToCutoverTests (EnterPlayerModeNow-shape rig: walks
to arrival with zero user input and zero MotionStateChanged frames -
the #75 invariant by construction; TurnToHeading rotates Yaw and snaps
exact; W-edge and jump cancel without firing complete). W6 edge suite
retargeted from the deleted echo tap to a direct apply pass (the
regression lives in ApplyInterpretedMovement, not the wire trigger).

Spec: docs/research/2026-07-03-r4-moveto/r4-port-plan.md section 3 V5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 13:24:22 +02:00
parent e4553a0262
commit b3decdfac6
9 changed files with 947 additions and 688 deletions

View file

@ -198,13 +198,6 @@ public sealed class PlayerMovementController
private bool _prevTurnLeftHeld;
private bool _prevTurnRightHeld;
private bool _prevRunHeld;
private int _prevAutoWalkTurnDir;
// R4-V4: relocated from the DELETED RemoteMoveToDriver — B.6 auto-walk's
// last two dependencies on it. Both die with auto-walk in R4-V5.
private const float AutoWalkArrivalEpsilon = 0.05f;
private static float AutoWalkTurnRateFor(bool running)
=> running ? (MathF.PI / 2.0f) * 1.5f : (MathF.PI / 2.0f); // BaseTurnRate x RunTurnFactor
// Heartbeat timer.
// Cadence is 1.0 sec to match holtburger's
@ -267,83 +260,31 @@ public sealed class PlayerMovementController
private Vector3 _prevPhysicsPos;
private Vector3 _currPhysicsPos;
// ── B.6 slice 2 (2026-05-14): local-player server-initiated auto-walk ──
// When ACE sends a MoveToObject motion for the local player (out-of-range
// Use / PickUp triggers ACE's server-side CreateMoveToChain), the wire
// payload includes a destination, arrival predicates, and a run rate.
// Retail's MovementManager::PerformMovement (0x00524440 case 6) runs a
// LOCAL auto-walk in response: heading correction toward the target,
// run-forward velocity at the wire's runRate, arrival detection via
// MoveToManager::HandleMoveToPosition. Here we keep the active auto-walk
// state and inject it into Update() as a synthesized Forward+Run input
// so the existing motion-interpreter / body-velocity pipeline runs
// unchanged. Spec: docs/superpowers/specs/2026-05-14-phase-b6-design.md.
private bool _autoWalkActive;
private Vector3 _autoWalkDestination;
private float _autoWalkMinDistance;
private float _autoWalkDistanceToObject;
private bool _autoWalkMoveTowards;
// 2026-05-16 (retail-faithful) — walk-vs-run is a ONE-SHOT
// decision at chain start. Per user observation 2026-05-16: if
// initial distance is at or above the walk-run threshold, the
// body runs all the way to the target; otherwise it walks all
// the way. No per-frame switching as the player closes distance.
//
// Formula matches retail's MovementParameters::get_command
// (decomp 0x0052aa00, line 308000+):
// running = (initialDist - distance_to_object) >= walk_run_threshhold
// The "distance left to walk" (current minus use-radius) is
// compared against the wire-supplied threshold (15m default,
// retail constant at 0x005243b5). The retail function reads
// `arg2` as the current distance but in practice is called at
// chain setup with the initial distance, and the resulting
// decision is cached for the rest of the chain — matching the
// user-observed "run all the way / walk all the way" behaviour.
private bool _autoWalkInitiallyRunning;
// ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ──
// The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase,
// 30° walk-while-turning band, one-shot walk/run decision, invented
// arrival epsilon — registers AD-26/AD-8-local) is DELETED; server
// MoveTos for the local player now run through the SAME verbatim
// MoveToManager remotes use (R4-V4), bound below by GameWindow's
// EnterPlayerModeNow beside the R3-W6 DefaultSink bind.
/// <summary>
/// True while a server-initiated auto-walk (MoveToObject inbound) is
/// active on the local player. Update drives the body's velocity
/// and motion state machine DIRECTLY from the wire-supplied path
/// data, NOT via synthesized player-input. The
/// motion-state-change detection downstream sees no user input
/// during auto-walk, so no MoveToState wire packet is built — ACE's
/// server-side MoveToChain can run uninterrupted until its callback
/// fires.
/// R4-V5: the local player's verbatim retail <c>MoveToManager</c>
/// (decomp 0x00529010-0x0052a987), constructed + seam-bound by
/// <c>GameWindow.EnterPlayerModeNow</c> against this controller's
/// <see cref="Motion"/>/body/Yaw (the same wiring shape
/// <c>EnsureRemoteMotionBindings</c> uses for remotes). GameWindow
/// routes inbound mt 6-9 movement events to
/// <see cref="AcDream.Core.Physics.Motion.MoveToManager.PerformMovement"/>;
/// <see cref="Update"/> ticks <c>UseTime()</c> at the slot the deleted
/// <c>DriveServerAutoWalk</c> occupied and relays <c>HitGround()</c>
/// (retail order: minterp first, then moveto — MovementManager::HitGround
/// 0x00524300). User input cancels a moveto through the retail chain:
/// key edge → DoMotion (ctor-default params, CancelMoveTo bit set) →
/// <see cref="MotionInterpreter.InterruptCurrentMovement"/> →
/// <c>CancelMoveTo(ActionCancelled)</c> (register TS-36 retired).
/// </summary>
public bool IsServerAutoWalking => _autoWalkActive;
// 2026-05-16 (issue #75) — tracks whether the auto-walk overlay is
// actually advancing the body this frame. False during the
// turn-first phase (rotating in place toward target) and after
// arrival. Drives the animation cycle override: walking animation
// only plays when the body is actually moving forward.
// R3-W6: _autoWalkMovingForwardThisFrame deleted with the LocalAnimationCommand override (forward legs come from DriveServerAutoWalk's own DoMotion).
// 2026-05-16 (issue #69 fix) — turn direction this frame.
// +1 = rotating counter-clockwise (Yaw increasing) → TurnLeft cycle
// -1 = rotating clockwise (Yaw decreasing) → TurnRight cycle
// 0 = aligned or not turning
// Drives the animation cycle override during turn-first phase so
// the body plays the actual turn animation instead of statue-pivoting.
private int _autoWalkTurnDirectionThisFrame;
/// <summary>
/// Fires once when an auto-walk reaches its destination naturally
/// (i.e. <see cref="EndServerAutoWalk"/> called with
/// <c>reason="arrived"</c>). Does NOT fire on user-input cancel or
/// on a re-target (BeginServerAutoWalk overwriting state).
///
/// <para>
/// Host (<see cref="Rendering.GameWindow"/>) subscribes to re-send
/// the Use/PickUp action that triggered the auto-walk — without
/// this, ACE's server-side MoveToChain may have already timed out
/// by the time our local body arrives, so the action wouldn't
/// fire. Re-sending the action close-range hits ACE's WithinUseRadius
/// fast-path and completes immediately.
/// </para>
/// </summary>
public event Action? AutoWalkArrived;
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo { get; set; }
public PlayerMovementController(PhysicsEngine physics)
{
@ -355,8 +296,10 @@ public sealed class PlayerMovementController
};
// Default skills — tuned toward mid-retail feel. Real characters'
// skills come from PlayerDescription (0xF7B0/0x0013) which we don't
// parse yet; override via env vars:
// skills come from PlayerDescription (0xF7B0/0x0013) — GameWindow
// pushes them via SetCharacterSkills once the controller exists
// (K-fix7; PD arrives at login before auto-entry). These env-var
// defaults only cover tests / pre-PD frames:
// ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL
// K-fix6 (2026-04-26): bumped default jump skill from 200 → 300.
// Retail formula: height = (skill/(skill+1300))*22.2 + 0.05 (extent=1):
@ -390,6 +333,17 @@ public sealed class PlayerMovementController
/// </summary>
internal MotionInterpreter Motion => _motion;
/// <summary>R4-V5: CONTACT transient-state bit (retail
/// <c>transient_state &amp; 1</c>) — the <see cref="MoveTo"/> manager's
/// UseTime tick gate reads exactly this bit (decomp §6a @307781; a
/// strict subset of the funnel's Contact+OnWalkable gate).</summary>
internal bool BodyInContact => _body.InContact;
/// <summary>R4-V5: body orientation for the <see cref="MoveTo"/>
/// manager's position seam (re-derived from <see cref="Yaw"/> every
/// Update — heading reads/writes go through Yaw, not this).</summary>
internal Quaternion BodyOrientation => _body.Orientation;
/// <summary>
/// Wire the player's AnimationSequencer current cycle velocity into
/// <see cref="MotionInterpreter.GetCycleVelocity"/>. When attached,
@ -417,97 +371,6 @@ public sealed class PlayerMovementController
_motion.GetCycleVelocity = accessor;
}
/// <summary>
/// Apply a server-echoed run rate (ForwardSpeed from UpdateMotion) to the
/// player's MotionInterpreter. The server broadcasts the real RunRate
/// derived from the character's Run skill; wiring it here ensures
/// get_state_velocity produces the correct speed instead of the default 1.0.
/// </summary>
public void ApplyServerRunRate(float forwardSpeed)
{
_motion.InterpretedState.ForwardSpeed = forwardSpeed;
_motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
}
/// <summary>
/// B.6 slice 2 (2026-05-14). Install a server-initiated auto-walk
/// against this body. <see cref="Update"/> will synthesize
/// <c>Forward+Run</c> input and steer <see cref="Yaw"/> toward
/// <paramref name="destinationWorld"/> until the body reaches the
/// arrival predicate (<c>moveTowards: dist ≤ distanceToObject</c>;
/// <c>!moveTowards: dist ≥ minDistance</c>) or the user presses any
/// movement key (which auto-cancels).
///
/// <para>
/// Retail reference: <c>MovementManager::PerformMovement</c>
/// (<c>0x00524440</c>) case 6 — unpacks the wire's target +
/// origin + run rate and calls <c>CPhysicsObj::MoveToObject</c> on
/// the local body. We do the equivalent at acdream's altitude:
/// hold the destination + thresholds + run rate locally, let the
/// existing per-tick motion machinery do the walking, and arrive
/// when the horizontal distance hits the threshold.
/// </para>
///
/// <para>
/// The run-rate parameter is the EFFECTIVE rate after the
/// <c>mtRun=0</c> fallback chain — the caller (GameWindow) is
/// responsible for substituting a non-zero rate when ACE sends 0.0
/// on the wire, per the trace finding in the design spec.
/// </para>
/// </summary>
public void BeginServerAutoWalk(
Vector3 destinationWorld,
float minDistance,
float distanceToObject,
bool moveTowards,
bool canCharge)
{
_autoWalkActive = true;
_autoWalkDestination = destinationWorld;
_autoWalkMinDistance = minDistance;
_autoWalkDistanceToObject = distanceToObject;
_autoWalkMoveTowards = moveTowards;
// Issue #77 fix (2026-05-18) — retail-faithful walk-vs-run.
//
// 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>
/// B.6 slice 2 (2026-05-14). Cancel any active server-initiated
/// auto-walk. Idempotent. <paramref name="reason"/> is logged when
/// <see cref="PhysicsDiagnostics.ProbeAutoWalkEnabled"/> is on so
/// the trace shows why the auto-walk ended.
/// </summary>
public void EndServerAutoWalk(string reason)
{
if (!_autoWalkActive) return;
_autoWalkActive = false;
if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[autowalk-end] reason={reason}");
if (reason == "arrived")
AutoWalkArrived?.Invoke();
}
/// <summary>
/// 2026-05-16. Called by the network outbound layer after every
/// AutonomousPosition or MoveToState that carries the player's
@ -531,270 +394,6 @@ public sealed class PlayerMovementController
_lastSentInitialized = true;
}
/// <summary>
/// B.6 slice 2 (2026-05-14). If a server-initiated auto-walk is
/// active, either cancel it (user pressed a movement key) or
/// synthesize a Forward+Run input with <see cref="Yaw"/> stepped
/// toward the destination. Returns the (possibly modified) input
/// for the rest of <see cref="Update"/> to consume.
///
/// <para>
/// Heading correction matches the deleted RemoteMoveToDriver.Drive
/// — ±its 20-degree HeadingSnapTolerance
/// snap-on-aligned, otherwise rotate at
/// pi/2 rad/s. Arrival
/// predicate matches retail's
/// <c>MoveToManager::HandleMoveToPosition</c>: chase arrives at
/// <c>distanceToObject</c>; flee arrives at <c>minDistance</c>.
/// </para>
/// </summary>
/// <summary>
/// 2026-05-16 (issue #75 refactor) — drive the body directly from
/// the wire-supplied path data during server-initiated auto-walk,
/// without synthesizing player-input. Replaces the earlier
/// ApplyAutoWalkOverlay which returned a synthesized Forward+Run
/// MovementInput; that synthesis leaked to the wire as an outbound
/// MoveToState packet ("user is RunForward") which ACE read as
/// user-took-manual-control and cancelled its own MoveToChain. The
/// architecture now mirrors retail's MovementManager::PerformMovement
/// case 6 (decomp 0x00524440): step the body's velocity + motion
/// state directly; the user-input pipeline downstream sees no input
/// because the user didn't press anything, so no MoveToState gets
/// built.
///
/// <para>
/// Returns <c>true</c> when this method consumed motion control for
/// the frame (auto-walk active, no user override, no arrival).
/// Caller (<see cref="Update"/>) must skip the user-input motion +
/// body-velocity sections to avoid them overriding the auto-walk's
/// velocity assignment.
/// </para>
/// </summary>
private bool DriveServerAutoWalk(float dt, MovementInput input)
{
_autoWalkTurnDirectionThisFrame = 0;
if (!_autoWalkActive) return false;
// User-input cancellation. Any direct movement key takes over.
// Mouse-only turning (no movement key) doesn't cancel — the
// user might just be looking around mid-walk.
bool userOverride = input.Forward || input.Backward
|| input.StrafeLeft || input.StrafeRight
|| input.TurnLeft || input.TurnRight;
if (userOverride)
{
EndServerAutoWalk("user-input");
return false;
}
// Horizontal distance to target — server owns Z, our local body
// Z snaps to UpdatePosition broadcasts when ACE sends them.
var pos = _body.Position;
float dx = _autoWalkDestination.X - pos.X;
float dy = _autoWalkDestination.Y - pos.Y;
float dist = MathF.Sqrt(dx * dx + dy * dy);
// Arrival predicate. With the 10 Hz heartbeat from 301281d the
// server-side Player.Location tracks our body within ~100 ms, so
// the previous "subtract 0.2 m safety margin" workaround is no
// longer needed. Tiny 0.05 m margin remains to absorb the
// sub-tick race between local arrival-fire and the next
// heartbeat's outbound packet.
//
// ARRIVAL IS GATED ON ALIGNMENT: we only end the auto-walk once
// the body is BOTH within use-radius AND facing the target.
// Without the alignment gate, a Use on a close target while
// facing away would end immediately and the body wouldn't turn
// at all (user feedback 2026-05-15: 'when I'm close I'm not
// facing'). The alignment check is computed below in the same
// block as the heading-step; we defer the arrival fire-and-end
// until after we've inspected `aligned`.
float arrivalThreshold = _autoWalkMoveTowards
? _autoWalkDistanceToObject
: _autoWalkMinDistance;
// 2026-05-16 — retail "stop at the radius" semantics.
// Previously had a 0.05 m TinyMargin inside the threshold to
// ensure ACE's server-side WithinUseRadius poll saw us inside
// the radius before our next AP heartbeat. With the
// diff-driven AP cadence (Task B2) ACE sees the final position
// the same frame we arrive — no margin needed. Retail's
// arrival check is `dist <= radius` exact at
// CMotionInterp::apply_interpreted_movement integration.
bool withinArrival =
(_autoWalkMoveTowards
&& dist <= arrivalThreshold)
|| (!_autoWalkMoveTowards
&& dist >= arrivalThreshold + AutoWalkArrivalEpsilon);
// Step Yaw toward target. Convention from Update line 364:
// _body.Orientation = Quaternion.CreateFromAxisAngle(Z, Yaw - π/2),
// so local-forward (+Y) maps to world (cos Yaw, sin Yaw, 0).
// Therefore Yaw that faces (dx,dy) is atan2(dy, dx).
//
// User feedback (2026-05-15): 'I should face that object and then
// start moving. Now it starts running before facing is complete.'
// Track the current heading delta — if we're more than the
// walk-while-turning threshold off, suppress Forward this frame
// so the body turns IN PLACE first. Once we're within the
// threshold, the synthesised Forward+Run kicks in below.
bool aligned = true;
bool walkAligned = true;
if (dist > 1e-4f)
{
float desiredYaw = MathF.Atan2(dy, dx);
float delta = desiredYaw - Yaw;
while (delta > MathF.PI) delta -= 2f * MathF.PI;
while (delta < -MathF.PI) delta += 2f * MathF.PI;
// Retail-faithful local rotation: rotate continuously at
// TurnRate, never snap until overshoot would occur. Retail's
// MoveToManager::HandleTurnToHeading (0x0052a0c0) only snaps
// when heading_greater() detects we've crossed the target —
// there's no "snap when close" tolerance band. The earlier
// 20° snap was borrowed wrongly from RemoteMoveToDriver
// (which is the sparse-update-fudge path for remotes).
//
// MathF.Min(|delta|, maxStep) naturally clamps the final
// fractional step to exactly delta, so we land on the
// target heading without overshoot.
// 2026-05-16 — retail-faithful turn rate. Auto-walk's
// run/walk decision (one-shot at chain start) drives the
// turn rate: running rotation is 50% faster per
// run_turn_factor at retail 0x007c8914.
float maxStep = AutoWalkTurnRateFor(_autoWalkInitiallyRunning) * dt;
float yawStep = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
Yaw += yawStep;
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
// 2026-05-16 (issue #69) — record rotation direction so the
// animation override can pick the TurnLeft/TurnRight cycle.
// Sign convention matches user-driven A/D in Update:
// yawStep > 0 ⇔ TurnLeft (Yaw increases)
// yawStep < 0 ⇔ TurnRight (Yaw decreases)
// Small dead-zone avoids flickering between Turn cycles
// when the residual delta is effectively zero.
if (MathF.Abs(yawStep) > 1e-5f)
_autoWalkTurnDirectionThisFrame = yawStep > 0f ? +1 : -1;
// Two alignment thresholds:
// walkWhileTurning (30°): outside this, body turns in place.
// Inside, body walks forward while
// finishing residual alignment.
// fullyAligned (5°): the arrival-fire alignment. ACE
// rotates server-side via Rotate(target)
// BEFORE invoking the Use callback —
// user reported 'it does not face it
// completely', so the final-alignment
// check must be tighter than the
// walking gate.
const float WalkWhileTurningRad = 30f * MathF.PI / 180f;
const float FullyAlignedRad = 5f * MathF.PI / 180f;
walkAligned = MathF.Abs(delta) <= WalkWhileTurningRad;
aligned = MathF.Abs(delta) <= FullyAlignedRad;
}
// End the auto-walk once the body is BOTH within use radius
// AND aligned with the target. This is the alignment-gated
// arrival the comment above flagged: a close-range Use on a
// target behind the player still rotates the body first.
if (withinArrival && aligned)
{
EndServerAutoWalk("arrived");
return false;
}
// Walk vs run uses the one-shot decision from BeginServerAutoWalk
// (initial distance minus use-radius vs walkRunThreshold).
// Held for the rest of the auto-walk so the body runs all
// the way to a far target, or walks all the way to a near
// one — matching user-observed retail behaviour.
bool shouldRun = _autoWalkInitiallyRunning;
// Turn-first gate: if not yet within the 30° walking band,
// suppress forward motion so the body turns in place rather
// than walking an arc. Also suppress when already within
// arrival — we just turned to face it; no need to step forward
// into it.
bool moveForward = walkAligned && !withinArrival;
if (!moveForward)
{
// 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.
// R3-W6 note: deliberately NOT StopCompletely — auto-walk is
// KEEP-LIST until R4 (w6-cutover-map.md R4 risk note).
_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;
}
// Drive motion state machine + body velocity directly. This
// mirrors what the user-input section would have done with
// synthesized Forward+Run, but without putting anything into
// MovementInput — so the outbound-packet pipeline never builds
// a MoveToState packet for auto-walk frames.
uint forwardCmd;
float forwardCmdSpeed;
if (shouldRun && _weenie.InqRunRate(out float runRate))
{
// Wire-compatible: WalkForward command @ runRate triggers
// ACE's auto-upgrade to RunForward for observers. Same
// shape as the user-input section's running path.
forwardCmd = MotionCommand.WalkForward;
forwardCmdSpeed = runRate;
}
else
{
forwardCmd = MotionCommand.WalkForward;
forwardCmdSpeed = 1.0f;
}
// Update interpreted motion state — drives the animation cycle
// via UpdatePlayerAnimation downstream + the MotionInterpreter's
// state-velocity getter (used for our velocity assignment below).
_motion.DoMotion(forwardCmd, forwardCmdSpeed);
// Set body velocity directly. Only meaningful when grounded;
// mirror the user-input section's `if (_body.OnWalkable)` gate
// so we don't override gravity/jump velocity mid-air.
if (_body.OnWalkable)
{
float savedWorldVz = _body.Velocity.Z;
var stateVel = _motion.get_state_velocity();
_body.set_local_velocity(new Vector3(0f, stateVel.Y, savedWorldVz));
}
return true;
}
// L.2a slice 1 (2026-05-12): centralized CellId mutation so the
// [cell-transit] probe fires from a single chokepoint. Both the
// server-snap path (SetPosition) and the per-frame resolver path
@ -867,7 +466,6 @@ public sealed class PlayerMovementController
_prevTurnLeftHeld = false;
_prevTurnRightHeld = false;
_prevRunHeld = false;
_prevAutoWalkTurnDir = 0;
// Reset physics clock so any subsequent update_object calls start fresh.
_body.LastUpdateTime = 0.0;
@ -884,22 +482,20 @@ public sealed class PlayerMovementController
{
_simTimeSeconds += dt;
// 2026-05-16 (issue #75 refactor): server-initiated auto-walk
// drives the body's velocity + motion state machine DIRECTLY.
// When _autoWalkActive, DriveServerAutoWalk steps Yaw, computes
// velocity from wire-supplied runRate, calls _motion.DoMotion,
// and sets _body.set_local_velocity. The user-input motion +
// velocity sections below are SKIPPED so they don't override
// the auto-walk's assignments. Critically, no synthesized input
// gets put back into `input` — the outbound-packet pipeline at
// GameWindow.cs:6410 sees user-input null/Ready throughout the
// auto-walk and never builds a MoveToState packet, leaving
// ACE's server-side MoveToChain to run uninterrupted until its
// TryUseItem/TryPickUp callback fires. Retail equivalent:
// MovementManager::PerformMovement case 6 (decomp 0x00524440)
// calls CPhysicsObj::MoveToObject server-side; the local body
// is moved without ever touching CommandInterpreter input.
bool autoWalkConsumedMotion = DriveServerAutoWalk(dt, input);
// R4-V5: tick the verbatim MoveToManager at the slot the deleted
// DriveServerAutoWalk occupied — after inbound wire routing, before
// the input-driven motion sections. UseTime runs the retail per-tick
// moveto drivers (HandleMoveToPosition aux-turn steering + arrival +
// fail-distance / HandleTurnToHeading); the motions it dispatches
// land in InterpretedState through _DoMotion → DoInterpretedMotion,
// and sections 1/2 below turn that state into Yaw rotation + body
// velocity — the same per-frame apply user input gets. No
// synthesized input exists, so the outbound-packet pipeline sees no
// user motion and never builds a MoveToState mid-moveto (the #75
// invariant, now by construction). Provisional tick placement until
// R6 ports retail's UpdateObjectInternal ordering (r4-port-plan.md
// §3 placement decision).
MoveTo?.UseTime();
// Portal-space guard: while teleporting, no input is processed and
// no physics is resolved. Return a zero-movement result so the caller
@ -929,11 +525,13 @@ public sealed class PlayerMovementController
// remotes use. The Shift edge is retail's set_hold_run
// (0x00528b70, caller 0x006b33ca shape: interrupt=true). RAW speeds
// stay 1.0 (apply_run_to_command applies the run rate — pre-scaling
// would double-scale; TS-22 unchanged). Skipped during server
// auto-walk, which drives the body itself (edge state still
// tracked so release edges fire correctly when auto-walk ends).
// would double-scale; TS-22 unchanged). R4-V5: the ctor-default
// params carry retail's 0x1EE0F bitfield whose CancelMoveTo bit
// (0x8000) is SET — any key edge mid-moveto fires
// InterruptCurrentMovement → MoveTo.CancelMoveTo(ActionCancelled),
// the retail user-input cancel chain (TS-36 retired; set_hold_run's
// interrupt:true is the same chain for the Shift edge).
bool motionEdgeFired = false;
if (!autoWalkConsumedMotion)
{
var p = new AcDream.Core.Physics.Motion.MovementParameters();
@ -1011,11 +609,13 @@ public sealed class PlayerMovementController
// BaseTurnRateRadPerSec × turn_speed — the same π/2 formula the remote
// path uses (GameWindow ObservedOmega seed). Numerically identical to the
// former TurnRateFor(input.Run); AP-9 (π/2 base rate) is unchanged.
// Skipped during auto-walk (server drives the turn). Mouse turn stays a
// direct Yaw delta (deliberately generates no turn command — avoids
// MoveToState spam from mouse jitter).
if (!autoWalkConsumedMotion
&& _motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
// R4-V5: runs during a moveto too — the manager's aux-turn steering
// and TurnToHeading nodes dispatch TurnRight/TurnLeft through
// _DoMotion into the SAME interpreted turn state, so this one
// integrator rotates the player for both input and moveto turns.
// Mouse turn stays a direct Yaw delta (deliberately generates no
// turn command — avoids MoveToState spam from mouse jitter).
if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
{
Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver
* _motion.InterpretedState.TurnSpeed * dt;
@ -1031,14 +631,12 @@ public sealed class PlayerMovementController
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
// ── 2. Set velocity via MotionInterpreter state machine ───────────────
// 2026-05-16 (issue #75): skip when DriveServerAutoWalk owns
// motion control this frame — it has already called
// _motion.DoMotion + _body.set_local_velocity from the auto-
// walk's path data + runRate. Running this section would
// overwrite the auto-walk velocity with the user-input
// (Ready/Stand) velocity, freezing the body.
if (!autoWalkConsumedMotion)
{
// R4-V5: runs during a moveto too — the manager's BeginMoveForward
// dispatched WalkForward/RunForward through _DoMotion into the SAME
// interpreted state, so this one per-frame apply turns it into body
// velocity exactly like input-driven motion (the #75 "two writers"
// hazard is gone: there is only this writer now).
// D6.2: the forward/sidestep command determination + DoMotion +
// DoInterpretedMotion moved into the apply_raw_movement call above, so
// the interpreted state (and thus get_state_velocity) is already
@ -1057,12 +655,17 @@ public sealed class PlayerMovementController
// that adjust_motion ran above. The hand-mirrored formulas are gone
// (register TS-22 retired).
var stateVel = _motion.get_state_velocity();
// R3-W6: autonomous — this is local input-driven motion; the
// default (false) silently cleared LastMoveWasAutonomous and
// flipped the A3 dual dispatch to the interpreted branch.
_body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz), autonomous: true);
// R3-W6: autonomous=true flags input-driven motion (the default
// false silently cleared LastMoveWasAutonomous and flipped the
// A3 dual dispatch to the interpreted branch). R4-V5: while the
// MoveToManager is driving, the last movement EVENT was the
// server's non-autonomous moveto — retail stores the wire
// autonomous byte on the unpack path (P1 pin,
// last_move_was_autonomous), so the per-frame stamp must not
// overwrite that with true mid-moveto.
bool autonomousMove = MoveTo is null || !MoveTo.IsMovingTo();
_body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz), autonomous: autonomousMove);
}
} // end of `if (!autoWalkConsumedMotion)` — section 2
// ── 3. Jump (charged) ─────────────────────────────────────────────────
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
@ -1334,6 +937,12 @@ public sealed class PlayerMovementController
if (wasAirborne)
{
_motion.HitGround();
// R4-V5: retail order — minterp first, then moveto
// (MovementManager::HitGround 0x00524300, decomp §2d);
// re-arms a moveto suspended by the airborne UseTime
// contact gate. LeaveGround has NO moveto side (COMDAT
// no-op, §2e) — do not add one.
MoveTo?.HitGround();
justLanded = true;
}
}
@ -1527,24 +1136,11 @@ public sealed class PlayerMovementController
bool anyDirectional = input.Forward || input.Backward
|| input.StrafeLeft || input.StrafeRight;
// R3-W6 (#69 kept alive through the retail pipeline): auto-walk's
// turn-first phase now dispatches the turn cycle as an EDGE through
// DoMotion/StopMotion instead of the deleted LocalAnimationCommand
// override. Forward legs during auto-walk come from
// DriveServerAutoWalk's own DoMotion(WalkForward) (KEEP-LIST, R4
// replaces). Expected-diff: auto-walk-at-run plays walk-pace legs
// until R4's MoveToManager drives CanCharge walk/run selection.
if (_autoWalkTurnDirectionThisFrame != _prevAutoWalkTurnDir)
{
var pTurn = new AcDream.Core.Physics.Motion.MovementParameters();
if (_autoWalkTurnDirectionThisFrame > 0)
_motion.DoMotion(MotionCommand.TurnLeft, pTurn);
else if (_autoWalkTurnDirectionThisFrame < 0)
_motion.DoMotion(MotionCommand.TurnRight, pTurn);
else
_motion.StopMotion(MotionCommand.TurnRight, pTurn);
_prevAutoWalkTurnDir = _autoWalkTurnDirectionThisFrame;
}
// R4-V5: the #69 auto-walk turn-cycle edge synthesizer is DELETED —
// the MoveToManager's own aux-turn steering and TurnToHeading nodes
// dispatch TurnRight/TurnLeft through _DoMotion (the retail
// mechanism), so turn cycles during a moveto come from the same
// pipeline as everything else.
return new MovementResult(
Position: Position,