From fb7beb706b9e728c41c6e983c596957b6428bb53 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 3 Jul 2026 09:19:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(R3-W6):=20LOCAL=20PLAYER=20UNIFICATION=20?= =?UTF-8?q?=E2=80=94=20edge-driven=20retail=20input;=20UpdatePlayerAnimati?= =?UTF-8?q?on=20+=20the=20synthesis=20layer=20DELETED=20(closes=20J15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Input/PlayerMovementController.cs | 240 +++++++++++------- src/AcDream.App/Rendering/GameWindow.cs | 202 +-------------- src/AcDream.Core/Physics/MotionInterpreter.cs | 18 +- .../Physics/MotionNormalizationTests.cs | 4 +- 4 files changed, 177 insertions(+), 287 deletions(-) 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;