diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 80c35f44..880d2bbf 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -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
///
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);
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 486892d0..4c1c114f 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -739,8 +739,6 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
private bool _playerMode;
private uint _playerServerGuid;
- private uint? _playerCurrentAnimCommand;
- private float _playerCurrentAnimSpeed = 1f;
private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character
private MovementTruthOutbound? _lastMovementTruthOutbound;
@@ -8003,7 +8001,6 @@ public sealed class GameWindow : IDisposable
}
// Update the player entity's animation cycle to match current motion.
- UpdatePlayerAnimation(result);
}
}
@@ -10122,189 +10119,14 @@ public sealed class GameWindow : IDisposable
}
}
- ///
- /// Phase B.2: switch the locally-controlled player entity's animation cycle
- /// to match the current motion command. Only re-resolves when the command
- /// actually changes (forward → run, idle → walk, etc.) to avoid re-building
- /// the animation entry every frame.
- ///
- ///
- /// Action motions (Jump, FallDown, emotes, attacks) are routed through
- /// — 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.
- ///
- ///
- 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(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?)[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;
- }
+ // R3-W6: UpdatePlayerAnimation DELETED — the player's sequencer is
+ // driven through the SAME MotionTableDispatchSink/DefaultSink funnel
+ // remotes use (edge-driven DoMotion/StopMotion/set_hold_run in
+ // PlayerMovementController; airborne-Falling falls out of
+ // contact_allows_move + apply_current_movement). The #45 sidestep
+ // 1.248x factor + ACDREAM_ANIM_SPEED_SCALE died with it —
+ // EXPECTED-DIFF: local sidestep pacing now matches how remotes have
+ // always played (w6-cutover-map.md R3).
///
/// Phase 3a — re-roll the active DayGroup whenever the current
@@ -11845,7 +11667,6 @@ public sealed class GameWindow : IDisposable
_playerController = null;
_chaseCamera = null;
_retailChaseCamera = null;
- _playerCurrentAnimCommand = null;
}
else
_window!.Close();
@@ -12743,7 +12564,6 @@ public sealed class GameWindow : IDisposable
_playerController = null;
_chaseCamera = null;
_retailChaseCamera = null;
- _playerCurrentAnimCommand = null;
_playerMouseDeltaX = 0f;
}
}
@@ -12997,6 +12817,12 @@ public sealed class GameWindow : IDisposable
() => playerSeq.Manager.InitializeState();
_playerController.Motion.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;
diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index 0d34a7d5..bcf82438 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -587,12 +587,15 @@ public sealed class MotionInterpreter : IMotionDoneSink
///
/// The physics object's currently-held movement key (retail
/// this->raw_state.current_holdkey).
- /// falls back to this field when the per-channel holdKey argument passed
- /// in is . D6.2 (the raw-state orchestrator)
- /// is responsible for keeping this in sync with the raw motion state;
- /// default is a safe no-op until then.
+ /// falls back to this property when the per-channel holdKey argument
+ /// passed in is . R3-W6: an ALIAS of
+ /// — retail's adjust_motion
+ /// reads raw_state.current_holdkey 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).
///
- public HoldKey CurrentHoldKey = HoldKey.None;
+ public HoldKey CurrentHoldKey => RawState.CurrentHoldKey;
/// True when crouching-in-place for a standing long jump (offset +0x70).
public bool StandingLongJump;
@@ -1345,7 +1348,10 @@ public sealed class MotionInterpreter : IMotionDoneSink
///
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
// InterpretedMotionState omits current_style (velocity doesn't use it).
diff --git a/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs b/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs
index 0887c844..415bf828 100644
--- a/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs
@@ -149,7 +149,7 @@ public sealed class MotionNormalizationTests
public void AdjustMotion_HoldKeyInvalid_FallsBackToCurrentHoldKey_PromotesWalkForwardWhenRun()
{
var interp = MakeInterp();
- interp.CurrentHoldKey = HoldKey.Run;
+ interp.RawState.CurrentHoldKey = HoldKey.Run; // R3-W6: CurrentHoldKey aliases RawState
uint motion = MotionCommand.WalkForward;
float speed = 1.0f;
@@ -164,7 +164,7 @@ public sealed class MotionNormalizationTests
public void AdjustMotion_HoldKeyNone_DoesNotPromote()
{
var interp = MakeInterp();
- interp.CurrentHoldKey = HoldKey.Run;
+ interp.RawState.CurrentHoldKey = HoldKey.Run; // R3-W6: CurrentHoldKey aliases RawState
uint motion = MotionCommand.WalkForward;
float speed = 1.0f;