feat(retail): Phase B.6 — server-driven auto-walk done right

Closes #63, #69, #74, #75. Replaces the chain of Commit-B workarounds
that compensated for ACE's MoveToChain getting cancelled by a leaked
user-MoveToState packet during inbound auto-walk. The fix is
architectural — auto-walk drives the body directly from the
server-supplied path data, no player-input synthesis, no spurious
wire-packet transitions, no grace-period band-aid.

Architectural change (closes #75):
  PlayerMovementController.ApplyAutoWalkOverlay → DriveServerAutoWalk.
  - Steps Yaw toward target at retail-faithful turn rates.
  - Computes desired forward velocity from path runRate.
  - Calls _motion.DoMotion(WalkForward, speed) directly for the
    motion-interpreter state (drives animation cycle).
  - Sets _body.set_local_velocity directly when grounded.
  - Returns true to gate the user-input motion + velocity section
    in Update so user-input flow doesn't overwrite auto-walk
    velocity or motion state.
  Mirrors retail's MovementManager::PerformMovement case 6 (decomp
  0x00524440) which never touches the user-input pipeline during
  server-controlled auto-walk.

Wire-layer guard at GameWindow.cs:6419 retained as a SEMANTIC
statement (`if (result.MotionStateChanged && !IsServerAutoWalking)`):
user-MoveToState packets are for user-driven motion intent. During
server-controlled auto-walk, the motion-state transitions caused by
the animation override (RunForward / WalkForward / TurnLeft /
TurnRight cycles) must not leak as user-cancellation packets. This
is NOT the deleted 500ms grace-period band-aid; it's the wire-layer
expressing the user-vs-server motion split.

Animation plumbed for auto-walk phases (closes #69):
  - Moving forward → WalkForward (speed=1.0) / RunForward (speed=runRate)
  - Turn-first phase → TurnLeft / TurnRight (sign of yawStep)
  - Aligned-but-pre-step / arrival → no override (idle)
Driven via _autoWalkMovingForwardThisFrame + _autoWalkTurnDirectionThisFrame
fields set in DriveServerAutoWalk and read in the MovementResult
construction at the bottom of Update. UpdatePlayerAnimation picks up
the localAnimCmd as the highest-priority animation source.

Walk/run threshold = 1.0m, retail-observed. ACE's wire-default of
15.0f is too generous; ACE's own physics layer uses 1.0f at
MovementParameters.cs:50 (with the 15.0f line commented out) and
Creature.cs:312 notes "default 15 distance seems too far". The
formula matches retail's MovementParameters::get_command at decomp
0x0052aa00: running = (initialDist - distance_to_object) >=
threshold, evaluated ONCE at chain start and held for the rest of
the auto-walk (matches retail "runs all the way / walks all the way"
behaviour). Wire-supplied threshold is ignored.

Pickup gate (IsPickupableTarget) now uses BF_STUCK
(acclient.h:6435, bit 0x4) to discriminate immovable scenery from
real pickup items that share a Misc ItemType. Sign (pwd=0x14 with
BF_STUCK) → blocked; spell component (pwd=0x10, no BF_STUCK) →
allowed. ACE's PutItemInContainer (Player_Inventory.cs:831-836)
responds with WeenieError.Stuck (0x29) on stuck items so the gate
prevents wasted wire packets + a UX dead-end.

R-key dispatch by target type. UseCurrentSelection's top-level
IsUseableTarget gate was wrong (blocked USEABLE_NO=1 items that
ARE pickupable). Reordered:
  1. Creature → SendUse
  2. Pickupable → SendPickUp
  3. Useable → SendUse
  4. Otherwise → "cannot be used" toast
Each handler keeps its own gate. Matches retail's per-action
server-side validation.

AP cadence revert (closes #74). With the MoveToChain race fixed,
the per-frame "send while moving" cadence is no longer load-bearing.
Reverted to retail's two-branch ShouldSendPositionEvent gate
(acclient_2013_pseudo_c.txt:700233-700285):
  Interval NOT elapsed (< 1 sec): send if cell or contact-plane changed.
  Interval elapsed (>= 1 sec):    send if cell or position frame changed.
Adds _lastSentContactPlane field + ApproxPlaneEqual helper +
PlayerMovementController.ContactPlane public accessor. Extended
NotePositionSent(Vector3, uint, Plane, float) — both outbound sites
(MoveToState + AP) pass _playerController.ContactPlane.
Effective rates: 0 Hz idle, ~1 Hz smooth motion, per-event on
cell/plane changes, 0 Hz airborne.

CLAUDE.md updated with no-workarounds rule (commit `da126f9` on
the worktree branch). Saved as feedback memory at
memory/feedback_no_workarounds.md.

Tests: build green; Core.Net 294/294; Core 1073/1081 (baseline,
8 pre-existing Physics failures unchanged). Visual-verified
end-to-end on 2026-05-16 for far/near Use + PickUp on NPCs,
doors, items, spell components, signs (correctly blocked), corpses,
turn-first animation, run/walk thresholds, idle quiet, smooth-
motion 1Hz.

Spec: docs/superpowers/specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md
Plan: docs/superpowers/plans/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-16 16:14:44 +02:00
parent b5da17db76
commit d640ed74e1
6 changed files with 1317 additions and 200 deletions

View file

@ -154,6 +154,16 @@ public sealed class PlayerMovementController
/// <summary>Full 3D world-space velocity of the physics body. Exposed for diagnostic logging.</summary>
public Vector3 BodyVelocity => _body.Velocity;
/// <summary>
/// 2026-05-16 — current contact plane (normal + distance) for the
/// physics body. Exposed so the network outbound layer can stamp
/// it into <see cref="NotePositionSent"/> for retail's diff-driven
/// AP cadence: SendPositionEvent re-sends if cell OR contact-plane
/// changed since last_sent, per
/// <c>acclient_2013_pseudo_c.txt:700233 ShouldSendPositionEvent</c>.
/// </summary>
public System.Numerics.Plane ContactPlane => _body.ContactPlane;
// Jump charge state.
private bool _jumpCharging;
private float _jumpExtent;
@ -203,6 +213,7 @@ public sealed class PlayerMovementController
private System.Numerics.Vector3 _lastSentPos;
private uint _lastSentCellId;
private System.Numerics.Plane _lastSentContactPlane;
private float _lastSentTime;
private bool _lastSentInitialized;
private float _simTimeSeconds;
@ -256,22 +267,51 @@ public sealed class PlayerMovementController
private float _autoWalkMinDistance;
private float _autoWalkDistanceToObject;
private bool _autoWalkMoveTowards;
// Walk-vs-run decision is made ONCE at BeginServerAutoWalk based on
// initial distance vs the wire's WalkRunThreshold, then held for the
// duration of the auto-walk. Earlier per-frame evaluation produced
// "runs partway then walks the rest" which the user reported as
// wrong: the character should run all the way to the target then
// stop, not transition to a walk near the end.
// 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;
/// <summary>
/// True while a server-initiated auto-walk (MoveToObject inbound) is
/// active on the local player. The next <see cref="Update"/> call
/// synthesizes Forward+Run input and steers <see cref="Yaw"/> toward
/// the destination until arrival or user-input cancellation.
/// 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.
/// </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.
private bool _autoWalkMovingForwardThisFrame;
// 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
@ -398,17 +438,35 @@ public sealed class PlayerMovementController
_autoWalkDistanceToObject = distanceToObject;
_autoWalkMoveTowards = moveTowards;
// Decide run vs walk ONCE based on the initial horizontal
// distance from the player to the destination. Run-all-the-way
// is the retail-faithful behaviour the user observed: pick a
// distant target → character runs the whole way, decelerates
// to a stop at use radius. Earlier per-frame evaluation made
// the body transition to a walk inside threshold and felt
// wrong (the user reported "runs partway then walks").
// 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.
//
// 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);
_autoWalkInitiallyRunning = initialDist > walkRunThreshold;
float remainingAtStart = initialDist - distanceToObject;
_autoWalkInitiallyRunning = remainingAtStart >= RetailWalkRunThresholdMeters;
}
/// <summary>
@ -431,19 +489,21 @@ public sealed class PlayerMovementController
/// 2026-05-16. Called by the network outbound layer after every
/// AutonomousPosition or MoveToState that carries the player's
/// position. Resets the diff-driven heartbeat clock so the next
/// `HeartbeatDue` evaluation requires either a fresh position
/// change OR another full HeartbeatInterval. Mirrors retail's
/// SendPositionEvent (0x006b4770) which updates
/// `last_sent_position_time` + `last_sent_position` at every
/// send, AND SendMovementEvent (0x006b4680) which also touches
/// the same shared clock (both consumers of the 1 sec window).
/// `HeartbeatDue` evaluation requires either a fresh state change
/// (cell, contact-plane, or frame) OR another full HeartbeatInterval.
/// Mirrors retail's SendPositionEvent at
/// <c>acclient_2013_pseudo_c.txt:700345-700348</c> which updates
/// `last_sent_position`, `last_sent_position_time`, AND
/// `last_sent_contact_plane` after every send.
/// </summary>
public void NotePositionSent(System.Numerics.Vector3 worldPos,
uint cellId,
System.Numerics.Plane contactPlane,
float nowSeconds)
{
_lastSentPos = worldPos;
_lastSentCellId = cellId;
_lastSentContactPlane = contactPlane;
_lastSentTime = nowSeconds;
_lastSentInitialized = true;
}
@ -465,9 +525,33 @@ public sealed class PlayerMovementController
/// <c>distanceToObject</c>; flee arrives at <c>minDistance</c>.
/// </para>
/// </summary>
private MovementInput ApplyAutoWalkOverlay(float dt, MovementInput input)
/// <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)
{
if (!_autoWalkActive) return input;
_autoWalkMovingForwardThisFrame = false;
_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
@ -478,7 +562,7 @@ public sealed class PlayerMovementController
if (userOverride)
{
EndServerAutoWalk("user-input");
return input;
return false;
}
// Horizontal distance to target — server owns Z, our local body
@ -551,16 +635,26 @@ public sealed class PlayerMovementController
// 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 knows
// its run/walk decision from _autoWalkInitiallyRunning
// (set at BeginServerAutoWalk based on initial distance vs
// WalkRunThreshold). Running rotation is 50% faster per
// 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 = RemoteMoveToDriver.TurnRateFor(_autoWalkInitiallyRunning) * dt;
Yaw += MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
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
@ -585,15 +679,14 @@ public sealed class PlayerMovementController
if (withinArrival && aligned)
{
EndServerAutoWalk("arrived");
return input;
return false;
}
// Walk vs run decided ONCE at BeginServerAutoWalk based on
// initial distance — held for the rest of the auto-walk so the
// character keeps running all the way to the target instead of
// transitioning to a walk inside the threshold. Matches user-
// observed retail behaviour ("if its far away it should run
// all the way to the object and then stop").
// 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,
@ -603,22 +696,56 @@ public sealed class PlayerMovementController
// into it.
bool moveForward = walkAligned && !withinArrival;
// Synthesize "moving forward" input. The rest of Update reads
// Yaw + input.Forward + input.Run to drive _motion + body
// velocity exactly as it does for user-driven W (+ optional Shift).
// We zero any mouse delta so a stale frame doesn't fight the
// steering.
return input with
if (!moveForward)
{
Forward = moveForward,
Run = moveForward && shouldRun,
Backward = false,
StrafeLeft = false,
StrafeRight = false,
TurnLeft = false,
TurnRight = false,
MouseDeltaX = 0f,
};
// 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.
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;
}
_autoWalkMovingForwardThisFrame = true;
// 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
@ -664,14 +791,22 @@ public sealed class PlayerMovementController
{
_simTimeSeconds += dt;
// B.6 slice 2 (2026-05-14): server-initiated auto-walk overlay.
// When _autoWalkActive, steer Yaw toward _autoWalkDestination and
// synthesize Forward+Run input so the rest of Update runs the
// body forward as if the user were holding W. User movement-key
// input cancels the auto-walk (retail UX). Arrival check fires
// before synthesizing, so a one-frame arrival doesn't waste a
// tick of velocity past the target.
input = ApplyAutoWalkOverlay(dt, input);
// 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);
// Portal-space guard: while teleporting, no input is processed and
// no physics is resolved. Return a zero-movement result so the caller
@ -719,6 +854,14 @@ 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)
{
// Determine the dominant forward/backward command and speed.
uint forwardCmd;
float forwardCmdSpeed;
@ -806,6 +949,7 @@ public sealed class PlayerMovementController
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
}
} // end of `if (!autoWalkConsumedMotion)` — section 2
// ── 3. Jump (charged) ─────────────────────────────────────────────────
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
@ -1243,46 +1387,55 @@ public sealed class PlayerMovementController
}
// ── 8. Heartbeat timer (always while in-world, not just while moving) ─
// 2026-05-16 — retail diff-driven AP cadence (acclient_2013_pseudo_c.txt
// 0x006b45e0 ShouldSendPositionEvent + 0x006b4770 SendPositionEvent).
// 2026-05-16 (closes #74) — retail-faithful AP cadence per
// CommandInterpreter::ShouldSendPositionEvent at
// acclient_2013_pseudo_c.txt:700233-700285. Two-branch:
//
// Rules:
// - When interval elapsed (>= 1 sec since last send): send.
// - When interval NOT elapsed: send only if position or cell
// differs from last_sent (Frame::is_equal check at
// acclient_2013_pseudo_c.txt:700248-700265).
// - SendPositionEvent (acclient_2013_pseudo_c.txt:700327)
// gates on `((state & 1) != 0 && (state & 2) != 0)` —
// Contact (CONTACT_TS bit 0) AND OnWalkable (ON_WALKABLE_TS
// bit 1) BOTH set. Two independent `& != 0` tests joined
// by `&&`, NOT a single bitwise-OR mask test. Airborne
// (neither bit) and wall-contact-without-walkable (Contact
// only) both suppress AP. MoveToState carries jump/fall
// snapshots while airborne.
// Branch 1 — interval NOT yet elapsed (< 1 sec since last
// send): send only if cell changed OR contact-plane changed
// (mid-walk events that matter — stair / hill / cell cross).
//
// Effective rate: per-frame while moving on the ground, 1 Hz at-rest
// heartbeat, 0 Hz airborne. Retires the 1 Hz / 10 Hz flat model.
// Branch 2 — interval HAS elapsed (>= 1 sec): send only if
// cell OR position frame changed. Truly idle = no send
// (retail's `last_sent.frame == player.frame` check at
// acclient_2013_pseudo_c.txt:700248-700265).
//
// If NotePositionSent has never been called (no network session),
// _lastSentInitialized stays false and we treat every frame as
// "first send" — HeartbeatDue fires once per frame, which matches
// "send if anything to send" semantics.
// SendPositionEvent (line 700327) gates the actual send on
// (state & 1) != 0 && (state & 2) != 0 — Contact AND
// OnWalkable both set. We mirror that gate so airborne and
// wall-contact-without-walkable suppress AP entirely;
// MoveToState carries jump/fall snapshots while airborne.
//
// Effective rates:
// Truly idle (grounded, no movement) : 0 Hz
// Smooth movement (no cell/plane changes) : ~1 Hz (interval)
// Cell crossings + stair/hill steps : per-event
// Airborne : 0 Hz
//
// Bootstrap: when NotePositionSent has never been called
// (_lastSentInitialized=false), every state-changed branch is
// forced true so the first AP gets a chance to fire.
bool intervalElapsed = !_lastSentInitialized
|| (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval;
bool positionChanged =
!_lastSentInitialized
|| _lastSentCellId != CellId
bool cellChanged = !_lastSentInitialized
|| _lastSentCellId != CellId;
bool planeChanged = !_lastSentInitialized
|| !ApproxPlaneEqual(_lastSentContactPlane, _body.ContactPlane);
bool frameChanged = !_lastSentInitialized
|| !ApproxPositionEqual(_lastSentPos, _body.Position);
// Grounded-on-walkable. Retail's CONTACT_TS + ON_WALKABLE_TS
// (acclient.h:3688). Our equivalent: PhysicsBody.InContact &&
// PhysicsBody.OnWalkable (both map to TransientStateFlags bits 0+1
// which are set together by ResolveWithTransition on walkable ground).
bool sendThisFrame = intervalElapsed
? (cellChanged || frameChanged)
: (cellChanged || planeChanged);
// Grounded-on-walkable gate per acclient_2013_pseudo_c.txt:700327
// (`(state & 1) != 0 && (state & 2) != 0`). Both flags must be
// set simultaneously, NOT a bitwise-OR mask test.
bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
HeartbeatDue = groundedOnWalkable && (positionChanged || intervalElapsed);
HeartbeatDue = groundedOnWalkable && sendThisFrame;
// K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate
// should match the actual movement speed. For Forward+Run this is
@ -1296,6 +1449,40 @@ public sealed class PlayerMovementController
? (_weenie.InqRunRate(out float vrrAnim) ? vrrAnim : 1f)
: 1f;
// 2026-05-16 (issue #75) — server-initiated auto-walk drives
// the local animation cycle directly:
// - moving forward → WalkForward / RunForward (legs animate)
// - turn-first phase → TurnLeft / TurnRight (issue #69 fix)
// - aligned but pre-step / arrival → no override, falls to
// the user-input section's default (idle)
// UpdatePlayerAnimation reads LocalAnimationCommand +
// LocalAnimationSpeed; without these overrides the body
// translates/rotates without leg/arm animation. The motion
// cycle commands here flow into the animation sequencer
// ONLY — the wire-layer guard at GameWindow.cs:6419 prevents
// them from leaking to a user-MoveToState packet during
// auto-walk.
if (_autoWalkMovingForwardThisFrame)
{
if (_autoWalkInitiallyRunning && _weenie.InqRunRate(out float autoWalkRunRate))
{
localAnimCmd = MotionCommand.RunForward;
localAnimSpeed = autoWalkRunRate;
}
else
{
localAnimCmd = MotionCommand.WalkForward;
localAnimSpeed = 1f;
}
}
else if (_autoWalkTurnDirectionThisFrame != 0)
{
localAnimCmd = _autoWalkTurnDirectionThisFrame > 0
? MotionCommand.TurnLeft
: MotionCommand.TurnRight;
localAnimSpeed = 1f;
}
return new MovementResult(
Position: Position,
RenderPosition: RenderPosition,
@ -1339,4 +1526,24 @@ public sealed class PlayerMovementController
&& MathF.Abs(a.Y - b.Y) < Epsilon
&& MathF.Abs(a.Z - b.Z) < Epsilon;
}
/// <summary>
/// 2026-05-16. Contact-plane-equality test for retail's
/// sub-interval AP gate. Retail's SendPositionEvent stores
/// last_sent_contact_plane and ShouldSendPositionEvent re-sends
/// during the sub-interval window if the plane has changed (e.g.,
/// player stepped onto stairs / a hill — same cell but different
/// contact normal). Tiny epsilon on normal + distance covers
/// floating-point noise from the physics integration.
/// </summary>
private static bool ApproxPlaneEqual(
System.Numerics.Plane a, System.Numerics.Plane b)
{
const float NormalEpsilon = 1e-4f;
const float DistanceEpsilon = 0.001f;
return MathF.Abs(a.Normal.X - b.Normal.X) < NormalEpsilon
&& MathF.Abs(a.Normal.Y - b.Normal.Y) < NormalEpsilon
&& MathF.Abs(a.Normal.Z - b.Normal.Z) < NormalEpsilon
&& MathF.Abs(a.D - b.D) < DistanceEpsilon;
}
}

View file

@ -6407,7 +6407,23 @@ public sealed class GameWindow : IDisposable
var wireRot = YawToAcQuaternion(_playerController.Yaw);
byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
if (result.MotionStateChanged)
// 2026-05-16 (issue #75): wire-layer semantic gate —
// user-MoveToState packets are ONLY for user-initiated
// motion intent. During server-controlled auto-walk
// (inbound MoveToObject), motion-state transitions
// come from the auto-walk's animation override, not
// from user input. Sending a MoveToState in that case
// would tell ACE "user took control" and cancel its
// own MoveToChain. This is NOT a band-aid like the
// earlier grace-period — it's the wire-layer's
// expression of retail's architectural split between
// user-input motion and server-driven motion: they
// share the local motion-state machine but only
// user-input flows back to the wire. Without the
// refactor (issue #75) this guard masked a synthesis
// leak; with the refactor it expresses the proper
// semantic.
if (result.MotionStateChanged && !_playerController.IsServerAutoWalking)
{
// HoldKey axis values — retail enum (holtburger types.rs HoldKey):
// Invalid = 0, None = 1, Run = 2
@ -6446,11 +6462,13 @@ public sealed class GameWindow : IDisposable
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
// HeartbeatDue resets its interval from THIS send — mirrors retail's
// SendMovementEvent (acclient_2013_pseudo_c.txt:0x006b4680) writing
// last_sent_position_time + last_sent_position after each MTS send.
// last_sent_position_time + last_sent_position + contact_plane
// after each MTS send.
_playerController.NotePositionSent(
worldPos: _playerController.Position,
cellId: _playerController.CellId,
nowSeconds: _playerController.SimTimeSeconds);
worldPos: _playerController.Position,
cellId: _playerController.CellId,
contactPlane: _playerController.ContactPlane,
nowSeconds: _playerController.SimTimeSeconds);
}
if (_playerController.HeartbeatDue)
@ -6472,11 +6490,13 @@ public sealed class GameWindow : IDisposable
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
// HeartbeatDue resets its interval from THIS send — mirrors retail's
// SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348)
// writing last_sent_position_time + last_sent_position after each AP.
// writing last_sent_position_time + last_sent_position +
// last_sent_contact_plane after each AP.
_playerController.NotePositionSent(
worldPos: _playerController.Position,
cellId: _playerController.CellId,
nowSeconds: _playerController.SimTimeSeconds);
worldPos: _playerController.Position,
cellId: _playerController.CellId,
contactPlane: _playerController.ContactPlane,
nowSeconds: _playerController.SimTimeSeconds);
}
if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue)
@ -9106,45 +9126,51 @@ public sealed class GameWindow : IDisposable
return;
}
// 2026-05-16 — R is conceptually "use." It smart-routes to
// pickup as a downstream optimization (see the isPickupableItem
// dispatch below), but the GATE is always IsUseableTarget —
// what retail's UseObject would do.
// 2026-05-16 (Phase B.6 follow-up) — R is the universal "interact"
// key. Retail dispatches by TARGET TYPE first; the useability gate
// is enforced by each individual action handler (SendUse checks
// IsUseableTarget; SendPickUp checks IsPickupableTarget), not as
// a top-level block. Previously the IsUseableTarget gate at the
// entry point rejected USEABLE_NO=1 items (spell components,
// gems) which retail accepts as pickupable — they just aren't
// "useable" in the activate-from-world sense.
//
// Dispatch order:
// 1. Creature → SendUse (talk to NPC, attack monster)
// 2. Pickupable → SendPickUp (small items, corpses)
// 3. Useable → SendUse (doors, portals, lifestones,
// potions / scrolls activated from world)
// 4. Else → toast "X cannot be used" (signs, banners,
// decorative scenery)
//
// Retail string at acclient_2013_pseudo_c.txt:1033115
// (data_7e2a70): "The %s cannot be used".
if (!IsUseableTarget(sel))
bool isCreature = _liveEntityInfoByGuid.TryGetValue(sel, out var info)
&& (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0;
if (isCreature)
{
string label = DescribeLiveEntity(sel);
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] R-key ignored — not useable guid=0x{sel:X8}");
SendUse(sel);
return;
}
// B.7 (2026-05-15): the user requested R behave as a universal
// interact key — pickup for items, use for NPCs / doors /
// lifestones / portals / corpses. Matches retail's "use"
// behaviour where the action picked depends on the target's
// type rather than forcing the player to remember a different
// hotkey per target type.
bool isPickupableItem = true;
if (_liveEntityInfoByGuid.TryGetValue(sel, out var info)
&& (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0)
if (IsPickupableTarget(sel))
{
// NPCs / monsters / players are Use targets, never PickUp.
isPickupableItem = false;
}
if (_lastSpawnByGuid.TryGetValue(sel, out var spawn)
&& spawn.ObjectDescriptionFlags is { } odf)
{
// BF_DOOR | BF_LIFESTONE | BF_PORTAL | BF_CORPSE → Use, not PickUp.
// (acclient.h:6431-6463)
const uint NonPickupMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
if ((odf & NonPickupMask) != 0) isPickupableItem = false;
SendPickUp(sel);
return;
}
if (isPickupableItem) SendPickUp(sel);
else SendUse(sel);
if (IsUseableTarget(sel))
{
SendUse(sel);
return;
}
string label = DescribeLiveEntity(sel);
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] R-key ignored — neither pickupable nor useable guid=0x{sel:X8}");
}
private void SendUse(uint guid)
@ -9188,22 +9214,17 @@ public sealed class GameWindow : IDisposable
return;
}
// Far range: send Use immediately so ACE has the request,
// AND queue an arrival re-send. Issue #63 (server-initiated
// MoveToObject not honored) means ACE's first-Use response
// is dropped as too-far and ACE doesn't re-poll
// WithinUseRadius when the speculative local walk gets us in
// range. The arrival re-send fires a second Use packet once
// the body reaches the target — at which point ACE accepts
// and executes the action. The retail-faithful path is to
// honor MoveToObject and let ACE complete the Use server-
// side; until #63 lands, this client-side retry is the
// workaround that keeps far-range Use working.
// Far range: send Use ONCE. ACE's CreateMoveToChain
// (Player_Use.cs:205) holds a callback (TryUseItem) and fires
// it server-side when WithinUseRadius passes during the
// MoveToChain poll (Player_Move.cs:150). No client-side retry
// needed — the Phase B.6 MoveToState-suppression fix
// (GameWindow.cs:6410) keeps ACE's chain alive during the
// walk.
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
_liveSession.SendGameAction(body);
_pendingPostArrivalAction = (guid, false);
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq} (queued for arrival re-send pending #63)");
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
string label = DescribeLiveEntity(guid);
@ -9263,16 +9284,16 @@ public sealed class GameWindow : IDisposable
return;
}
// Far range: same arrival-retry pattern as SendUse — fire
// PickUp immediately AND queue for arrival re-send. ACE's
// first PickUp is dropped if we're outside the use-radius
// and isn't re-polled (issue #63 workaround).
// Far range: send PickUp ONCE. Same auto-fire-via-MoveToChain
// callback pattern as SendUse — ACE's chain fires
// PutItemInContainer/Move server-side when in range. No
// client-side retry; Phase B.6 MoveToState suppression keeps
// ACE's chain alive.
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
seq, itemGuid, _playerServerGuid, placement: 0);
_liveSession.SendGameAction(body);
_pendingPostArrivalAction = (itemGuid, true);
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq} (queued for arrival re-send pending #63)");
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
string label = DescribeLiveEntity(itemGuid);
@ -9665,56 +9686,75 @@ public sealed class GameWindow : IDisposable
/// pickup flow for entities where ACE didn't publish useability.
/// </para>
/// </summary>
/// <summary>
/// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based.
///
/// <para>
/// The earlier <c>(useability &amp; USEABLE_REMOTE) != 0u</c> check
/// was a misread of the audit. USEABLE_REMOTE (0x20) gates the USE
/// action ("can the player activate this item from the world");
/// PICKUP is a separate action governed by retail's
/// PutItemInContainer handler, which accepts any small-item-class
/// entity from the world regardless of useability bits. A spell
/// component with useability=USEABLE_NO=1 is still pickupable in
/// retail — USEABLE_NO blocks using the component (you can't
/// "activate" it standalone), not picking it up.
/// </para>
///
/// <para>
/// Now matches retail: small-item ItemType class OR BF_CORPSE bit
/// → pickupable. Server validates the request server-side
/// (in-range, target-still-exists, container-has-room).
/// </para>
/// </summary>
private bool IsPickupableTarget(uint guid)
{
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn))
return false;
// 2026-05-16 — primary discriminator is the BF_STUCK
// ObjectDescriptionFlag (acclient.h:6435, bit 0x4). Retail and
// ACE mark immovable world objects (signs, banners, doors,
// benches) as Stuck server-side. ACE's PutItemInContainer
// handler (Player_Inventory.cs:831-836) responds with
// WeenieError.Stuck (0x29) when the client attempts a pickup
// on an item with the Stuck flag — so the client should gate
// out signs etc. before sending the wire packet.
//
// Discriminates same-ItemType ambiguity that useability can't:
// Holtburg sign (Misc + USEABLE_NO + BF_STUCK) → block
// Spell component (Misc + USEABLE_NO + ~BF_STUCK) → allow
// Door (no SmallItemMask + BF_DOOR + BF_STUCK) → never matches SmallItemMask, separately
if (spawn.ObjectDescriptionFlags is { } odf)
{
if (spawn.Useability is uint useability)
{
const uint USEABLE_REMOTE = 0x20u;
return (useability & USEABLE_REMOTE) != 0u;
}
// Useability null: corpses are pickupable; signs aren't.
if (spawn.ObjectDescriptionFlags is { } odf)
{
const uint BF_CORPSE = 0x2000u;
if ((odf & BF_CORPSE) != 0u)
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[useability-fallback] pickup-corpse guid=0x{guid:X8} (ACE sent no useability bit)"));
return true;
}
}
// Small-item ItemType fallback (covers F on dropped items
// when ACE doesn't publish useability for the weenie).
uint it = spawn.ItemType ?? 0u;
const uint SmallItemMask =
(uint)(AcDream.Core.Items.ItemType.MeleeWeapon
| AcDream.Core.Items.ItemType.Armor
| AcDream.Core.Items.ItemType.Clothing
| AcDream.Core.Items.ItemType.Jewelry
| AcDream.Core.Items.ItemType.Food
| AcDream.Core.Items.ItemType.Money
| AcDream.Core.Items.ItemType.Misc
| AcDream.Core.Items.ItemType.MissileWeapon
| AcDream.Core.Items.ItemType.Container
| AcDream.Core.Items.ItemType.Gem
| AcDream.Core.Items.ItemType.SpellComponents
| AcDream.Core.Items.ItemType.Writable
| AcDream.Core.Items.ItemType.Key
| AcDream.Core.Items.ItemType.Caster);
if ((it & SmallItemMask) != 0u)
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[useability-fallback] pickup-smallitem guid=0x{guid:X8} itemType=0x{it:X8} (ACE sent no useability bit)"));
return true;
}
const uint BF_STUCK = 0x0004u;
const uint BF_CORPSE = 0x2000u;
// Corpses are pickupable (loot) — BF_CORPSE wins over
// any BF_STUCK that might be coincidentally set.
if ((odf & BF_CORPSE) != 0u) return true;
// Anything else with BF_STUCK is immovable scenery.
if ((odf & BF_STUCK) != 0u) return false;
}
return false;
// Small-item ItemType class: dropped weapons, armor, food,
// jewelry, money, misc, gems, spell components, etc.
uint it = spawn.ItemType ?? 0u;
const uint SmallItemMask =
(uint)(AcDream.Core.Items.ItemType.MeleeWeapon
| AcDream.Core.Items.ItemType.Armor
| AcDream.Core.Items.ItemType.Clothing
| AcDream.Core.Items.ItemType.Jewelry
| AcDream.Core.Items.ItemType.Food
| AcDream.Core.Items.ItemType.Money
| AcDream.Core.Items.ItemType.Misc
| AcDream.Core.Items.ItemType.MissileWeapon
| AcDream.Core.Items.ItemType.Container
| AcDream.Core.Items.ItemType.Gem
| AcDream.Core.Items.ItemType.SpellComponents
| AcDream.Core.Items.ItemType.Writable
| AcDream.Core.Items.ItemType.Key
| AcDream.Core.Items.ItemType.Caster);
return (it & SmallItemMask) != 0u;
}
private string DescribeLiveEntity(uint guid)