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:
parent
e4553a0262
commit
b3decdfac6
9 changed files with 947 additions and 688 deletions
|
|
@ -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 & 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue