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

@ -153,12 +153,17 @@ public sealed class MoveToManager
/// <summary>
/// CLIENT ADDITION — NOT retail (decomp §7e / do-not-invent list):
/// <c>CleanUpAndCallWeenie</c> contains no weenie call in this build
/// (the name is vestigial; body ≡ CleanUp + StopCompletely). This seam
/// stands in for ACE's server-side <c>OnMoveComplete</c> notification so
/// the App layer can re-anchor AD-27 (Use/PickUp re-send on arrival).
/// Fires from <see cref="CleanUpAndCallWeenie"/> ONLY (never from plain
/// <see cref="CleanUp"/>/<see cref="CancelMoveTo"/>). Optional — null is
/// a silent no-op.
/// (the name is vestigial; body ≡ CleanUp + StopCompletely), and retail
/// notifies NOTHING on arrival (§4b's empty-queue completion is inline
/// CleanUp + StopCompletely). This seam stands in for ACE's server-side
/// <c>OnMoveComplete</c> notification so the App layer can re-anchor
/// AD-27 (Use/PickUp re-send on arrival). Fires with
/// <see cref="WeenieError.None"/> on NATURAL COMPLETION — the
/// <see cref="BeginNextNode"/> empty-queue exits (both sticky and
/// non-sticky) and <see cref="CleanUpAndCallWeenie"/>'s instant-success
/// path — and NEVER from <see cref="CancelMoveTo"/>/plain
/// <see cref="CleanUp"/> (a cancel is not an arrival; AD-27's re-send
/// must not fire on user interrupt). Optional — null is a silent no-op.
/// </summary>
public Action<WeenieError>? MoveToComplete { get; set; }
@ -701,11 +706,17 @@ public sealed class MoveToManager
if (HasPhysicsObj) _stopCompletely();
StickTo?.Invoke(tlid, radius, height);
// CLIENT ADDITION (see MoveToComplete doc): natural completion.
// Reentrancy-safe: CleanUp reset movement_type to Invalid BEFORE
// the stop, so the stop's interrupt→CancelMoveTo chain no-oped.
MoveToComplete?.Invoke(WeenieError.None);
return;
}
CleanUp();
if (HasPhysicsObj) _stopCompletely();
// CLIENT ADDITION (see MoveToComplete doc): natural completion.
MoveToComplete?.Invoke(WeenieError.None);
}
/// <summary>
@ -1497,9 +1508,10 @@ public sealed class MoveToManager
/// raw 306740-306752). Despite the name, contains NO weenie call in this
/// build (decomp §7e — the compiled-out server-side callback; body ≡
/// <see cref="CleanUp"/> + StopCompletely). <see cref="MoveToComplete"/>
/// is a documented CLIENT ADDITION firing HERE ONLY (see its doc)
/// standing in for ACE's server-side <c>OnMoveComplete</c> — do NOT
/// present it as retail behavior.
/// is a documented CLIENT ADDITION (see its doc) firing here and at
/// <see cref="BeginNextNode"/>'s empty-queue completion, standing in for
/// ACE's server-side <c>OnMoveComplete</c> — do NOT present it as retail
/// behavior.
/// </summary>
public void CleanUpAndCallWeenie(WeenieError error)
{

View file

@ -165,6 +165,37 @@ public static class MoveToMath
return Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yaw - MathF.PI / 2f);
}
/// <summary>
/// R4-V5: the scalar leg of <see cref="GetHeading"/> for bodies whose
/// authoritative facing is a yaw ANGLE rather than a quaternion (the
/// local player: <c>PlayerMovementController.Yaw</c>, radians, Yaw=0
/// faces +X, re-synced into the body quaternion every Update). Same P5
/// bridge: <c>heading = (90 - yawDeg) mod 360</c>.
/// </summary>
public static float HeadingFromYaw(float yawRad)
{
float headingDeg = 90f - yawRad * (180f / MathF.PI);
headingDeg %= 360f;
if (headingDeg < 0f) headingDeg += 360f;
return headingDeg;
}
/// <summary>
/// R4-V5: exact inverse of <see cref="HeadingFromYaw"/> — the
/// <c>set_heading</c> seam for yaw-authoritative bodies (the local
/// player's heading snap must write <c>Yaw</c>, NOT the body
/// quaternion, which the controller re-derives from Yaw every frame).
/// Returns radians wrapped to [-π, π] matching the controller's own
/// wrap discipline.
/// </summary>
public static float YawFromHeading(float headingDeg)
{
float yaw = (90f - headingDeg) * (MathF.PI / 180f);
while (yaw > MathF.PI) yaw -= 2f * MathF.PI;
while (yaw < -MathF.PI) yaw += 2f * MathF.PI;
return yaw;
}
/// <summary>
/// Retail <c>Position::cylinder_distance</c>, the pure-math shape
/// consumed by <c>MoveToManager::GetCurrentDistance</c>