using System; using System.Numerics; using AcDream.Core.Physics; namespace AcDream.App.Input; /// /// Input state for a single frame of player movement. /// public readonly record struct MovementInput( bool Forward = false, bool Backward = false, bool StrafeLeft = false, bool StrafeRight = false, bool TurnLeft = false, bool TurnRight = false, bool Run = false, float MouseDeltaX = 0f, bool Jump = false); /// /// Result of a single frame's movement update. /// /// /// Wire vs. local animation command. ACE's MovementData /// (ACE.Server/Network/Motion/MovementData.cs) only computes /// interpState.ForwardSpeed for raw WalkForward/ /// WalkBackwards — on every other command the else branch /// passes through command without setting speed, leaving observers with /// speed=0. The client therefore has to send WalkForward /// (with HoldKey.Run for running) and let ACE auto-upgrade to /// RunForward for broadcast. But the LOCAL view wants the run /// cycle immediately, so we carry a separate /// for the player's own renderer. /// /// /// — true when the player is holding Shift to run. /// Used by the GameWindow when building the outbound MoveToState's /// CURRENT_HOLD_KEY (2=Run) vs (1=None). /// /// public readonly record struct MovementResult( Vector3 Position, uint CellId, bool IsOnGround, bool MotionStateChanged, uint? ForwardCommand, // wire-side command (WalkForward / WalkBackward / …) uint? SidestepCommand, uint? TurnCommand, float? ForwardSpeed, 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); // world-space launch velocity (sent in jump packet) /// /// Portal-space state for the player movement controller. /// PortalSpace freezes all movement input while the server is moving the /// player through a portal — resumed once the destination UpdatePosition /// arrives and the player is snapped to the new location. /// While in PortalSpace, Update returns immediately with a zero-movement /// result so no WASD input or physics is processed. /// public enum PlayerState { InWorld, PortalSpace } /// /// Per-frame player movement controller. Reads input, drives the /// ported PhysicsBody + MotionInterpreter, tracks motion state for /// animation + server messages. /// /// Architecture: /// - PhysicsBody owns integration: gravity, friction, sub-stepping, /// velocity clamping — all from the decompiled retail client. /// - MotionInterpreter owns the motion state machine: walk/run/jump /// validation, state tracking, speed constants from the retail dat. /// - PhysicsEngine.Resolve is still used each frame to snap the player /// to terrain/cell floor Z and detect ground contact. /// public sealed class PlayerMovementController { private readonly PhysicsEngine _physics; private readonly PhysicsBody _body; private readonly MotionInterpreter _motion; private readonly PlayerWeenie _weenie; public float MouseTurnSensitivity { get; set; } = 0.003f; /// /// Maximum Z increase per movement step before the move is rejected. /// Retail's step_up_height for human characters is ~0.4 m (hip- /// level). Setting this too high lets the player teleport up small /// buildings via the step-up scan finding any walkable polygon within /// reach (Bug 3 in L.2.3 testing — walking into a steep slope mounted /// the building's flat top instead of sliding off the slope). /// Authoritative source is the player's Setup.StepUpHeight set /// in GameWindow.cs at world-entry time. /// public float StepUpHeight { get; set; } = 0.4f; /// /// L.2.3a (2026-04-29): how far below the foot the step-down probe /// reaches when transitioning between surfaces. Retail's /// step_down_height for human characters is ~0.4 m. With the /// previous 4 cm hardcoded value, walking off the top of a stair onto /// the ground 25 cm below produced a one-frame contact-plane gap — the /// animation system briefly flickered to falling. /// public float StepDownHeight { get; set; } = 0.4f; /// /// Current portal-space state. Set to PortalSpace when the server sends /// PlayerTeleport (0xF751); set back to InWorld once the destination /// UpdatePosition arrives and the player is snapped to the new cell. /// While in PortalSpace, Update returns immediately with a zero-movement /// result so no WASD input or physics is processed. /// public PlayerState State { get; set; } = PlayerState.InWorld; public float Yaw { get; set; } public Vector3 Position => _body.Position; public uint CellId { get; private set; } public bool IsAirborne => !_body.OnWalkable; /// /// Current vertical (Z-axis) velocity of the physics body. /// Positive = rising, negative = falling. Exposed for tests and HUD. /// public float VerticalVelocity => _body.Velocity.Z; /// Full 3D world-space velocity of the physics body. Exposed for diagnostic logging. public Vector3 BodyVelocity => _body.Velocity; // Jump charge state. private bool _jumpCharging; private float _jumpExtent; // K-fix6 (2026-04-26): retail's PowerBar charge constant for jump is // not legible in the named decomp (the divisor was clobbered in // GetPowerBarLevel's FPU stack reordering at FUN_0056ade0). 2.0/s // (full charge in 0.5s) feels matches retail muscle memory better // than the previous 1.0/s — a tap gives a noticeable hop, half-hold // a meaningful jump, full-hold the maximum extent. The vertical // velocity formula itself (height × 19.6 → vz) is unchanged and // matches retail byte-for-byte; only the time-to-fill is faster. private const float JumpChargeRate = 2.0f; // Airborne → grounded transition detection. Flipped on every frame where // the body transitions from airborne to on-walkable; used by the GameWindow // to drive the landing animation cycle. private bool _wasAirborneLastFrame; // Previous frame's motion commands for change detection. private uint? _prevForwardCmd; private uint? _prevSidestepCmd; private uint? _prevTurnCmd; private float? _prevForwardSpeed; private bool _prevRunHold; private uint? _prevLocalAnimCmd; // Heartbeat timer. private float _heartbeatAccum; public const float HeartbeatInterval = 0.2f; // 200ms public bool HeartbeatDue { get; private set; } public PlayerMovementController(PhysicsEngine physics) { _physics = physics; _body = new PhysicsBody { State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, }; // Default skills — tuned toward mid-retail feel. Real characters' // skills come from PlayerDescription (0xF7B0/0x0013) which we don't // parse yet; override via env vars: // ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL // K-fix6 (2026-04-26): bumped default jump skill from 200 → 300. // Retail formula: height = (skill/(skill+1300))*22.2 + 0.05 (extent=1): // skill=200 → 3.01m max (felt too low — user complaint) // skill=300 → 4.21m max (closer to a typical retail mid-tier // character's "I can clear that fence" hop) // Until #7 ships and PlayerDescription gives us the server's real // skill, this default is the right "feels like retail" baseline. int runSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_RUN_SKILL"), out var rs) ? rs : 200; int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300; _weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill); _motion = new MotionInterpreter(_body, _weenie); } public void SetCharacterSkills(int runSkill, int jumpSkill) { _weenie.SetSkills(runSkill, jumpSkill); } /// /// Wire the player's AnimationSequencer current cycle velocity into /// . When attached, /// get_state_velocity uses MotionData.Velocity * speedMod /// as the primary forward-axis drive, keeping the body's world velocity /// locked to the animation's baked-in root-motion velocity. /// /// /// Without this accessor, the decompiled constant path /// (RunAnimSpeed * ForwardSpeed) is used — matches retail only /// when the character's MotionTable happens to bake Velocity=4.0 on /// RunForward, which is true for Humanoid but not for arbitrary /// creatures. See /// for the full rationale. /// /// /// /// Called once from GameWindow.CreateAnimatedEntity after the /// player's AnimatedEntity.Sequencer is constructed. /// /// public void AttachCycleVelocityAccessor(Func accessor) { if (accessor is null) throw new ArgumentNullException(nameof(accessor)); _motion.GetCycleVelocity = accessor; } /// /// Apply a server-echoed run rate (ForwardSpeed from UpdateMotion) to the /// player's MotionInterpreter. The server broadcasts the real RunRate /// derived from the character's Run skill; wiring it here ensures /// get_state_velocity produces the correct speed instead of the default 1.0. /// public void ApplyServerRunRate(float forwardSpeed) { _motion.InterpretedState.ForwardSpeed = forwardSpeed; _motion.apply_current_movement(cancelMoveTo: false, allowJump: false); } public void SetPosition(Vector3 pos, uint cellId) { _body.Position = pos; CellId = cellId; // Treat as grounded after a server-side position snap. _body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable; _body.Velocity = Vector3.Zero; // Reset physics clock so any subsequent update_object calls start fresh. _body.LastUpdateTime = 0.0; } public MovementResult Update(float dt, MovementInput input) { // Portal-space guard: while teleporting, no input is processed and // no physics is resolved. Return a zero-movement result so the caller // can detect the frozen state (MotionStateChanged = false, no commands). if (State == PlayerState.PortalSpace) { return new MovementResult( Position: Position, CellId: CellId, IsOnGround: _body.OnWalkable, MotionStateChanged: false, ForwardCommand: null, SidestepCommand: null, TurnCommand: null, ForwardSpeed: null, SidestepSpeed: null, TurnSpeed: null); } // ── 1. Apply turning from keyboard + mouse ──────────────────────────── if (input.TurnRight) Yaw -= MotionInterpreter.WalkAnimSpeed * 0.5f * dt; // ~90°/s if (input.TurnLeft) Yaw += MotionInterpreter.WalkAnimSpeed * 0.5f * dt; Yaw -= input.MouseDeltaX * MouseTurnSensitivity; // Wrap yaw to [-PI, PI] so it doesn't grow unbounded. while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI; while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI; // Sync the body's orientation quaternion with our Yaw (rotation about Z). // Convention: Yaw=0 faces +X. Local body +Y is "forward", so we rotate // by (Yaw - PI/2) about Z to map local +Y → world (cos Yaw, sin Yaw, 0). _body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f); // ── 2. Set velocity via MotionInterpreter state machine ─────────────── // Determine the dominant forward/backward command and speed. uint forwardCmd; float forwardCmdSpeed; if (input.Forward) { forwardCmd = input.Run ? MotionCommand.RunForward : MotionCommand.WalkForward; // When running, use the PlayerWeenie's RunRate as ForwardSpeed. // The retail server computes this from Run skill + encumbrance and // broadcasts it in UpdateMotion, but it doesn't echo to the sender. // We compute locally using the same formula. if (input.Run && _weenie.InqRunRate(out float runRate)) forwardCmdSpeed = runRate; else forwardCmdSpeed = 1.0f; } else if (input.Backward) { forwardCmd = MotionCommand.WalkBackward; // K-fix3 (2026-04-26): backward also honors Run. Without // this, holding X with Run=true (default) still produced // walk-tier backward speed because forwardCmdSpeed was // hardcoded to 1.0. Now scale by runRate the same way // RunForward does. if (input.Run && _weenie.InqRunRate(out float runRateBack)) forwardCmdSpeed = runRateBack; else forwardCmdSpeed = 1.0f; } else { forwardCmd = MotionCommand.Ready; forwardCmdSpeed = 1.0f; } // Update interpreted motion state (needed for animation + server messages). _motion.DoMotion(forwardCmd, forwardCmdSpeed); // Sidestep. if (input.StrafeRight) _motion.DoInterpretedMotion(MotionCommand.SideStepRight, 1.0f, modifyInterpretedState: true); else if (input.StrafeLeft) _motion.DoInterpretedMotion(MotionCommand.SideStepLeft, 1.0f, modifyInterpretedState: true); else { _motion.StopInterpretedMotion(MotionCommand.SideStepRight, modifyInterpretedState: true); _motion.StopInterpretedMotion(MotionCommand.SideStepLeft, modifyInterpretedState: true); } // Only replace velocity with motion interpreter output when grounded. // While airborne, the physics body's integrated velocity (from LeaveGround) // persists — gravity pulls Z down, horizontal momentum is preserved. // Retail AC works this way: you maintain momentum in the air. if (_body.OnWalkable) { float savedWorldVz = _body.Velocity.Z; var stateVel = _motion.get_state_velocity(); float localY = 0f; float localX = 0f; // K-fix3 (2026-04-26): unified run-multiplier for backward // + strafe. Forward already scales correctly because it uses // stateVel.Y (which the motion state machine fed runRate // into via DoMotion). Backward + strafe bypass the state // machine and hardcoded speed; previously they capped at // walk speed regardless of Run, which made the ~2.4× // forward-vs-back/strafe ratio feel wrong. Now both scale // with the same runRate the forward branch uses. float runMul = 1.0f; if (input.Run && _weenie.InqRunRate(out float vrr)) runMul = vrr; if (input.Forward) localY = stateVel.Y; else if (input.Backward) localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * runMul); // Strafe scales with the same runMul so sidestep matches // the forward pace at run speed (retail uses speed=1.0 for // SideStep + the same hold-key-driven run/walk multiplier). if (input.StrafeRight) localX = MotionInterpreter.SidestepAnimSpeed * runMul; else if (input.StrafeLeft) localX = -MotionInterpreter.SidestepAnimSpeed * runMul; _body.set_local_velocity(new Vector3(localX, localY, savedWorldVz)); } // ── 3. Jump (charged) ───────────────────────────────────────────────── // Hold spacebar to charge (0→1 over JumpChargeRate seconds). // Release to execute: jump(extent) validates + sets JumpExtent, // then LeaveGround() applies the scaled velocity via get_leave_ground_velocity. float? outJumpExtent = null; Vector3? outJumpVelocity = null; if (input.Jump && _body.OnWalkable) { // Spacebar held and on the ground — accumulate charge. if (!_jumpCharging) { _jumpCharging = true; _jumpExtent = 0f; } _jumpExtent = MathF.Min(_jumpExtent + dt * JumpChargeRate, 1.0f); } else if (_jumpCharging) { // Spacebar released (or left ground during charge) — fire jump. var jumpResult = _motion.jump(_jumpExtent); if (jumpResult == WeenieError.None) { _motion.LeaveGround(); outJumpExtent = _jumpExtent; outJumpVelocity = _body.Velocity; // capture after LeaveGround applies it } _jumpCharging = false; _jumpExtent = 0f; } // ── 4. Integrate physics (gravity, friction, sub-stepping) ──────────── var preIntegratePos = _body.Position; _body.calc_acceleration(); _body.UpdatePhysicsInternal(dt); var postIntegratePos = _body.Position; // ── 5. Collision resolution via CTransition sphere-sweep ───────────── // The Transition system subdivides the movement from pre→post into // sphere-radius steps, testing terrain collision at each step. // Falls back to simple Z-snap if transition fails. var resolveResult = _physics.ResolveWithTransition( preIntegratePos, postIntegratePos, CellId, sphereRadius: 0.48f, // human player radius from Setup sphereHeight: 1.2f, // human player height from Setup stepUpHeight: StepUpHeight, stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight isOnGround: _body.OnWalkable, body: _body, // persist ContactPlane across frames for slope tracking // L.2c 2026-04-30: retail PhysicsGlobals.DefaultState includes // EdgeSlide, and PhysicsObj.get_object_info copies that bit into // OBJECTINFO. Keep it explicit here so edge/cliff handling runs // under the same flag profile as retail player movement. // // Commit C 2026-04-29 — local player is always IsPlayer. // The PK/PKLite/Impenetrable bits come from PlayerDescription's // PlayerKillerStatus property; not yet parsed (non-PK pair → walks // through other non-PK players, which is retail's default for // ACE's character creation defaults too). moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer | AcDream.Core.Physics.ObjectInfoState.EdgeSlide); // L.4-diag (2026-04-30): trace position transitions so we can see // whether the body is actually moving frame-to-frame on the steep // roof, or whether it's frozen at the impact point. if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1" && resolveResult.CollisionNormalValid) { Console.WriteLine( $"[steep-roof] FRAME pre=({preIntegratePos.X:F2},{preIntegratePos.Y:F2},{preIntegratePos.Z:F2}) " + $"post=({postIntegratePos.X:F2},{postIntegratePos.Y:F2},{postIntegratePos.Z:F2}) " + $"resolved=({resolveResult.Position.X:F2},{resolveResult.Position.Y:F2},{resolveResult.Position.Z:F2}) " + $"isOnGround={resolveResult.IsOnGround}"); } // Apply resolved position. _body.Position = resolveResult.Position; // L.3a (2026-04-30): retail wall-bounce / velocity reflection. // // Retail's CPhysicsObj::handle_all_collisions runs after every // SetPositionInternal. It reads the wall normal that the // transition's slide computed and reflects the body's velocity: // // v_new = v - (1 + elasticity) * dot(v, n) * n // // This is what gives retail its "bouncy" feel — fast head-on // jumps push the player back from the wall, glancing angles // produce a small deflection. acdream's transition resolver // SLID position correctly but never updated velocity, so the // player kept driving into walls until the controller's input // changed direction. Felt sticky / fragile. // // Suppression rule (apply_bounce): grounded movement on a wall // SHOULDN'T bounce — sliding along a corridor is expected. Only // airborne wall hits reflect. Mirrors retail's `var_10_1` guard // and ACE PhysicsObj.cs:2656-2660 `apply_bounce`. // // Inelastic flag (spell projectiles, missiles) zeros velocity // entirely instead of reflecting. The player never has it set. // // Sources: // acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions) // acclient.h:2834 (INELASTIC_PS = 0x20000) // ACE PhysicsObj.cs:2656-2721 (line-for-line port) // PhysicsGlobals.DefaultElasticity = 0.05f, MaxElasticity = 0.1f if (resolveResult.CollisionNormalValid) { bool prevOnWalkable = _body.OnWalkable; bool nowOnWalkable = resolveResult.IsOnGround; // apply_bounce: bounce ONLY when the body stays airborne both // before and after this step. That is: jumping into a wall // mid-flight, hitting a ceiling, etc. Specifically NOT: // // - prev grounded + now grounded → wall-slide along corridor // (bounce would feel sticky on every wall touch). // - prev airborne + now grounded → terrain landing // (terrain normal is mostly +Z; reflecting downward velocity // would push the body upward and prevent the landing snap // from firing — player perpetually micro-bouncing on the // floor instead of resting). // - prev grounded + now airborne → walked off cliff // (gravity should take over, not lateral bounce). // // Sledding mode reverts to retail's broader rule (bounce // unless both grounded), since sledding intentionally bounces // off ramps. // // This is more conservative than retail's strict // `!(prev && now && !sledding)` rule — retail bounces on // landing too, but at elasticity 0.05 the visual effect is // imperceptible there. acdream's per-frame architecture // amplifies the artifact (the post-reflection upward Z // defeats the controller's `Velocity.Z <= 0` landing-snap // gate), so we suppress it on landing to avoid the // micro-bounce death spiral. bool applyBounce = _body.State.HasFlag(PhysicsStateFlags.Sledding) ? !(prevOnWalkable && nowOnWalkable) : (!prevOnWalkable && !nowOnWalkable); // L.4-steep-landing-bounce (2026-04-30): also bounce on // landing IF the contact surface is upward-facing but // steeper than walkable (FloorZ ≈ 49°). Per retail and the // user's expectation: jumping onto a steep roof should NOT // result in a landing — the player should bounce off, keep // the falling animation, and slide off. // // Without this: the L.3a base rule suppresses the bounce on // landing transitions (prev air → now ground) to avoid // micro-bounce on flat terrain, but that suppression also // sticks the player to too-steep roofs they shouldn't land // on. This carve-out re-enables the bounce specifically for // steep upward-facing surfaces. // // Range `0 < N.Z < FloorZ` means "facing upward but too // steep" — excludes walls (N.Z ≈ 0) which are handled by the // existing prevAirborne+nowAirborne rule, and ceilings // (N.Z < 0) which the body shouldn't bounce off the same way. if (!applyBounce && resolveResult.CollisionNormalValid && resolveResult.CollisionNormal.Z > 0f && resolveResult.CollisionNormal.Z < PhysicsGlobals.FloorZ) { applyBounce = true; } // L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug. bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1"; if (diagSteep && resolveResult.CollisionNormalValid) { var n0 = resolveResult.CollisionNormal; var v0 = _body.Velocity; Console.WriteLine( $"[steep-roof] BOUNCE-CHECK applyBounce={applyBounce} " + $"prevWalk={prevOnWalkable} nowWalk={nowOnWalkable} " + $"N=({n0.X:F2},{n0.Y:F2},{n0.Z:F2}) FloorZ={PhysicsGlobals.FloorZ:F2} " + $"V=({v0.X:F2},{v0.Y:F2},{v0.Z:F2}) " + $"dot={Vector3.Dot(v0, n0):F3} " + $"isOnGround={resolveResult.IsOnGround}"); } if (applyBounce) { if (_body.State.HasFlag(PhysicsStateFlags.Inelastic)) { // Full stop on impact. Spell projectiles / missiles. _body.Velocity = Vector3.Zero; } else { var v = _body.Velocity; var n = resolveResult.CollisionNormal; float dotVN = Vector3.Dot(v, n); if (dotVN < 0f) { // Reflect the into-wall component back out. // Player elasticity is 0.05 → 105% of perpendicular // velocity reflects (subtle bounce). float k = -(dotVN * (_body.Elasticity + 1f)); _body.Velocity = v + n * k; if (diagSteep) { var v1 = _body.Velocity; Console.WriteLine( $"[steep-roof] BOUNCE-APPLIED V_after=({v1.X:F2},{v1.Y:F2},{v1.Z:F2}) k={k:F3}"); } } } } } bool justLanded = false; if (resolveResult.IsOnGround) { if (_body.Velocity.Z <= 0f) { // Grounded — snap to resolved position and land. bool wasAirborne = !_body.OnWalkable; _body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; _body.calc_acceleration(); if (_body.Velocity.Z < 0f) _body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f); if (wasAirborne) { _motion.HitGround(); justLanded = true; } } else { // Moving upward (jump) — stay airborne even though terrain is below. _body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); _body.calc_acceleration(); } } else { // No ground found — airborne. _body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); _body.calc_acceleration(); } _wasAirborneLastFrame = !_body.OnWalkable; CellId = resolveResult.CellId; // ── 6. Determine outbound motion commands ───────────────────────────── uint? outForwardCmd = null; float? outForwardSpeed = null; uint? outSidestepCmd = null; float? outSidestepSpeed = null; uint? outTurnCmd = null; float? outTurnSpeed = null; // Retail-faithful wire commands. ACE's MovementData constructor only // computes interpState.ForwardSpeed for WalkForward / WalkBackwards // (Network/Motion/MovementData.cs:104-119) — for any other command // the else-branch passes through without setting speed, so observers // dead-reckon at speed=0. The wire therefore must be: // - Forward (walk): WalkForward @ 1.0 // - Forward (run): WalkForward @ run_rate + HoldKey.Run // (ACE auto-upgrades to RunForward for observers) // - Backward: WalkBackward @ 1.0 // Our own local animation still wants the actual RunForward cycle // though — that's carried separately in LocalAnimationCommand below. uint? localAnimCmd = null; if (input.Forward) { outForwardCmd = MotionCommand.WalkForward; if (input.Run && _weenie.InqRunRate(out float runRate)) { outForwardSpeed = runRate; localAnimCmd = MotionCommand.RunForward; // local cycle is RunForward } else { outForwardSpeed = 1.0f; localAnimCmd = 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 // common.rs::locomotion_command_for_state). 0.5 was our earlier guess // and made strafing feel lethargic; the retail feel is full-speed // sidestep matching the walk forward pace. if (input.StrafeRight) { outSidestepCmd = MotionCommand.SideStepRight; outSidestepSpeed = 1.0f; } else if (input.StrafeLeft) { outSidestepCmd = MotionCommand.SideStepLeft; outSidestepSpeed = 1.0f; } // Turn commands from KEYBOARD only (A/D). Mouse turning is applied // directly to Yaw above and doesn't generate a turn command — if it // did, mouse jitter would flip turnCmd between TurnRight/TurnLeft // every frame, causing stateChanged=True on every frame and flooding // the server with MoveToState spam. if (input.TurnRight) { outTurnCmd = MotionCommand.TurnRight; outTurnSpeed = 1.0f; } else if (input.TurnLeft) { outTurnCmd = MotionCommand.TurnLeft; outTurnSpeed = 1.0f; } // ── 7. Detect motion state change ───────────────────────────────────── // Bug fix: ForwardCommand can stay the same (WalkForward) while ONLY // ForwardSpeed or the run-hold bit changes. If the user is already // walking (W held), then presses Shift, the outbound wire still has // ForwardCommand=WalkForward but outForwardSpeed jumps from 1.0 to // runRate. Without also tracking speed + hold-key here, no new // MoveToState is sent — the server keeps thinking the player walks, // and retail observers render walking animation despite the local // player's RunForward cycle. // // Similarly LocalAnimationCommand change (Walk→Run on local cycle) // must force a fresh outbound so ACE's BroadcastMovement re-runs // MovementData(this, moveToState) which only reads ForwardCommand + // ForwardSpeed + HoldKey to pick between WalkForward vs RunForward // for remote observers. bool runHold = input.Run; bool changed = outForwardCmd != _prevForwardCmd || outSidestepCmd != _prevSidestepCmd || outTurnCmd != _prevTurnCmd || !FloatsEqual(outForwardSpeed, _prevForwardSpeed) || runHold != _prevRunHold || localAnimCmd != _prevLocalAnimCmd; _prevForwardCmd = outForwardCmd; _prevSidestepCmd = outSidestepCmd; _prevTurnCmd = outTurnCmd; _prevForwardSpeed = outForwardSpeed; _prevRunHold = runHold; _prevLocalAnimCmd = localAnimCmd; static bool FloatsEqual(float? a, float? b) { if (a.HasValue != b.HasValue) return false; if (!a.HasValue || !b.HasValue) return true; return System.Math.Abs(a.Value - b.Value) < 1e-4f; } // ── 8. Heartbeat timer (only while moving) ──────────────────────────── bool isMoving = outForwardCmd is not null || outSidestepCmd is not null || outTurnCmd is not null; if (isMoving) { _heartbeatAccum += dt; HeartbeatDue = _heartbeatAccum >= HeartbeatInterval; if (HeartbeatDue) _heartbeatAccum = 0f; } else { _heartbeatAccum = 0f; HeartbeatDue = false; } // 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. bool anyDirectional = input.Forward || input.Backward || input.StrafeLeft || input.StrafeRight; float localAnimSpeed = (input.Run && anyDirectional) ? (_weenie.InqRunRate(out float vrrAnim) ? vrrAnim : 1f) : 1f; return new MovementResult( Position: Position, CellId: CellId, IsOnGround: _body.OnWalkable, MotionStateChanged: changed, ForwardCommand: outForwardCmd, SidestepCommand: outSidestepCmd, TurnCommand: outTurnCmd, ForwardSpeed: outForwardSpeed, SidestepSpeed: outSidestepSpeed, TurnSpeed: outTurnSpeed, IsRunning: input.Run && input.Forward, LocalAnimationCommand: localAnimCmd, LocalAnimationSpeed: localAnimSpeed, JustLanded: justLanded, JumpExtent: outJumpExtent, JumpVelocity: outJumpVelocity); } }