feat(R3-W6): LOCAL PLAYER UNIFICATION — edge-driven retail input; UpdatePlayerAnimation + the synthesis layer DELETED (closes J15)

The local player now drives the SAME pipeline remotes use. The
controller's per-frame RawMotionState rebuild (level-triggered D6.2) is
replaced by retail's CommandInterpreter altitude: key EDGES fire
DoMotion/StopMotion (0x00528d20/0x00528530) and the Shift edge fires
set_hold_run (0x00528b70, caller-0x006b33ca shape) — the interpreter's
own RawState mutates via ApplyMotion/RemoveMotion and every dispatch
routes the funnel + DefaultSink into the sequencer. GameWindow's
UpdatePlayerAnimation (169 lines) is DELETED with both
LocalAnimationCommand/LocalAnimationSpeed MovementResult fields and all
three synthesis sites (K-fix5 pacing, Walk→Run cycle selection, the
auto-walk overrides): run pacing now comes from apply_run_to_command's
my_run_rate promotion — the same source remotes use; airborne-Falling
falls out of contact_allows_move (the funnel's second dispatch).
Auto-walk's #69 turn-phase legs re-expressed as DoMotion(Turn*) edges;
teleport idle = StopCompletely (retail's full stop) + an edge-tracker
reset so held keys walk straight out of a portal.

Map discovery R1 fixed: ChargeJump() now fires at charge START (the
0x0056afac input-boundary site) — StandingLongJump had NEVER armed in
production despite the W3 port.

TWO root-cause fixes the cutover surfaced (each would have been
invisible under the old synthesis layer):
- CurrentHoldKey was a shadow FIELD synced only on the raw dispatch
  branch; set_hold_run writes RawState.CurrentHoldKey, so an edge-driven
  Shift toggle followed by DoMotion read a stale None and lost the run
  promotion. Now an alias of RawState.CurrentHoldKey (retail's
  adjust_motion reads raw_state.current_holdkey directly).
- The controller's grounded velocity + jump-launch writes used
  set_local_velocity's default autonomous:false, silently clearing
  LastMoveWasAutonomous and flipping the A3 dual dispatch to the
  interpreted branch for the local player. Both marked autonomous.

EXPECTED-DIFFS (map §6, for the R3 visual pass): #45's 1.248x sidestep
factor + ACDREAM_ANIM_SPEED_SCALE died with UpdatePlayerAnimation —
local sidestep pacing now matches how remotes have always played;
auto-walk-at-run plays walk-pace legs until R4; ApplyServerRunRate's
apply_current_movement goes live (Branch-2 fast re-speed absorbs the
echo — retail's own mid-run speed-change mechanism).

Wire: outbound values stay input-derived (the L.2b-verified byte
stream untouched; RawState-sourcing needs the map §2b cdb TODO and R7
owns outbound). MotionStateChanged = the old comparison minus the dead
localAnimCmd leg, OR any edge fired.

Full suite green: 3,731 passed. Live smoke: in-world, user-driven
input through the new path (walk/turn/strafe), 12k entities, zero
exceptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 09:19:56 +02:00
parent fc5a2cda28
commit fb7beb706b
4 changed files with 177 additions and 287 deletions

View file

@ -52,14 +52,12 @@ public readonly record struct MovementResult(
float? SidestepSpeed, float? SidestepSpeed,
float? TurnSpeed, float? TurnSpeed,
bool IsRunning = false, bool IsRunning = false,
uint? LocalAnimationCommand = null, // which cycle to play on the local player (RunForward when running)
// K-fix5 (2026-04-26): cycle-pace multiplier for the LOCAL animation // K-fix5 (2026-04-26): cycle-pace multiplier for the LOCAL animation
// sequencer. Decoupled from ForwardSpeed so the wire can keep sending // sequencer. Decoupled from ForwardSpeed so the wire can keep sending
// 1.0 for WalkBackward (ACE-compatible) while the animation plays at // 1.0 for WalkBackward (ACE-compatible) while the animation plays at
// runRate × so the cycle visually matches the run-speed velocity. // runRate × so the cycle visually matches the run-speed velocity.
// Forward+Run = runRate (same as ForwardSpeed); Backward+Run, Strafe+Run // Forward+Run = runRate (same as ForwardSpeed); Backward+Run, Strafe+Run
// = runRate (where ForwardSpeed is 1.0 / null); everything else = 1.0. // = runRate (where ForwardSpeed is 1.0 / null); everything else = 1.0.
float LocalAnimationSpeed = 1f,
bool JustLanded = false, // true on the single frame we transitioned airborne → grounded bool JustLanded = false, // true on the single frame we transitioned airborne → grounded
float? JumpExtent = null, // non-null when a jump was triggered this frame float? JumpExtent = null, // non-null when a jump was triggered this frame
Vector3? JumpVelocity = null); // BODY-LOCAL launch velocity (forward/right/up relative to facing) — see PlayerMovementController jump path for the inverse-yaw conversion. Server rotates body→world on broadcast. Vector3? JumpVelocity = null); // BODY-LOCAL launch velocity (forward/right/up relative to facing) — see PlayerMovementController jump path for the inverse-yaw conversion. Server rotates body→world on broadcast.
@ -182,13 +180,25 @@ public sealed class PlayerMovementController
// to drive the landing animation cycle. // to drive the landing animation cycle.
private bool _wasAirborneLastFrame; private bool _wasAirborneLastFrame;
// Previous frame's motion commands for change detection. // Previous frame's motion commands for change detection (wire cadence).
private uint? _prevForwardCmd; private uint? _prevForwardCmd;
private uint? _prevSidestepCmd; private uint? _prevSidestepCmd;
private uint? _prevTurnCmd; private uint? _prevTurnCmd;
private float? _prevForwardSpeed; private float? _prevForwardSpeed;
private bool _prevRunHold; private bool _prevRunHold;
private uint? _prevLocalAnimCmd;
// R3-W6: previous frame's HELD-KEY state — the edge detector feeding
// retail's DoMotion/StopMotion/set_hold_run calls (retail's
// CommandInterpreter altitude: motion dispatch happens on key EDGES,
// never level-triggered per-frame).
private bool _prevForwardHeld;
private bool _prevBackwardHeld;
private bool _prevStrafeLeftHeld;
private bool _prevStrafeRightHeld;
private bool _prevTurnLeftHeld;
private bool _prevTurnRightHeld;
private bool _prevRunHeld;
private int _prevAutoWalkTurnDir;
// Heartbeat timer. // Heartbeat timer.
// Cadence is 1.0 sec to match holtburger's // Cadence is 1.0 sec to match holtburger's
@ -302,7 +312,7 @@ public sealed class PlayerMovementController
// turn-first phase (rotating in place toward target) and after // turn-first phase (rotating in place toward target) and after
// arrival. Drives the animation cycle override: walking animation // arrival. Drives the animation cycle override: walking animation
// only plays when the body is actually moving forward. // only plays when the body is actually moving forward.
private bool _autoWalkMovingForwardThisFrame; // 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. // 2026-05-16 (issue #69 fix) — turn direction this frame.
// +1 = rotating counter-clockwise (Yaw increasing) → TurnLeft cycle // +1 = rotating counter-clockwise (Yaw increasing) → TurnLeft cycle
@ -556,7 +566,6 @@ public sealed class PlayerMovementController
/// </summary> /// </summary>
private bool DriveServerAutoWalk(float dt, MovementInput input) private bool DriveServerAutoWalk(float dt, MovementInput input)
{ {
_autoWalkMovingForwardThisFrame = false;
_autoWalkTurnDirectionThisFrame = 0; _autoWalkTurnDirectionThisFrame = 0;
if (!_autoWalkActive) return false; if (!_autoWalkActive) return false;
@ -729,6 +738,8 @@ public sealed class PlayerMovementController
// The motion-interpreter state also has to step out of // The motion-interpreter state also has to step out of
// WalkForward so get_state_velocity (used downstream) reports // WalkForward so get_state_velocity (used downstream) reports
// standing-velocity, not the prior frame's run-speed. // 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); _motion.DoMotion(MotionCommand.Ready, 1.0f);
if (_body.OnWalkable) if (_body.OnWalkable)
{ {
@ -759,7 +770,6 @@ public sealed class PlayerMovementController
forwardCmdSpeed = 1.0f; forwardCmdSpeed = 1.0f;
} }
_autoWalkMovingForwardThisFrame = true;
// Update interpreted motion state — drives the animation cycle // Update interpreted motion state — drives the animation cycle
// via UpdatePlayerAnimation downstream + the MotionInterpreter's // via UpdatePlayerAnimation downstream + the MotionInterpreter's
@ -836,7 +846,22 @@ public sealed class PlayerMovementController
// reconstruct that run vector via get_state_velocity and the player would sprint // reconstruct that run vector via get_state_velocity and the player would sprint
// off in the old direction the instant input resumes. Resetting the forward // off in the old direction the instant input resumes. Resetting the forward
// command to Ready makes the player arrive at rest. // command to Ready makes the player arrive at rest.
_motion.DoMotion(MotionCommand.Ready, 1.0f); // R3-W6: retail's teleport idle is a FULL stop (StopCompletely
// 0x00527e40: resets fwd/sidestep/turn COMMANDS, zeroes velocity,
// enqueues the A9 jump-snapshot node) — not a bare DoMotion(Ready).
_motion.StopCompletely();
// Reset the edge tracker: the stop wiped the motion state, so keys
// still physically held must re-fire as press edges on the next
// Update (matches the pre-W6 level-triggered behavior of walking
// straight out of a teleport while W stays held).
_prevForwardHeld = false;
_prevBackwardHeld = false;
_prevStrafeLeftHeld = false;
_prevStrafeRightHeld = false;
_prevTurnLeftHeld = false;
_prevTurnRightHeld = false;
_prevRunHeld = false;
_prevAutoWalkTurnDir = 0;
// Reset physics clock so any subsequent update_object calls start fresh. // Reset physics clock so any subsequent update_object calls start fresh.
_body.LastUpdateTime = 0.0; _body.LastUpdateTime = 0.0;
@ -889,40 +914,80 @@ public sealed class PlayerMovementController
TurnSpeed: null); TurnSpeed: null);
} }
// ── D6.2: normalize input into the interpreted motion state ─────────── // ── R3-W6: EDGE-DRIVEN retail input (replaces the D6.2 per-frame
// ONE RawMotionState from MovementInput drives the local velocity + turn // RawMotionState rebuild — the level-triggered substitute for
// (retail CMotionInterp::apply_raw_movement 0x005287e0). RAW speeds are // retail's edge-triggered CommandInterpreter). Each key EDGE fires
// 1.0 — apply_run_to_command applies the run rate, so passing a pre-scaled // DoMotion/StopMotion (0x00528d20/0x00528530) which mutate the
// speed would double-scale. adjust_motion normalizes backward → // interpreter's OWN RawState via ApplyMotion/RemoveMotion and
// WalkForward (×-0.65) and strafe-left → SideStepRight (×-1×1.248), so // dispatch through the funnel + DefaultSink — the SAME pipeline
// get_state_velocity below is correct for ALL directions (retires the // remotes use. The Shift edge is retail's set_hold_run
// hand-mirrored formulas; register TS-22). Skipped during server // (0x00528b70, caller 0x006b33ca shape: interrupt=true). RAW speeds
// auto-walk, which drives the body itself. // 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).
bool motionEdgeFired = false;
if (!autoWalkConsumedMotion) if (!autoWalkConsumedMotion)
{ {
var axisHold = input.Run ? HoldKey.Run : HoldKey.None; var p = new AcDream.Core.Physics.Motion.MovementParameters();
var raw = new RawMotionState
// Shift/run edge FIRST — retail's set_hold_run re-applies
// movement, so the hold-key state is current before any
// same-frame directional dispatch.
if (input.Run != _prevRunHeld)
{ {
CurrentHoldKey = axisHold, _motion.set_hold_run(input.Run, interrupt: true);
ForwardCommand = input.Forward ? MotionCommand.WalkForward motionEdgeFired = true;
: input.Backward ? MotionCommand.WalkBackward }
: MotionCommand.Ready,
ForwardHoldKey = (input.Forward || input.Backward) ? axisHold : HoldKey.Invalid, // Forward channel (W / S share one raw channel — retail
ForwardSpeed = 1.0f, // RawMotionState.ApplyMotion's forward-class switch). W wins on
SidestepCommand = input.StrafeRight ? MotionCommand.SideStepRight // a same-frame double-press; releasing one key while the
: input.StrafeLeft ? MotionCommand.SideStepLeft // opposite is still held re-issues the survivor (equivalent to
: 0u, // the old level-triggered build, which always reflected the
SidestepHoldKey = (input.StrafeRight || input.StrafeLeft) ? axisHold : HoldKey.Invalid, // currently-held set).
SidestepSpeed = 1.0f, if (input.Forward && !_prevForwardHeld)
TurnCommand = input.TurnRight ? MotionCommand.TurnRight { _motion.DoMotion(MotionCommand.WalkForward, p); motionEdgeFired = true; }
: input.TurnLeft ? MotionCommand.TurnLeft else if (input.Backward && !_prevBackwardHeld && !input.Forward)
: 0u, { _motion.DoMotion(MotionCommand.WalkBackward, p); motionEdgeFired = true; }
TurnHoldKey = (input.TurnRight || input.TurnLeft) ? axisHold : HoldKey.Invalid, if (!input.Forward && _prevForwardHeld)
TurnSpeed = 1.0f, {
}; if (input.Backward)
_motion.apply_raw_movement(raw); _motion.DoMotion(MotionCommand.WalkBackward, p);
else
_motion.StopMotion(MotionCommand.WalkForward, p);
motionEdgeFired = true;
}
else if (!input.Backward && _prevBackwardHeld && !input.Forward)
{ _motion.StopMotion(MotionCommand.WalkBackward, p); motionEdgeFired = true; }
// Sidestep channel.
if (input.StrafeRight && !_prevStrafeRightHeld)
{ _motion.DoMotion(MotionCommand.SideStepRight, p); motionEdgeFired = true; }
else if (input.StrafeLeft && !_prevStrafeLeftHeld)
{ _motion.DoMotion(MotionCommand.SideStepLeft, p); motionEdgeFired = true; }
else if (!input.StrafeRight && !input.StrafeLeft
&& (_prevStrafeRightHeld || _prevStrafeLeftHeld))
{ _motion.StopMotion(MotionCommand.SideStepRight, p); motionEdgeFired = true; }
// Turn channel.
if (input.TurnRight && !_prevTurnRightHeld)
{ _motion.DoMotion(MotionCommand.TurnRight, p); motionEdgeFired = true; }
else if (input.TurnLeft && !_prevTurnLeftHeld)
{ _motion.DoMotion(MotionCommand.TurnLeft, p); motionEdgeFired = true; }
else if (!input.TurnRight && !input.TurnLeft
&& (_prevTurnRightHeld || _prevTurnLeftHeld))
{ _motion.StopMotion(MotionCommand.TurnRight, p); motionEdgeFired = true; }
} }
_prevForwardHeld = input.Forward;
_prevBackwardHeld = input.Backward;
_prevStrafeLeftHeld = input.StrafeLeft;
_prevStrafeRightHeld = input.StrafeRight;
_prevTurnLeftHeld = input.TurnLeft;
_prevTurnRightHeld = input.TurnRight;
_prevRunHeld = input.Run;
// ── 1. Apply turning from keyboard + mouse ──────────────────────────── // ── 1. Apply turning from keyboard + mouse ────────────────────────────
// 2026-05-16 — retail-faithful turn rate. // 2026-05-16 — retail-faithful turn rate.
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt // Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
@ -986,7 +1051,10 @@ public sealed class PlayerMovementController
// that adjust_motion ran above. The hand-mirrored formulas are gone // that adjust_motion ran above. The hand-mirrored formulas are gone
// (register TS-22 retired). // (register TS-22 retired).
var stateVel = _motion.get_state_velocity(); var stateVel = _motion.get_state_velocity();
_body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz)); // 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);
} }
} // end of `if (!autoWalkConsumedMotion)` — section 2 } // end of `if (!autoWalkConsumedMotion)` — section 2
@ -1004,6 +1072,12 @@ public sealed class PlayerMovementController
{ {
_jumpCharging = true; _jumpCharging = true;
_jumpExtent = 0f; _jumpExtent = 0f;
// R3-W6 (map R1): retail's charge_jump fires at charge
// START (SmartBox/input boundary 0x0056afac) — the ONLY
// place StandingLongJump arms (grounded + Ready + no
// sidestep/turn). Never called by production code before
// this line despite the W3 port.
_motion.ChargeJump();
} }
_jumpExtent = MathF.Min(_jumpExtent + dt * JumpChargeRate, 1.0f); _jumpExtent = MathF.Min(_jumpExtent + dt * JumpChargeRate, 1.0f);
} }
@ -1036,7 +1110,7 @@ public sealed class PlayerMovementController
// client renders the jump in the same world direction the // client renders the jump in the same world direction the
// server is broadcasting to observers. Same vector we just // server is broadcasting to observers. Same vector we just
// sent in JumpAction — local + remote stay in sync. // sent in JumpAction — local + remote stay in sync.
_body.set_local_velocity(outJumpVelocity.Value); _body.set_local_velocity(outJumpVelocity.Value, autonomous: true);
} }
_jumpCharging = false; _jumpCharging = false;
_jumpExtent = 0f; _jumpExtent = 0f;
@ -1301,24 +1375,25 @@ public sealed class PlayerMovementController
// from the character's run skill and auto-upgrades WalkForward+HoldKey.Run // from the character's run skill and auto-upgrades WalkForward+HoldKey.Run
// → RunForward for observers. Sending the raw forward_speed=1.0 (omitted by // → RunForward for observers. Sending the raw forward_speed=1.0 (omitted by
// default-difference packing) still broadcasts RunForward @ runRate — a // default-difference packing) still broadcasts RunForward @ runRate — a
// retail observer saw +Acdream run at full pace. The earlier "wire must // retail observer saw +Acdream run at full pace.
// send run_rate because ACE relays it" comment (citing MovementData.cs) // R3-W6: the LOCAL animation no longer needs a separate
// was WRONG. Our own local animation still wants the actual RunForward // LocalAnimationCommand — the walk→run promotion happens inside the
// cycle — carried separately in LocalAnimationCommand below. // ported machinery (apply_raw_movement → apply_run_to_command
uint? localAnimCmd = null; // promotes WalkForward+HoldKey.Run → RunForward @ my_run_rate on the
// interpreted side, which the DefaultSink dispatch plays).
// NOTE (R7 scope): the wire values below stay derived from input —
// the L.2b-verified byte stream is untouched; sourcing them from
// _motion.RawState needs the cdb CommandInterpreter-boundary capture
// the W6 map's §2b TODO flags, and R7 owns outbound anyway.
if (input.Forward) if (input.Forward)
{ {
outForwardCmd = MotionCommand.WalkForward; outForwardCmd = MotionCommand.WalkForward;
outForwardSpeed = 1.0f; // RAW — ACE recomputes the broadcast speed outForwardSpeed = 1.0f; // RAW — ACE recomputes the broadcast speed
localAnimCmd = (input.Run && _weenie.InqRunRate(out _))
? MotionCommand.RunForward // local cycle is RunForward
: MotionCommand.WalkForward;
} }
else if (input.Backward) else if (input.Backward)
{ {
outForwardCmd = MotionCommand.WalkBackward; outForwardCmd = MotionCommand.WalkBackward;
outForwardSpeed = 1.0f; outForwardSpeed = 1.0f;
localAnimCmd = MotionCommand.WalkBackward;
} }
// Strafe: retail uses speed=1.0 for SideStep (see holtburger // Strafe: retail uses speed=1.0 for SideStep (see holtburger
@ -1363,18 +1438,22 @@ public sealed class PlayerMovementController
// observers. The forward_speed comparison below is retained (harmless — it // observers. The forward_speed comparison below is retained (harmless — it
// never fires now that forward_speed is constant) for defensiveness. // never fires now that forward_speed is constant) for defensiveness.
bool runHold = input.Run; bool runHold = input.Run;
// R3-W6: the localAnimCmd leg is deleted with the synthesis layer;
// motionEdgeFired (any DoMotion/StopMotion/set_hold_run edge this
// frame) is OR-ed in — by construction an edge IS a state change
// (retail dispatches only on edges), keeping the wire cadence
// identical to the old output-comparison.
bool changed = outForwardCmd != _prevForwardCmd bool changed = outForwardCmd != _prevForwardCmd
|| outSidestepCmd != _prevSidestepCmd || outSidestepCmd != _prevSidestepCmd
|| outTurnCmd != _prevTurnCmd || outTurnCmd != _prevTurnCmd
|| !FloatsEqual(outForwardSpeed, _prevForwardSpeed) || !FloatsEqual(outForwardSpeed, _prevForwardSpeed)
|| runHold != _prevRunHold || runHold != _prevRunHold
|| localAnimCmd != _prevLocalAnimCmd; || motionEdgeFired;
_prevForwardCmd = outForwardCmd; _prevForwardCmd = outForwardCmd;
_prevSidestepCmd = outSidestepCmd; _prevSidestepCmd = outSidestepCmd;
_prevTurnCmd = outTurnCmd; _prevTurnCmd = outTurnCmd;
_prevForwardSpeed = outForwardSpeed; _prevForwardSpeed = outForwardSpeed;
_prevRunHold = runHold; _prevRunHold = runHold;
_prevLocalAnimCmd = localAnimCmd;
static bool FloatsEqual(float? a, float? b) static bool FloatsEqual(float? a, float? b)
{ {
@ -1434,50 +1513,31 @@ public sealed class PlayerMovementController
HeartbeatDue = groundedOnWalkable && sendThisFrame; HeartbeatDue = groundedOnWalkable && sendThisFrame;
// K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate // R3-W6: the K-fix5 LocalAnimationSpeed synthesis is DELETED — the
// should match the actual movement speed. For Forward+Run this is // run pacing now comes from the ported machinery itself
// already runRate (it equals ForwardSpeed). For Backward+Run and // (apply_run_to_command scales the interpreted speed by my_run_rate;
// Strafe+Run it must be runRate too even though the wire keeps // the DefaultSink dispatch plays the cycle at that speed — the same
// those at 1.0. Picking runMul (already computed above) keeps the // source remotes use).
// math in one place.
bool anyDirectional = input.Forward || input.Backward bool anyDirectional = input.Forward || input.Backward
|| input.StrafeLeft || input.StrafeRight; || input.StrafeLeft || input.StrafeRight;
float localAnimSpeed = (input.Run && anyDirectional)
? (_weenie.InqRunRate(out float vrrAnim) ? vrrAnim : 1f)
: 1f;
// 2026-05-16 (issue #75) — server-initiated auto-walk drives // R3-W6 (#69 kept alive through the retail pipeline): auto-walk's
// the local animation cycle directly: // turn-first phase now dispatches the turn cycle as an EDGE through
// - moving forward → WalkForward / RunForward (legs animate) // DoMotion/StopMotion instead of the deleted LocalAnimationCommand
// - turn-first phase → TurnLeft / TurnRight (issue #69 fix) // override. Forward legs during auto-walk come from
// - aligned but pre-step / arrival → no override, falls to // DriveServerAutoWalk's own DoMotion(WalkForward) (KEEP-LIST, R4
// the user-input section's default (idle) // replaces). Expected-diff: auto-walk-at-run plays walk-pace legs
// UpdatePlayerAnimation reads LocalAnimationCommand + // until R4's MoveToManager drives CanCharge walk/run selection.
// LocalAnimationSpeed; without these overrides the body if (_autoWalkTurnDirectionThisFrame != _prevAutoWalkTurnDir)
// translates/rotates without leg/arm animation. The motion
// cycle commands here flow into the animation sequencer
// ONLY — the wire-layer guard at GameWindow.cs:6419 prevents
// them from leaking to a user-MoveToState packet during
// auto-walk.
if (_autoWalkMovingForwardThisFrame)
{ {
if (_autoWalkInitiallyRunning && _weenie.InqRunRate(out float autoWalkRunRate)) var pTurn = new AcDream.Core.Physics.Motion.MovementParameters();
{ if (_autoWalkTurnDirectionThisFrame > 0)
localAnimCmd = MotionCommand.RunForward; _motion.DoMotion(MotionCommand.TurnLeft, pTurn);
localAnimSpeed = autoWalkRunRate; else if (_autoWalkTurnDirectionThisFrame < 0)
} _motion.DoMotion(MotionCommand.TurnRight, pTurn);
else else
{ _motion.StopMotion(MotionCommand.TurnRight, pTurn);
localAnimCmd = MotionCommand.WalkForward; _prevAutoWalkTurnDir = _autoWalkTurnDirectionThisFrame;
localAnimSpeed = 1f;
}
}
else if (_autoWalkTurnDirectionThisFrame != 0)
{
localAnimCmd = _autoWalkTurnDirectionThisFrame > 0
? MotionCommand.TurnLeft
: MotionCommand.TurnRight;
localAnimSpeed = 1f;
} }
return new MovementResult( return new MovementResult(
@ -1500,8 +1560,6 @@ public sealed class PlayerMovementController
// who then animated walk + dead-reckoned at walk speed while the // who then animated walk + dead-reckoned at walk speed while the
// server position moved at run speed — visible as observer lag. // server position moved at run speed — visible as observer lag.
IsRunning: input.Run && anyDirectional, IsRunning: input.Run && anyDirectional,
LocalAnimationCommand: localAnimCmd,
LocalAnimationSpeed: localAnimSpeed,
JustLanded: justLanded, JustLanded: justLanded,
JumpExtent: outJumpExtent, JumpExtent: outJumpExtent,
JumpVelocity: outJumpVelocity); JumpVelocity: outJumpVelocity);

View file

@ -739,8 +739,6 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera; private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
private bool _playerMode; private bool _playerMode;
private uint _playerServerGuid; private uint _playerServerGuid;
private uint? _playerCurrentAnimCommand;
private float _playerCurrentAnimSpeed = 1f;
private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character
private MovementTruthOutbound? _lastMovementTruthOutbound; private MovementTruthOutbound? _lastMovementTruthOutbound;
@ -8003,7 +8001,6 @@ public sealed class GameWindow : IDisposable
} }
// Update the player entity's animation cycle to match current motion. // Update the player entity's animation cycle to match current motion.
UpdatePlayerAnimation(result);
} }
} }
@ -10122,189 +10119,14 @@ public sealed class GameWindow : IDisposable
} }
} }
/// <summary> // R3-W6: UpdatePlayerAnimation DELETED — the player's sequencer is
/// Phase B.2: switch the locally-controlled player entity's animation cycle // driven through the SAME MotionTableDispatchSink/DefaultSink funnel
/// to match the current motion command. Only re-resolves when the command // remotes use (edge-driven DoMotion/StopMotion/set_hold_run in
/// actually changes (forward → run, idle → walk, etc.) to avoid re-building // PlayerMovementController; airborne-Falling falls out of
/// the animation entry every frame. // contact_allows_move + apply_current_movement). The #45 sidestep
/// // 1.248x factor + ACDREAM_ANIM_SPEED_SCALE died with it —
/// <para> // EXPECTED-DIFF: local sidestep pacing now matches how remotes have
/// Action motions (Jump, FallDown, emotes, attacks) are routed through // always played (w6-cutover-map.md R3).
/// <see cref="AcDream.Core.Physics.AnimationSequencer.PlayAction"/> — they
/// live in the motion table's Modifiers dict, not the Cycles dict, and
/// are inserted into the queue on top of the current cycle instead of
/// replacing it.
/// </para>
/// </summary>
private void UpdatePlayerAnimation(AcDream.App.Input.MovementResult result)
{
if (_dats is null) return;
// ── Airborne SubState (Falling) ────────────────────────────────────
//
// Retail models the jump-animation as a SubState swap to
// MotionCommand.Falling (0x40000015) while airborne, NOT as an
// Action overlay. Empirically verified: Links[(NonCombat,RunForward)]
// has 3 transitions including 0x40000015 Falling. The SubState cycle
// for Falling lives in Cycles[(style, Falling)] and loops while
// airborne. On land, we transition back to whatever SubState the
// motion input implies (Ready / WalkForward / RunForward).
//
// Implementation: force animCommand = Falling when airborne; the
// existing SetCycle pathway resolves the link + cycle correctly and
// the transition back happens naturally when airborne becomes false.
// Determine the animation command: airborne takes priority (Falling
// SubState), then forward, sidestep, turn, then idle (Ready 0x41000003).
//
// Airborne → Falling (retail behavior; see airborne note above).
// Otherwise: LocalAnimationCommand (RunForward when running) preferred,
// falling back to wire ForwardCommand (WalkForward / WalkBackward).
uint animCommand;
if (!result.IsOnGround)
animCommand = AcDream.Core.Physics.MotionCommand.Falling;
else if (result.LocalAnimationCommand is { } localCmd)
animCommand = localCmd;
else if (result.ForwardCommand is { } fwd)
animCommand = fwd;
else if (result.SidestepCommand is { } ss)
animCommand = ss;
else if (result.TurnCommand is { } tc)
animCommand = tc;
else
animCommand = 0x41000003u; // Ready (idle)
// Fast path: no command change AND speed delta is negligible. If
// command is unchanged but speed changed, we must still propagate
// so the sequencer can MultiplyCyclicFramerate — keeping the run
// loop smooth without restart.
// K-fix5 (2026-04-26): use LocalAnimationSpeed (cycle pace) NOT
// ForwardSpeed (wire field) — backward+run + strafe+run keep
// ForwardSpeed/SidestepSpeed at 1.0 for ACE compatibility but
// need the local cycle to play at runRate × so the animation
// matches the actual movement velocity.
float newSpeed = result.LocalAnimationSpeed;
bool sameCmd = animCommand == _playerCurrentAnimCommand;
bool sameSpeed = MathF.Abs(newSpeed - _playerCurrentAnimSpeed) < 1e-3f;
if (sameCmd && sameSpeed) return;
_playerCurrentAnimCommand = animCommand;
_playerCurrentAnimSpeed = newSpeed;
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return;
// The player entity may not be in _animatedEntities if a post-spawn
// UpdateMotion removed it (Phase 6.8 pattern). In that case, load
// the Setup and re-register. This is the player's own character so
// we always want it animated in player mode.
if (!_animatedEntities.TryGetValue(pe.Id, out var ae))
{
// A.5 T10: lock around _dats.Get — worker thread may be
// building a landblock mesh concurrently. DatBinReader's
// shared buffer position would corrupt without serialization.
DatReaderWriter.DBObjs.Setup? setup;
lock (_datLock) { setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(pe.SourceGfxObjOrSetupId); }
if (setup is null) return;
_physicsDataCache.CacheSetup(pe.SourceGfxObjOrSetupId, setup);
// Build a minimal part template from the entity's current MeshRefs.
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[pe.MeshRefs.Count];
for (int i = 0; i < pe.MeshRefs.Count; i++)
template[i] = (pe.MeshRefs[i].GfxObjId, pe.MeshRefs[i].SurfaceOverrides);
ae = new AnimatedEntity
{
Entity = pe,
Setup = setup,
Animation = null!, // filled below
LowFrame = 0,
HighFrame = 0,
Framerate = 30f,
Scale = 1f,
PartTemplate = template,
CurrFrame = 0f,
};
_animatedEntities[pe.Id] = ae;
}
// The motion table cycle key is (style << 16) | (command & 0xFFFFFF).
// Without a stance override, the resolver uses the table default
// (which always maps to the idle/Ready cycle regardless of command).
// Pass the NonCombat stance (0x003D) so the resolver builds the
// correct cycle key for walk/run/turn commands.
ushort cmdOverride = (ushort)(animCommand & 0xFFFFu);
const ushort NonCombatStance = 0x003D;
var cycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
ae.Setup, _dats,
motionTableIdOverride: _playerMotionTableId,
stanceOverride: NonCombatStance,
commandOverride: cmdOverride);
// Sequencer path: SetCycle handles adjust_motion internally
// (TurnLeft→TurnRight with negative speed, etc.)
//
// Speed scaling: K-fix5 (2026-04-26) — use LocalAnimationSpeed
// (the PlayerMovementController-computed cycle pace) instead of
// the wire ForwardSpeed. Forward+Run = runRate; Backward+Run =
// runRate (where ForwardSpeed is the ACE-compatible 1.0);
// Strafe+Run = runRate (where SidestepSpeed is 1.0). Anything
// not in run = 1.0. The animation cycle now visually matches
// the movement velocity in every direction.
if (ae.Sequencer is not null)
{
uint fullStyle = 0x80000000u | (uint)NonCombatStance;
float animSpeed = result.LocalAnimationSpeed > 0f
? result.LocalAnimationSpeed
: 1f;
// ACDREAM_ANIM_SPEED_SCALE: optional visual-pacing knob. Retail's
// animation framerate scales linearly with speedMod (r03 §8.3),
// and our speedMod = runRate. If the visual feel doesn't match
// retail, override via env var (default 1.0 = no change).
float animScale = 1.0f;
if (float.TryParse(
Environment.GetEnvironmentVariable("ACDREAM_ANIM_SPEED_SCALE"),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var s) && s > 0f)
{
animScale = s;
}
// R3-W4: the K-fix18 skipLink flag is DELETED — the instant
// Falling engage comes from the controller's grounded→airborne
// edge firing MotionInterpreter.LeaveGround, whose
// RemoveLinkAnimations seam (bound at sequencer creation)
// strips pending links exactly where retail does.
// #45 (2026-05-06): scale sidestep speedMod to match ACE's
// wire formula. PlayerMovementController hands us a raw
// localAnimSpeed (1.0 slow / runRate fast), but ACE's
// BroadcastMovement converts SidestepSpeed via
// `speed × 3.12 / 1.25 × 0.5`
// (Network/Motion/MovementData.cs:124-131). Without the
// matching multiplier here, the local sidestep cycle plays
// at speedMod = 1.0 while the observer-side cycle plays at
// ~1.248 — local strafe visibly slower than retail (user
// report at #45 close-out of #39).
// Factor = WalkAnimSpeed / SidestepAnimSpeed × 0.5
// = 3.12 / 1.25 × 0.5 = 1.248.
uint cmdLow = animCommand & 0xFFu;
if (cmdLow == 0x0Fu /* SideStepRight */ || cmdLow == 0x10u /* SideStepLeft */)
{
animSpeed *= AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed
/ AcDream.Core.Physics.MotionInterpreter.SidestepAnimSpeed
* 0.5f;
}
ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed * animScale);
}
// Legacy path: update the manual slerp fields (for entities without sequencer)
if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) return;
ae.Animation = cycle.Animation;
ae.LowFrame = Math.Max(0, cycle.LowFrame);
ae.HighFrame = Math.Min(cycle.HighFrame, cycle.Animation.PartFrames.Count - 1);
ae.Framerate = cycle.Framerate;
ae.CurrFrame = ae.LowFrame;
}
/// <summary> /// <summary>
/// Phase 3a — re-roll the active DayGroup whenever the current /// Phase 3a — re-roll the active DayGroup whenever the current
@ -11845,7 +11667,6 @@ public sealed class GameWindow : IDisposable
_playerController = null; _playerController = null;
_chaseCamera = null; _chaseCamera = null;
_retailChaseCamera = null; _retailChaseCamera = null;
_playerCurrentAnimCommand = null;
} }
else else
_window!.Close(); _window!.Close();
@ -12743,7 +12564,6 @@ public sealed class GameWindow : IDisposable
_playerController = null; _playerController = null;
_chaseCamera = null; _chaseCamera = null;
_retailChaseCamera = null; _retailChaseCamera = null;
_playerCurrentAnimCommand = null;
_playerMouseDeltaX = 0f; _playerMouseDeltaX = 0f;
} }
} }
@ -12997,6 +12817,12 @@ public sealed class GameWindow : IDisposable
() => playerSeq.Manager.InitializeState(); () => playerSeq.Manager.InitializeState();
_playerController.Motion.CheckForCompletedMotions = _playerController.Motion.CheckForCompletedMotions =
playerSeq.Manager.CheckForCompletedMotions; playerSeq.Manager.CheckForCompletedMotions;
// R3-W6: the player's cycles are now driven through the SAME
// dispatch sink remotes use (TurnApplied/TurnStopped omitted —
// ObservedOmega is a remote-DR-only concept; local rotation is
// the controller's Yaw integration).
_playerController.Motion.DefaultSink =
new AcDream.Core.Physics.Motion.MotionTableDispatchSink(playerSeq);
} }
var q = playerEntity.Rotation; var q = playerEntity.Rotation;

View file

@ -587,12 +587,15 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// <summary> /// <summary>
/// The physics object's currently-held movement key (retail /// The physics object's currently-held movement key (retail
/// <c>this->raw_state.current_holdkey</c>). <see cref="adjust_motion"/> /// <c>this->raw_state.current_holdkey</c>). <see cref="adjust_motion"/>
/// falls back to this field when the per-channel holdKey argument passed /// falls back to this property when the per-channel holdKey argument
/// in is <see cref="HoldKey.Invalid"/>. D6.2 (the raw-state orchestrator) /// passed in is <see cref="HoldKey.Invalid"/>. R3-W6: an ALIAS of
/// is responsible for keeping this in sync with the raw motion state; /// <see cref="RawMotionState.CurrentHoldKey"/> — retail's adjust_motion
/// default <see cref="HoldKey.None"/> is a safe no-op until then. /// reads <c>raw_state.current_holdkey</c> directly; the former shadow
/// field only synced on the raw dispatch branch, so an edge-driven
/// set_hold_run followed by DoMotion read a stale None and lost the
/// run promotion (the W6 cutover surfaced this).
/// </summary> /// </summary>
public HoldKey CurrentHoldKey = HoldKey.None; public HoldKey CurrentHoldKey => RawState.CurrentHoldKey;
/// <summary>True when crouching-in-place for a standing long jump (offset +0x70).</summary> /// <summary>True when crouching-in-place for a standing long jump (offset +0x70).</summary>
public bool StandingLongJump; public bool StandingLongJump;
@ -1345,7 +1348,10 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// </summary> /// </summary>
public void apply_raw_movement(RawMotionState raw) public void apply_raw_movement(RawMotionState raw)
{ {
CurrentHoldKey = raw.CurrentHoldKey; // R3-W6: CurrentHoldKey is now an alias of RawState.CurrentHoldKey;
// keep the interpreter's raw state authoritative when a snapshot
// arrives through this legacy overload.
RawState.CurrentHoldKey = raw.CurrentHoldKey;
// Copy raw -> interpreted. Retail copies 7 fields; our // Copy raw -> interpreted. Retail copies 7 fields; our
// InterpretedMotionState omits current_style (velocity doesn't use it). // InterpretedMotionState omits current_style (velocity doesn't use it).

View file

@ -149,7 +149,7 @@ public sealed class MotionNormalizationTests
public void AdjustMotion_HoldKeyInvalid_FallsBackToCurrentHoldKey_PromotesWalkForwardWhenRun() public void AdjustMotion_HoldKeyInvalid_FallsBackToCurrentHoldKey_PromotesWalkForwardWhenRun()
{ {
var interp = MakeInterp(); var interp = MakeInterp();
interp.CurrentHoldKey = HoldKey.Run; interp.RawState.CurrentHoldKey = HoldKey.Run; // R3-W6: CurrentHoldKey aliases RawState
uint motion = MotionCommand.WalkForward; uint motion = MotionCommand.WalkForward;
float speed = 1.0f; float speed = 1.0f;
@ -164,7 +164,7 @@ public sealed class MotionNormalizationTests
public void AdjustMotion_HoldKeyNone_DoesNotPromote() public void AdjustMotion_HoldKeyNone_DoesNotPromote()
{ {
var interp = MakeInterp(); var interp = MakeInterp();
interp.CurrentHoldKey = HoldKey.Run; interp.RawState.CurrentHoldKey = HoldKey.Run; // R3-W6: CurrentHoldKey aliases RawState
uint motion = MotionCommand.WalkForward; uint motion = MotionCommand.WalkForward;
float speed = 1.0f; float speed = 1.0f;