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

@ -937,6 +937,18 @@ public sealed class GameWindow : IDisposable
// far-range sends fire the wire packet immediately at SendUse/SendPickUp
// time. Cleared before the deferred send fires — single-fire, no retry.
private (uint Guid, bool IsPickup)? _pendingPostArrivalAction;
// R4-V5 (pin P4): the local player's minimal TargetTracker adapter
// state — the exact twin of RemoteMotion's TrackedTarget* fields. The
// player MoveToManager's setTarget/clearTarget seams store the tracked
// guid here; the pre-Update feed in OnUpdateFrame delivers
// HandleUpdateTarget(Ok/ExitWorld) from the live entity table. Full
// TargetManager port is R5 (register row, landed with V4).
private uint _playerMoveToTargetGuid;
private float _playerMoveToTargetRadius;
private System.Numerics.Vector3 _playerMoveToTargetLastFedPos;
private bool _playerMoveToTargetFedOnce;
private double _playerMoveToTargetQuantum;
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
private const double ServerControlledVelocityStaleSeconds = 0.60;
private int _liveSpawnReceived; // diagnostics
@ -4264,7 +4276,14 @@ public sealed class GameWindow : IDisposable
},
clearTarget: () => rmT.TrackedTargetGuid = 0,
getTargetQuantum: () => rmT.TargetQuantum,
setTargetQuantum: q => rmT.TargetQuantum = q);
setTargetQuantum: q => rmT.TargetQuantum = q,
// R4-V5: real clock (same epoch-seconds base the per-remote
// tick uses). V4 left this on the ctor's per-CALL stub, which
// advanced 1/30 s per _curTime() READ — multiple reads inside
// one dispatch skewed the progress/fail-distance clocks
// (CheckProgressMade's 1 s window). The V2 harness contract
// says production always supplies a real clock.
curTime: () => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds);
// TS-36 (remote side): the interp's interrupt seam is retail's
// interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
// chain (V2's reentrancy tests prove the wiring is inert-safe).
@ -4313,6 +4332,116 @@ public sealed class GameWindow : IDisposable
return true;
}
/// <summary>
/// R4-V5: shared retail <c>unpack_movement</c> type-6..9 routing
/// (<c>0x00524440</c> cases 6/7/8/9, decomp §2f) — one body for remotes
/// (R4-V4) and the local player (R4-V5). Writes the wire run rate to
/// the interp (<c>my_run_rate</c>, unpack @300603/@300660 — plan M13),
/// builds the <c>MovementStruct</c> (mt 6/8 resolve the target guid
/// against the entity table; unresolvable degrades to
/// MoveToPosition(wire origin) / TurnToHeading(wire heading) per §2f —
/// NOT an error), and calls <c>PerformMovement</c>. Returns true when
/// the event was a type-6..9 moveto (consumed); false for every other
/// movement type (caller falls through to its funnel / skip posture).
/// </summary>
private bool RouteServerMoveTo(
AcDream.Core.Physics.Motion.MoveToManager mgr,
AcDream.Core.Physics.MotionInterpreter interp,
uint cellId,
AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
{
if (update.MotionState.IsServerControlledMoveTo
&& update.MotionState.MoveToPath is { } path)
{
// my_run_rate write (unpack_movement @300603).
if (update.MotionState.MoveToRunRate is { } mtRunRate)
interp.MyRunRate = mtRunRate;
var destWorld = AcDream.Core.Physics.Motion.MoveToMath
.OriginToWorld(
path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
_liveCenterX, _liveCenterY);
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire(
path.Bitfield,
path.DistanceToObject,
path.MinDistance,
path.FailDistance,
update.MotionState.MoveToSpeed ?? 1f,
path.WalkRunThreshold,
path.DesiredHeading);
var ms = new AcDream.Core.Physics.MovementStruct
{
Params = mp,
};
// mt 6 with a resolvable target → MoveToObject (the P4 tracker
// feeds position updates per tick); else degrade to
// MoveToPosition at the wire origin (§2f).
if (update.MotionState.MovementType == 6
&& path.TargetGuid is { } tgtGuid
&& _entitiesByServerGuid.TryGetValue(tgtGuid, out var tgtEnt))
{
ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
ms.ObjectId = tgtGuid;
ms.TopLevelId = tgtGuid;
ms.Pos = new AcDream.Core.Physics.Position(
cellId, tgtEnt.Position,
System.Numerics.Quaternion.Identity);
}
else
{
ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition;
ms.Pos = new AcDream.Core.Physics.Position(
cellId, destWorld,
System.Numerics.Quaternion.Identity);
}
mgr.PerformMovement(ms);
return true;
}
if (update.MotionState.IsServerControlledTurnTo
&& update.MotionState.TurnToPath is { } turnPath)
{
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo(
turnPath.Bitfield,
turnPath.Speed,
turnPath.DesiredHeading);
var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
if (update.MotionState.MovementType == 8
&& turnPath.TargetGuid is { } turnTgt
&& _entitiesByServerGuid.TryGetValue(turnTgt, out var turnEnt))
{
ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
ms.ObjectId = turnTgt;
ms.TopLevelId = turnTgt;
ms.Pos = new AcDream.Core.Physics.Position(
cellId, turnEnt.Position,
System.Numerics.Quaternion.Identity);
}
else
{
ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading;
// Retail's mt-8 unresolvable-object fallback substitutes the
// STANDALONE wire heading into the params before degrading
// (decomp §2f case 8: `params.desired_heading = wire_heading`).
// Invisible against ACE (P6: both heading fields written from
// the same source) but required for the verbatim degrade —
// closes the V4 carry-over the adversarial review caught
// (TurnToPathData.WireHeading was parsed but never consumed).
if (update.MotionState.MovementType == 8
&& turnPath.WireHeading is { } wireHeading)
{
mp.DesiredHeading = wireHeading;
}
}
mgr.PerformMovement(ms);
return true;
}
return false;
}
/// <summary>
/// Phase 6.6: the server says an entity's motion has changed. Look up
/// the AnimatedEntity for that guid, re-resolve the idle cycle with the
@ -4355,6 +4484,26 @@ public sealed class GameWindow : IDisposable
return;
}
// R4-V5 (pin P1): retail CPhysics::SetObjectMovement's autonomous
// gate (0x00509690 @0050972e, raw 271370-271431) — a movement event
// whose wire autonomous byte is set is DROPPED ENTIRELY (no state
// application, no interrupt) when the addressed object IsThePlayer.
// ACE reflects the client's own outbound MoveToState back to the
// sender with IsAutonomous=1 hardcoded (MovementData.cs:162 +
// Player_Networking.cs:365) and retail never lets that echo reach
// unpack_movement — which is what makes the unconditional
// unpack-head interrupt in the player branch below safe against
// ACE. Order matches retail: the sequence gates above run FIRST.
// last_move_was_autonomous is NOT stored for dropped events (stored
// only on the unpack path). This retires the row-less "don't cancel
// on non-MoveTo UM" adaptation that lived here pre-V5 (its causal
// story was stale — V0-pins.md P1). Run-rate sync is re-anchored to
// retail's own feeds: PlayerDescription skills (SetCharacterSkills,
// K-fix7) + the mt-6/7 my_run_rate wire write below (M13) — the
// former ApplyServerRunRate echo tap is deleted, not gated.
if (update.Guid == _playerServerGuid && update.IsAutonomous)
return;
// Re-resolve using the new stance/command. Keep the setup and
// motion-table we already know about — the server's motion
// updates override state within the same table, not swap tables.
@ -4414,16 +4563,6 @@ public sealed class GameWindow : IDisposable
$"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}"));
}
// Wire server-echoed RunRate first — used for the player's own
// locomotion tuning regardless of whether a cycle resolves.
if (_playerController is not null
&& update.Guid == _playerServerGuid
&& update.MotionState.ForwardSpeed.HasValue
&& update.MotionState.ForwardSpeed.Value > 0f)
{
_playerController.ApplyServerRunRate(update.MotionState.ForwardSpeed.Value);
}
// ── Sequencer path (preferred) ──────────────────────────────────
// Call SetCycle directly. The sequencer already handles:
// - left→right / backward→forward remapping via adjust_motion
@ -4518,9 +4657,8 @@ public sealed class GameWindow : IDisposable
// the paths above, but don't stomp the animation sequencer.
// B.6 slice 1 (2026-05-14): trace inbound motion for the
// local player so we can characterize what ACE sends during
// a server-initiated auto-walk. One line per inbound UM,
// gated on ACDREAM_PROBE_AUTOWALK=1.
// local player. One line per inbound UM, gated on
// ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5).
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
string cmdHex = command.HasValue ? $"0x{command.Value:X4}" : "null";
@ -4540,52 +4678,32 @@ public sealed class GameWindow : IDisposable
$"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}"));
}
// B.6 slice 2 (2026-05-14): drive the local player's body
// through a server-initiated auto-walk when ACE sends
// MoveToObject (movement type 6) — retail-faithful per
// MovementManager::PerformMovement 0x00524440 case 6. When
// the inbound motion is NOT a MoveTo, cancel any active
// auto-walk (server intent changed).
// R4-V5: retail unpack_movement dispatch for the local
// player — the SAME shape the remote branch uses below.
// Head (@300566): interrupt + unstick fire for EVERY
// movement event that reached unpack (the P1 gate above
// already dropped the autonomous echoes that would have
// made this unsafe against ACE); then types 6-9 route to
// the player's MoveToManager. mt-0 falls through to the
// existing skip-sequencer posture (the interpreted-state
// copy + LoseControlToServer autonomy handoff is
// R5/MovementManager scope — V0-pins.md P1 adjacent seam).
if (_playerController is not null)
{
if (update.MotionState.IsServerControlledMoveTo
&& update.MotionState.MoveToPath is { } pathData)
_playerController.Motion.InterruptCurrentMovement?.Invoke();
_playerController.Motion.UnstickFromObject?.Invoke();
if (_playerController.MoveTo is { } playerMoveTo
&& RouteServerMoveTo(playerMoveTo, _playerController.Motion,
_playerController.CellId, update))
{
// Translate landblock-local origin → world space.
var destWorld = AcDream.Core.Physics.Motion.MoveToMath
.OriginToWorld(
pathData.OriginCellId,
pathData.OriginX,
pathData.OriginY,
pathData.OriginZ,
_liveCenterX,
_liveCenterY);
bool canCharge = update.MotionState.CanCharge;
_playerController.BeginServerAutoWalk(
destWorld,
pathData.MinDistance,
pathData.DistanceToObject,
update.MotionState.MoveTowards,
canCharge);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
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} canCharge={canCharge} towards={update.MotionState.MoveTowards}"));
$"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={playerMoveTo.IsMovingTo()} type={playerMoveTo.MovementTypeState}"));
}
return;
}
// Note: do NOT cancel auto-walk on a non-MoveTo motion
// arriving. The trace (2026-05-14, launch-slice2.log)
// shows ACE follows every mt=0x06 MoveToObject
// immediately with an mt=0x00 InterpretedMotionState
// (cmd=0x0007 RunForward, fwdSpd=2.86) — the
// companion locomotion echo, NOT a cancel. The two
// travel as separate packets but both belong to the
// same auto-walk. Cancelling on the InterpretedMotionState
// killed the auto-walk on frame 1. Arrival detection
// (inside ApplyAutoWalkOverlay) and user-input
// cancellation (same) are the two natural end paths;
// a fresh MoveToObject re-targets via BeginServerAutoWalk
// overwrite.
}
}
else
@ -4618,82 +4736,13 @@ public sealed class GameWindow : IDisposable
remoteMot.Motion.InterruptCurrentMovement?.Invoke();
remoteMot.Motion.UnstickFromObject?.Invoke();
if (update.MotionState.IsServerControlledMoveTo
&& update.MotionState.MoveToPath is { } path
&& remoteMot.MoveTo is { } moveMgr)
// R4-V5: the type-6..9 routing body is shared with the
// local player (RouteServerMoveTo) — behavior identical
// to the R4-V4 inline blocks it was extracted from.
if (remoteMot.MoveTo is { } moveMgr
&& RouteServerMoveTo(moveMgr, remoteMot.Motion,
remoteMot.CellId, update))
{
// my_run_rate write (unpack_movement @300603).
if (update.MotionState.MoveToRunRate is { } mtRunRate)
remoteMot.Motion.MyRunRate = mtRunRate;
var destWorld = AcDream.Core.Physics.Motion.MoveToMath
.OriginToWorld(
path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
_liveCenterX, _liveCenterY);
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire(
path.Bitfield,
path.DistanceToObject,
path.MinDistance,
path.FailDistance,
update.MotionState.MoveToSpeed ?? 1f,
path.WalkRunThreshold,
path.DesiredHeading);
var ms = new AcDream.Core.Physics.MovementStruct
{
Params = mp,
};
// mt 6 with a resolvable target → MoveToObject (the
// P4 tracker feeds position updates per tick); else
// degrade to MoveToPosition at the wire origin (§2f).
if (update.MotionState.MovementType == 6
&& path.TargetGuid is { } tgtGuid
&& _entitiesByServerGuid.TryGetValue(tgtGuid, out var tgtEnt))
{
ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
ms.ObjectId = tgtGuid;
ms.TopLevelId = tgtGuid;
ms.Pos = new AcDream.Core.Physics.Position(
remoteMot.CellId, tgtEnt.Position,
System.Numerics.Quaternion.Identity);
}
else
{
ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition;
ms.Pos = new AcDream.Core.Physics.Position(
remoteMot.CellId, destWorld,
System.Numerics.Quaternion.Identity);
}
moveMgr.PerformMovement(ms);
return;
}
if (update.MotionState.IsServerControlledTurnTo
&& update.MotionState.TurnToPath is { } turnPath
&& remoteMot.MoveTo is { } turnMgr)
{
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo(
turnPath.Bitfield,
turnPath.Speed,
turnPath.DesiredHeading);
var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
if (update.MotionState.MovementType == 8
&& turnPath.TargetGuid is { } turnTgt
&& _entitiesByServerGuid.TryGetValue(turnTgt, out var turnEnt))
{
ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
ms.ObjectId = turnTgt;
ms.TopLevelId = turnTgt;
ms.Pos = new AcDream.Core.Physics.Position(
remoteMot.CellId, turnEnt.Position,
System.Numerics.Quaternion.Identity);
}
else
{
ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading;
}
turnMgr.PerformMovement(ms);
return;
}
@ -7841,6 +7890,44 @@ public sealed class GameWindow : IDisposable
? localEnt.Id
: 0u;
// R4-V5 (pin P4): feed the player MoveToManager's target tracker
// BEFORE Update ticks UseTime — same feed-then-tick order the
// per-remote block uses. One immediate delivery after set_target,
// then re-delivery when the target moved beyond the voyeur
// radius; despawn delivers ExitWorld (the manager cancels
// 0x37/0x38 itself).
if (_playerController.MoveTo is { } playerMtm
&& _playerMoveToTargetGuid != 0)
{
if (_entitiesByServerGuid.TryGetValue(_playerMoveToTargetGuid, out var trackedEnt))
{
var tpos = trackedEnt.Position;
if (!_playerMoveToTargetFedOnce
|| System.Numerics.Vector3.Distance(tpos, _playerMoveToTargetLastFedPos)
> _playerMoveToTargetRadius)
{
_playerMoveToTargetFedOnce = true;
_playerMoveToTargetLastFedPos = tpos;
var tp = new AcDream.Core.Physics.Position(
_playerController.CellId, tpos, System.Numerics.Quaternion.Identity);
playerMtm.HandleUpdateTarget(new AcDream.Core.Physics.Motion.TargetInfo(
_playerMoveToTargetGuid,
AcDream.Core.Physics.Motion.TargetStatus.Ok,
tp, tp));
}
}
else
{
var lp = new AcDream.Core.Physics.Position(
_playerController.CellId, _playerMoveToTargetLastFedPos,
System.Numerics.Quaternion.Identity);
playerMtm.HandleUpdateTarget(new AcDream.Core.Physics.Motion.TargetInfo(
_playerMoveToTargetGuid,
AcDream.Core.Physics.Motion.TargetStatus.ExitWorld,
lp, lp));
}
}
var result = _playerController.Update((float)dt, input);
// Update the player entity's position + rotation so it renders at
@ -7952,23 +8039,20 @@ public sealed class GameWindow : IDisposable
var wireRot = YawToAcQuaternion(_playerController.Yaw);
byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
// 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)
// 2026-05-16 (issue #75) / R4-V5: user-MoveToState packets
// are ONLY for user-initiated motion intent — retail's
// architectural split between user-input motion and
// server-driven motion. Post-V5 the split holds BY
// CONSTRUCTION: MotionStateChanged derives exclusively from
// input edges (the MoveToManager's dispatches never touch
// the controller's edge detector), so a manager-driven
// moveto produces no outbound MoveToState; the moment the
// user presses a key, the edge's CancelMoveTo chain kills
// the moveto and THAT state change legitimately goes on
// the wire ("user took control" — which is now true). The
// former !IsServerAutoWalking guard is redundant and gone
// with B.6.
if (result.MotionStateChanged)
{
// HoldKey axis values — retail enum (acclient.h enum
// HoldKey): Invalid = 0, None = 1, Run = 2.
@ -9874,6 +9958,14 @@ public sealed class GameWindow : IDisposable
rm.Body.Velocity = new System.Numerics.Vector3(
rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f);
rm.Motion.HitGround();
// R4-V5 (closes the V4 wiring-contract gap the
// adversarial review caught): retail order —
// minterp first, then moveto (MovementManager::
// HitGround 0x00524300, §2d). Re-arms a moveto
// suspended by the airborne UseTime contact gate;
// without it a chasing NPC that lands stalls
// until ACE's ~1 Hz re-emit.
rm.MoveTo?.HitGround();
// K-fix17 (2026-04-26): reset the sequencer cycle
// from Falling back to whatever the interpreted
@ -11963,15 +12055,16 @@ public sealed class GameWindow : IDisposable
return;
}
// B.6 (2026-05-15): install speculative local auto-walk against
// the target so close-range Use rotates the body to face before
// the action fires. For FAR targets, ACE's CreateMoveToChain
// (Player_Move.cs:37-179) takes over via inbound MovementType=6
// and our overlay is overwritten by ACE's wire-supplied radius.
// B.6/R4-V5: install a speculative local TurnToObject/MoveToObject
// through the player's MoveToManager so close-range Use rotates the
// body to face before the action fires. For FAR targets, ACE's
// CreateMoveToChain (Player_Move.cs:37-179) takes over via inbound
// MovementType=6, whose PerformMovement re-targets with ACE's
// wire-supplied radius.
//
// 2026-05-16: simplified — close-range deferral now fires the
// wire packet ONCE on AutoWalkArrived (turn-first done), not a
// retry of an earlier failed send. No re-send path.
// Close-range deferral fires the wire packet ONCE on
// MoveToComplete(None) (turn-first done), not a retry of an
// earlier failed send. No re-send path.
bool closeRange = IsCloseRangeTarget(guid);
InstallSpeculativeTurnToTarget(guid);
@ -12088,13 +12181,14 @@ public sealed class GameWindow : IDisposable
}
/// <summary>
/// 2026-05-16. Fires the deferred close-range Use/PickUp action
/// once the local auto-walk overlay reports arrival (i.e. the body
/// has finished rotating to face the target). Unlike the old
/// <c>OnAutoWalkArrivedReSendAction</c>, this is a FIRST send — not a
/// retry of an earlier failed send. Far-range Use/PickUp paths
/// fire the wire packet immediately at <see cref="SendUse"/>/<see cref="SendPickUp"/> time
/// and never touch <c>_pendingPostArrivalAction</c>.
/// 2026-05-16 / R4-V5. Fires the deferred close-range Use/PickUp action
/// once the player's moveto completes naturally (the MoveToManager's
/// <c>MoveToComplete(None)</c> client seam — the body has finished
/// rotating to face / walking to the target; a user-input cancel never
/// fires it). This is a FIRST send — not a retry of an earlier failed
/// send. Far-range Use/PickUp paths fire the wire packet immediately at
/// <see cref="SendUse"/>/<see cref="SendPickUp"/> time and never touch
/// <c>_pendingPostArrivalAction</c>.
/// </summary>
private void OnAutoWalkArrivedSendDeferredAction()
{
@ -12179,12 +12273,14 @@ public sealed class GameWindow : IDisposable
private void InstallSpeculativeTurnToTarget(uint targetGuid)
{
if (_playerController is null) return;
if (_playerController?.MoveTo is not { } playerMoveTo) return;
if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var entity))
return;
// Per-type use radius — same heuristic as the picker's
// radiusForGuid callback.
// radiusForGuid callback (register AP-23, re-anchored here by
// R4-V5; survives because ACE's close-branch broadcasts nothing
// actionable).
float useRadius = 0.6f;
if ((LiveItemType(targetGuid) & AcDream.Core.Items.ItemType.Creature) != 0)
{
@ -12199,13 +12295,14 @@ public sealed class GameWindow : IDisposable
}
// Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit
// from local distance so the speculative auto-walk uses the
// from local distance so the speculative moveto 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.
// MoveToObject broadcast overwrites it (PerformMovement
// cancels + restarts — retail-consistent re-target).
const float AceCanChargeDistance = 7.5f;
var bodyPos = _playerController.Position;
float ddx = entity.Position.X - bodyPos.X;
@ -12213,12 +12310,33 @@ public sealed class GameWindow : IDisposable
float distToTarget = MathF.Sqrt(ddx * ddx + ddy * ddy);
bool speculativeCanCharge = distToTarget >= AceCanChargeDistance;
_playerController.BeginServerAutoWalk(
destinationWorld: entity.Position,
minDistance: 0f,
distanceToObject: useRadius,
moveTowards: true,
canCharge: speculativeCanCharge);
// R4-V5: retail's client-initiated use flow issues TurnToObject /
// MoveToObject through the SAME manager the wire path uses (decomp
// §9a/§9b callers) — in-range targets get a pure turn-to-face;
// out-of-range targets get the local moveto that ACE's mt-6
// broadcast will re-target moments later. MovementParameters ctor
// defaults (0x1EE0F: MoveTowards, UseSpheres, threshold 15,
// MinDistance 0) match the old BeginServerAutoWalk install's
// semantics; only the AP-23 radius + the #77 CanCharge prediction
// are non-default.
var p = new AcDream.Core.Physics.Motion.MovementParameters
{
DistanceToObject = useRadius,
CanCharge = speculativeCanCharge,
};
var ms = new AcDream.Core.Physics.MovementStruct
{
ObjectId = targetGuid,
TopLevelId = targetGuid,
Pos = new AcDream.Core.Physics.Position(
_playerController.CellId, entity.Position,
System.Numerics.Quaternion.Identity),
Params = p,
Type = IsCloseRangeTarget(targetGuid)
? AcDream.Core.Physics.MovementType.TurnToObject
: AcDream.Core.Physics.MovementType.MoveToObject,
};
playerMoveTo.PerformMovement(ms);
}
private uint? SelectClosestCombatTarget(bool showToast)
@ -12764,10 +12882,82 @@ public sealed class GameWindow : IDisposable
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
// B.6/B.7 (2026-05-16): fire the deferred close-range Use/PickUp
// action (first send, not a retry) when the local auto-walk overlay
// reports arrival (body finished rotating to face the target).
_playerController.AutoWalkArrived += OnAutoWalkArrivedSendDeferredAction;
// R4-V5: the local player's verbatim MoveToManager — same seam
// wiring shape as EnsureRemoteMotionBindings, with three
// player-specific differences: (a) heading reads/writes go through
// the controller's Yaw (the authoritative facing the body
// quaternion is re-derived from every Update; a quaternion-only
// set_heading would be overwritten next frame) via the P5-pinned
// yaw↔heading bridge; (b) the contact seam reads the REAL Contact
// transient bit (retail UseTime gates transient_state & 1 —
// remotes force-assert Contact+OnWalkable every grounded tick, so
// OnWalkable was equivalent there); (c) isInterpolating is false —
// the local player has no InterpolationManager. Own radius/height
// stay 0 for parity with the V4 remote bind (P4 note: setup
// cylsphere lands with R5's TargetManager port). setHeading's
// `send` flag is currently UNCONSUMED (register TS-33): the AP
// heartbeat diffs position/plane/cell but not orientation (retail's
// Frame::is_equal compares the full frame), so a stationary heading
// snap doesn't reach the wire — masked against ACE, which rotates
// server-side on its own turn paths; the full-frame diff lands with
// the R7 outbound-cadence port.
var pcMoveTo = _playerController;
var playerMoveTo = new AcDream.Core.Physics.Motion.MoveToManager(
pcMoveTo.Motion,
stopCompletely: () => pcMoveTo.Motion.StopCompletely(),
getPosition: () => new AcDream.Core.Physics.Position(
pcMoveTo.CellId, pcMoveTo.Position, pcMoveTo.BodyOrientation),
getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(pcMoveTo.Yaw),
setHeading: (h, _) => pcMoveTo.Yaw =
AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(h),
getOwnRadius: () => 0f,
getOwnHeight: () => 0f,
contact: () => pcMoveTo.BodyInContact,
isInterpolating: () => false,
getVelocity: () => pcMoveTo.BodyVelocity,
getSelfId: () => _playerServerGuid,
setTarget: (_, tlid, radius, _) =>
{
_playerMoveToTargetGuid = tlid;
_playerMoveToTargetRadius = radius;
_playerMoveToTargetFedOnce = false;
},
clearTarget: () => _playerMoveToTargetGuid = 0,
getTargetQuantum: () => _playerMoveToTargetQuantum,
setTargetQuantum: q => _playerMoveToTargetQuantum = q,
curTime: () => pcMoveTo.SimTimeSeconds);
_playerMoveToTargetGuid = 0;
_playerMoveToTargetFedOnce = false;
_playerMoveToTargetQuantum = 0.0;
// AD-27 re-anchored (was the deleted AutoWalkArrived event): fire
// the deferred close-range Use/PickUp action when the moveto
// completes NATURALLY (MoveToComplete is the documented client
// seam; it never fires on CancelMoveTo, so a user-input cancel
// doesn't send the action — same contract the old "arrived"-only
// event had).
playerMoveTo.MoveToComplete = err =>
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[autowalk-end] reason=complete err={err}");
if (err == AcDream.Core.Physics.WeenieError.None)
OnAutoWalkArrivedSendDeferredAction();
};
_playerController.MoveTo = playerMoveTo;
// TS-36 RETIRED: the interp's interrupt seam is retail's
// interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
// chain (raw 278189-278200). Every DoMotion/StopMotion/
// StopCompletely/jump/set_hold_run cancel site now genuinely
// cancels a running moveto (V2's reentrancy tests prove the chain
// is inert-safe).
_playerController.Motion.InterruptCurrentMovement = () =>
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled
&& playerMoveTo.IsMovingTo())
Console.WriteLine("[autowalk-end] reason=interrupt");
playerMoveTo.CancelMoveTo(AcDream.Core.Physics.WeenieError.ActionCancelled);
};
// K-fix7 (2026-04-26): if PlayerDescription already arrived, the
// server's Run / Jump skill values are cached here — push them