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? TurnSpeed,
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
// sequencer. Decoupled from ForwardSpeed so the wire can keep sending
// 1.0 for WalkBackward (ACE-compatible) while the animation plays at
// runRate × so the cycle visually matches the run-speed velocity.
// Forward+Run = runRate (same as ForwardSpeed); Backward+Run, Strafe+Run
// = 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
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.
@ -182,13 +180,25 @@ public sealed class PlayerMovementController
// to drive the landing animation cycle.
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? _prevSidestepCmd;
private uint? _prevTurnCmd;
private float? _prevForwardSpeed;
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.
// 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
// arrival. Drives the animation cycle override: walking animation
// 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.
// +1 = rotating counter-clockwise (Yaw increasing) → TurnLeft cycle
@ -556,7 +566,6 @@ public sealed class PlayerMovementController
/// </summary>
private bool DriveServerAutoWalk(float dt, MovementInput input)
{
_autoWalkMovingForwardThisFrame = false;
_autoWalkTurnDirectionThisFrame = 0;
if (!_autoWalkActive) return false;
@ -729,6 +738,8 @@ public sealed class PlayerMovementController
// 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)
{
@ -759,7 +770,6 @@ public sealed class PlayerMovementController
forwardCmdSpeed = 1.0f;
}
_autoWalkMovingForwardThisFrame = true;
// Update interpreted motion state — drives the animation cycle
// 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
// off in the old direction the instant input resumes. Resetting the forward
// 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.
_body.LastUpdateTime = 0.0;
@ -889,40 +914,80 @@ public sealed class PlayerMovementController
TurnSpeed: null);
}
// ── D6.2: normalize input into the interpreted motion state ───────────
// ONE RawMotionState from MovementInput drives the local velocity + turn
// (retail CMotionInterp::apply_raw_movement 0x005287e0). RAW speeds are
// 1.0 — apply_run_to_command applies the run rate, so passing a pre-scaled
// speed would double-scale. adjust_motion normalizes backward →
// WalkForward (×-0.65) and strafe-left → SideStepRight (×-1×1.248), so
// get_state_velocity below is correct for ALL directions (retires the
// hand-mirrored formulas; register TS-22). Skipped during server
// auto-walk, which drives the body itself.
// ── R3-W6: EDGE-DRIVEN retail input (replaces the D6.2 per-frame
// RawMotionState rebuild — the level-triggered substitute for
// retail's edge-triggered CommandInterpreter). Each key EDGE fires
// DoMotion/StopMotion (0x00528d20/0x00528530) which mutate the
// interpreter's OWN RawState via ApplyMotion/RemoveMotion and
// dispatch through the funnel + DefaultSink — the SAME pipeline
// 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).
bool motionEdgeFired = false;
if (!autoWalkConsumedMotion)
{
var axisHold = input.Run ? HoldKey.Run : HoldKey.None;
var raw = new RawMotionState
var p = new AcDream.Core.Physics.Motion.MovementParameters();
// 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,
ForwardCommand = input.Forward ? MotionCommand.WalkForward
: input.Backward ? MotionCommand.WalkBackward
: MotionCommand.Ready,
ForwardHoldKey = (input.Forward || input.Backward) ? axisHold : HoldKey.Invalid,
ForwardSpeed = 1.0f,
SidestepCommand = input.StrafeRight ? MotionCommand.SideStepRight
: input.StrafeLeft ? MotionCommand.SideStepLeft
: 0u,
SidestepHoldKey = (input.StrafeRight || input.StrafeLeft) ? axisHold : HoldKey.Invalid,
SidestepSpeed = 1.0f,
TurnCommand = input.TurnRight ? MotionCommand.TurnRight
: input.TurnLeft ? MotionCommand.TurnLeft
: 0u,
TurnHoldKey = (input.TurnRight || input.TurnLeft) ? axisHold : HoldKey.Invalid,
TurnSpeed = 1.0f,
};
_motion.apply_raw_movement(raw);
_motion.set_hold_run(input.Run, interrupt: true);
motionEdgeFired = true;
}
// Forward channel (W / S share one raw channel — retail
// RawMotionState.ApplyMotion's forward-class switch). W wins on
// a same-frame double-press; releasing one key while the
// opposite is still held re-issues the survivor (equivalent to
// the old level-triggered build, which always reflected the
// currently-held set).
if (input.Forward && !_prevForwardHeld)
{ _motion.DoMotion(MotionCommand.WalkForward, p); motionEdgeFired = true; }
else if (input.Backward && !_prevBackwardHeld && !input.Forward)
{ _motion.DoMotion(MotionCommand.WalkBackward, p); motionEdgeFired = true; }
if (!input.Forward && _prevForwardHeld)
{
if (input.Backward)
_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 ────────────────────────────
// 2026-05-16 — retail-faithful turn rate.
// 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
// (register TS-22 retired).
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
@ -1004,6 +1072,12 @@ public sealed class PlayerMovementController
{
_jumpCharging = true;
_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);
}
@ -1036,7 +1110,7 @@ public sealed class PlayerMovementController
// client renders the jump in the same world direction the
// server is broadcasting to observers. Same vector we just
// sent in JumpAction — local + remote stay in sync.
_body.set_local_velocity(outJumpVelocity.Value);
_body.set_local_velocity(outJumpVelocity.Value, autonomous: true);
}
_jumpCharging = false;
_jumpExtent = 0f;
@ -1301,24 +1375,25 @@ public sealed class PlayerMovementController
// from the character's run skill and auto-upgrades WalkForward+HoldKey.Run
// → RunForward for observers. Sending the raw forward_speed=1.0 (omitted by
// default-difference packing) still broadcasts RunForward @ runRate — a
// retail observer saw +Acdream run at full pace. The earlier "wire must
// send run_rate because ACE relays it" comment (citing MovementData.cs)
// was WRONG. Our own local animation still wants the actual RunForward
// cycle — carried separately in LocalAnimationCommand below.
uint? localAnimCmd = null;
// retail observer saw +Acdream run at full pace.
// R3-W6: the LOCAL animation no longer needs a separate
// LocalAnimationCommand — the walk→run promotion happens inside the
// ported machinery (apply_raw_movement → apply_run_to_command
// 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)
{
outForwardCmd = MotionCommand.WalkForward;
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)
{
outForwardCmd = MotionCommand.WalkBackward;
outForwardSpeed = 1.0f;
localAnimCmd = MotionCommand.WalkBackward;
}
// 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
// never fires now that forward_speed is constant) for defensiveness.
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
|| outSidestepCmd != _prevSidestepCmd
|| outTurnCmd != _prevTurnCmd
|| !FloatsEqual(outForwardSpeed, _prevForwardSpeed)
|| runHold != _prevRunHold
|| localAnimCmd != _prevLocalAnimCmd;
|| motionEdgeFired;
_prevForwardCmd = outForwardCmd;
_prevSidestepCmd = outSidestepCmd;
_prevTurnCmd = outTurnCmd;
_prevForwardSpeed = outForwardSpeed;
_prevRunHold = runHold;
_prevLocalAnimCmd = localAnimCmd;
static bool FloatsEqual(float? a, float? b)
{
@ -1434,50 +1513,31 @@ public sealed class PlayerMovementController
HeartbeatDue = groundedOnWalkable && sendThisFrame;
// K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate
// should match the actual movement speed. For Forward+Run this is
// already runRate (it equals ForwardSpeed). For Backward+Run and
// Strafe+Run it must be runRate too even though the wire keeps
// those at 1.0. Picking runMul (already computed above) keeps the
// math in one place.
// R3-W6: the K-fix5 LocalAnimationSpeed synthesis is DELETED — the
// run pacing now comes from the ported machinery itself
// (apply_run_to_command scales the interpreted speed by my_run_rate;
// the DefaultSink dispatch plays the cycle at that speed — the same
// source remotes use).
bool anyDirectional = input.Forward || input.Backward
|| 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
// the local animation cycle directly:
// - moving forward → WalkForward / RunForward (legs animate)
// - turn-first phase → TurnLeft / TurnRight (issue #69 fix)
// - aligned but pre-step / arrival → no override, falls to
// the user-input section's default (idle)
// UpdatePlayerAnimation reads LocalAnimationCommand +
// LocalAnimationSpeed; without these overrides the body
// translates/rotates without leg/arm animation. The motion
// cycle commands here flow into the animation sequencer
// ONLY — the wire-layer guard at GameWindow.cs:6419 prevents
// them from leaking to a user-MoveToState packet during
// auto-walk.
if (_autoWalkMovingForwardThisFrame)
// 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)
{
if (_autoWalkInitiallyRunning && _weenie.InqRunRate(out float autoWalkRunRate))
{
localAnimCmd = MotionCommand.RunForward;
localAnimSpeed = autoWalkRunRate;
}
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
{
localAnimCmd = MotionCommand.WalkForward;
localAnimSpeed = 1f;
}
}
else if (_autoWalkTurnDirectionThisFrame != 0)
{
localAnimCmd = _autoWalkTurnDirectionThisFrame > 0
? MotionCommand.TurnLeft
: MotionCommand.TurnRight;
localAnimSpeed = 1f;
_motion.StopMotion(MotionCommand.TurnRight, pTurn);
_prevAutoWalkTurnDir = _autoWalkTurnDirectionThisFrame;
}
return new MovementResult(
@ -1500,8 +1560,6 @@ public sealed class PlayerMovementController
// who then animated walk + dead-reckoned at walk speed while the
// server position moved at run speed — visible as observer lag.
IsRunning: input.Run && anyDirectional,
LocalAnimationCommand: localAnimCmd,
LocalAnimationSpeed: localAnimSpeed,
JustLanded: justLanded,
JumpExtent: outJumpExtent,
JumpVelocity: outJumpVelocity);