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, Vector3 RenderPosition, 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); // 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. /// /// 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 Vector3 RenderPosition => ComputeRenderPosition(); public uint CellId { get; private set; } /// /// Local-player entity id used to skip self-collision in the /// airborne sweep. GameWindow updates this whenever the local /// `+Acdream` entity (re)spawns. Default 0 = no filter (matches /// retail's CObjCell::find_obj_collisions self-skip when the /// caller's OBJECTINFO::object pointer is null). Without this the /// sweep collides with its own ShadowEntry registered at /// GameWindow.cs:2545 — see #42. /// public uint LocalEntityId { get; 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; /// /// 2026-05-16 — current contact plane (normal + distance) for the /// physics body. Exposed so the network outbound layer can stamp /// it into for retail's diff-driven /// AP cadence: SendPositionEvent re-sends if cell OR contact-plane /// changed since last_sent, per /// acclient_2013_pseudo_c.txt:700233 ShouldSendPositionEvent. /// public System.Numerics.Plane ContactPlane => _body.ContactPlane; // 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. // Cadence is 1.0 sec to match holtburger's // AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the retail trace // (2026-05-01 motion-trace findings.md): retail sends ~1 Hz at rest, // not the 5 Hz our pre-fix code used. Sending at 5 Hz was harmless // but wasteful and probably looked like jitter to observers. /// /// 2026-05-16 — retail-faithful AP cadence. Matches retail's /// CommandInterpreter::ShouldSendPositionEvent (acclient_2013_pseudo_c.txt /// at address 0x006b45e0) which gates on either (a) position-or-cell /// change since the last send, or (b) at-rest 1 sec heartbeat elapsed. /// `time_between_position_events` constant at 0x006b3efb = 1.0 sec. /// /// Old model: a 1 Hz idle / 10 Hz active flat accumulator. That /// missed retail's per-frame-while-moving behaviour and forced the /// four B.6 workarounds (arrival margin, re-send on arrival, AP /// flush, retry flag) to compensate for the lag in ACE's server-side /// WithinUseRadius poll. Replaced by diff-driven cadence below. /// public const float HeartbeatInterval = 1.0f; // retail 0x006b3efb private System.Numerics.Vector3 _lastSentPos; private uint _lastSentCellId; private System.Numerics.Plane _lastSentContactPlane; private float _lastSentTime; private bool _lastSentInitialized; private float _simTimeSeconds; public bool HeartbeatDue { get; private set; } /// Sim-time accumulator (advanced by dt at the top of Update). /// Exposed for the network outbound layer to stamp NotePositionSent. public float SimTimeSeconds => _simTimeSeconds; // L.5 retail physics-tick gate (2026-04-30). // // Retail's CPhysicsObj::update_object subdivides per-frame dt into // MinQuantum (1/30s) sized integration steps, SKIPPING entirely when // accumulated dt is below MinQuantum. The retail debugger trace // confirmed this: UpdatePhysicsInternal fires only ~61% as often as // update_object — i.e., retail's effective physics tick rate is 30Hz // even when the renderer runs at 60+Hz. // // Without this gate our acdream integrates at the full render rate // (60+Hz), which compresses bounce-energy / gravity-tangent // accumulation into half the time. Per-frame V grows ~2x faster than // retail's. On a steep-slope tangent that produces the wedge: V grows // tangent + huge while position reverts each frame, body locks in // place. Retail's slower integration cadence (and larger per-tick // position deltas) lets the body geometrically escape the tangent. // // Source: retail debugger trace 2026-04-30 // update_object = 40,960 calls // UpdatePhysicsInternal = 25,087 calls (61%) // ratio implies 39% of frames return early via the MinQuantum gate. // // ACE: PhysicsObj.UpdateObject (Physics.cs). // Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950). private float _physicsAccum; private Vector3 _prevPhysicsPos; private Vector3 _currPhysicsPos; // ── B.6 slice 2 (2026-05-14): local-player server-initiated auto-walk ── // When ACE sends a MoveToObject motion for the local player (out-of-range // Use / PickUp triggers ACE's server-side CreateMoveToChain), the wire // payload includes a destination, arrival predicates, and a run rate. // Retail's MovementManager::PerformMovement (0x00524440 case 6) runs a // LOCAL auto-walk in response: heading correction toward the target, // run-forward velocity at the wire's runRate, arrival detection via // MoveToManager::HandleMoveToPosition. Here we keep the active auto-walk // state and inject it into Update() as a synthesized Forward+Run input // so the existing motion-interpreter / body-velocity pipeline runs // unchanged. Spec: docs/superpowers/specs/2026-05-14-phase-b6-design.md. private bool _autoWalkActive; private Vector3 _autoWalkDestination; private float _autoWalkMinDistance; private float _autoWalkDistanceToObject; private bool _autoWalkMoveTowards; // 2026-05-16 (retail-faithful) — walk-vs-run is a ONE-SHOT // decision at chain start. Per user observation 2026-05-16: if // initial distance is at or above the walk-run threshold, the // body runs all the way to the target; otherwise it walks all // the way. No per-frame switching as the player closes distance. // // Formula matches retail's MovementParameters::get_command // (decomp 0x0052aa00, line 308000+): // running = (initialDist - distance_to_object) >= walk_run_threshhold // The "distance left to walk" (current minus use-radius) is // compared against the wire-supplied threshold (15m default, // retail constant at 0x005243b5). The retail function reads // `arg2` as the current distance but in practice is called at // chain setup with the initial distance, and the resulting // decision is cached for the rest of the chain — matching the // user-observed "run all the way / walk all the way" behaviour. private bool _autoWalkInitiallyRunning; /// /// True while a server-initiated auto-walk (MoveToObject inbound) is /// active on the local player. Update drives the body's velocity /// and motion state machine DIRECTLY from the wire-supplied path /// data, NOT via synthesized player-input. The /// motion-state-change detection downstream sees no user input /// during auto-walk, so no MoveToState wire packet is built — ACE's /// server-side MoveToChain can run uninterrupted until its callback /// fires. /// public bool IsServerAutoWalking => _autoWalkActive; // 2026-05-16 (issue #75) — tracks whether the auto-walk overlay is // actually advancing the body this frame. False during the // 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; // 2026-05-16 (issue #69 fix) — turn direction this frame. // +1 = rotating counter-clockwise (Yaw increasing) → TurnLeft cycle // -1 = rotating clockwise (Yaw decreasing) → TurnRight cycle // 0 = aligned or not turning // Drives the animation cycle override during turn-first phase so // the body plays the actual turn animation instead of statue-pivoting. private int _autoWalkTurnDirectionThisFrame; /// /// Fires once when an auto-walk reaches its destination naturally /// (i.e. called with /// reason="arrived"). Does NOT fire on user-input cancel or /// on a re-target (BeginServerAutoWalk overwriting state). /// /// /// Host () subscribes to re-send /// the Use/PickUp action that triggered the auto-walk — without /// this, ACE's server-side MoveToChain may have already timed out /// by the time our local body arrives, so the action wouldn't /// fire. Re-sending the action close-range hits ACE's WithinUseRadius /// fast-path and completes immediately. /// /// public event Action? AutoWalkArrived; 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); } /// /// B.6 slice 2 (2026-05-14). Install a server-initiated auto-walk /// against this body. will synthesize /// Forward+Run input and steer toward /// until the body reaches the /// arrival predicate (moveTowards: dist ≤ distanceToObject; /// !moveTowards: dist ≥ minDistance) or the user presses any /// movement key (which auto-cancels). /// /// /// Retail reference: MovementManager::PerformMovement /// (0x00524440) case 6 — unpacks the wire's target + /// origin + run rate and calls CPhysicsObj::MoveToObject on /// the local body. We do the equivalent at acdream's altitude: /// hold the destination + thresholds + run rate locally, let the /// existing per-tick motion machinery do the walking, and arrive /// when the horizontal distance hits the threshold. /// /// /// /// The run-rate parameter is the EFFECTIVE rate after the /// mtRun=0 fallback chain — the caller (GameWindow) is /// responsible for substituting a non-zero rate when ACE sends 0.0 /// on the wire, per the trace finding in the design spec. /// /// public void BeginServerAutoWalk( Vector3 destinationWorld, float minDistance, float distanceToObject, bool moveTowards, bool canCharge) { _autoWalkActive = true; _autoWalkDestination = destinationWorld; _autoWalkMinDistance = minDistance; _autoWalkDistanceToObject = distanceToObject; _autoWalkMoveTowards = moveTowards; // Issue #77 fix (2026-05-18) — retail-faithful walk-vs-run. // // Retail's MovementParameters::get_command (decomp 0x0052aa00) // gates run on the CanCharge flag (bit 0x10 of // MovementParameters). Cleared → fall through to the inner // walk_run_threshold check, which ACE's 15 m wire default + // 0.6 m use-radius makes practically always walk for any // chase under 15.6 m. Set → unconditional HoldKey_Run. // // ACE's Creature.SetWalkRunThreshold sets CanCharge when // (server-side player→target distance) >= WalkRunThreshold / // 2 (= 7.5 m for the 15 m default), and clears it otherwise. // The CanCharge bit IS the wire-side walk-vs-run answer; we // just relay it. // // Previously we hardcoded a 1.0 m threshold against // initialDist - distanceToObject, which forced run at any // chase past ~1.6 m — including the 3-5 m "walk range" the // user expected to walk in (issue #77 reproduction). Honoring // CanCharge restores the retail bucket: walk under ~7.5 m, // run beyond. _autoWalkInitiallyRunning = canCharge; } /// /// B.6 slice 2 (2026-05-14). Cancel any active server-initiated /// auto-walk. Idempotent. is logged when /// is on so /// the trace shows why the auto-walk ended. /// public void EndServerAutoWalk(string reason) { if (!_autoWalkActive) return; _autoWalkActive = false; if (PhysicsDiagnostics.ProbeAutoWalkEnabled) Console.WriteLine($"[autowalk-end] reason={reason}"); if (reason == "arrived") AutoWalkArrived?.Invoke(); } /// /// 2026-05-16. Called by the network outbound layer after every /// AutonomousPosition or MoveToState that carries the player's /// position. Resets the diff-driven heartbeat clock so the next /// `HeartbeatDue` evaluation requires either a fresh state change /// (cell, contact-plane, or frame) OR another full HeartbeatInterval. /// Mirrors retail's SendPositionEvent at /// acclient_2013_pseudo_c.txt:700345-700348 which updates /// `last_sent_position`, `last_sent_position_time`, AND /// `last_sent_contact_plane` after every send. /// public void NotePositionSent(System.Numerics.Vector3 worldPos, uint cellId, System.Numerics.Plane contactPlane, float nowSeconds) { _lastSentPos = worldPos; _lastSentCellId = cellId; _lastSentContactPlane = contactPlane; _lastSentTime = nowSeconds; _lastSentInitialized = true; } /// /// B.6 slice 2 (2026-05-14). If a server-initiated auto-walk is /// active, either cancel it (user pressed a movement key) or /// synthesize a Forward+Run input with stepped /// toward the destination. Returns the (possibly modified) input /// for the rest of to consume. /// /// /// Heading correction matches /// — ± /// snap-on-aligned, otherwise rotate at /// . Arrival /// predicate matches retail's /// MoveToManager::HandleMoveToPosition: chase arrives at /// distanceToObject; flee arrives at minDistance. /// /// /// /// 2026-05-16 (issue #75 refactor) — drive the body directly from /// the wire-supplied path data during server-initiated auto-walk, /// without synthesizing player-input. Replaces the earlier /// ApplyAutoWalkOverlay which returned a synthesized Forward+Run /// MovementInput; that synthesis leaked to the wire as an outbound /// MoveToState packet ("user is RunForward") which ACE read as /// user-took-manual-control and cancelled its own MoveToChain. The /// architecture now mirrors retail's MovementManager::PerformMovement /// case 6 (decomp 0x00524440): step the body's velocity + motion /// state directly; the user-input pipeline downstream sees no input /// because the user didn't press anything, so no MoveToState gets /// built. /// /// /// Returns true when this method consumed motion control for /// the frame (auto-walk active, no user override, no arrival). /// Caller () must skip the user-input motion + /// body-velocity sections to avoid them overriding the auto-walk's /// velocity assignment. /// /// private bool DriveServerAutoWalk(float dt, MovementInput input) { _autoWalkMovingForwardThisFrame = false; _autoWalkTurnDirectionThisFrame = 0; if (!_autoWalkActive) return false; // User-input cancellation. Any direct movement key takes over. // Mouse-only turning (no movement key) doesn't cancel — the // user might just be looking around mid-walk. bool userOverride = input.Forward || input.Backward || input.StrafeLeft || input.StrafeRight || input.TurnLeft || input.TurnRight; if (userOverride) { EndServerAutoWalk("user-input"); return false; } // Horizontal distance to target — server owns Z, our local body // Z snaps to UpdatePosition broadcasts when ACE sends them. var pos = _body.Position; float dx = _autoWalkDestination.X - pos.X; float dy = _autoWalkDestination.Y - pos.Y; float dist = MathF.Sqrt(dx * dx + dy * dy); // Arrival predicate. With the 10 Hz heartbeat from 301281d the // server-side Player.Location tracks our body within ~100 ms, so // the previous "subtract 0.2 m safety margin" workaround is no // longer needed. Tiny 0.05 m margin remains to absorb the // sub-tick race between local arrival-fire and the next // heartbeat's outbound packet. // // ARRIVAL IS GATED ON ALIGNMENT: we only end the auto-walk once // the body is BOTH within use-radius AND facing the target. // Without the alignment gate, a Use on a close target while // facing away would end immediately and the body wouldn't turn // at all (user feedback 2026-05-15: 'when I'm close I'm not // facing'). The alignment check is computed below in the same // block as the heading-step; we defer the arrival fire-and-end // until after we've inspected `aligned`. float arrivalThreshold = _autoWalkMoveTowards ? _autoWalkDistanceToObject : _autoWalkMinDistance; // 2026-05-16 — retail "stop at the radius" semantics. // Previously had a 0.05 m TinyMargin inside the threshold to // ensure ACE's server-side WithinUseRadius poll saw us inside // the radius before our next AP heartbeat. With the // diff-driven AP cadence (Task B2) ACE sees the final position // the same frame we arrive — no margin needed. Retail's // arrival check is `dist <= radius` exact at // CMotionInterp::apply_interpreted_movement integration. bool withinArrival = (_autoWalkMoveTowards && dist <= arrivalThreshold) || (!_autoWalkMoveTowards && dist >= arrivalThreshold + RemoteMoveToDriver.ArrivalEpsilon); // Step Yaw toward target. Convention from Update line 364: // _body.Orientation = Quaternion.CreateFromAxisAngle(Z, Yaw - π/2), // so local-forward (+Y) maps to world (cos Yaw, sin Yaw, 0). // Therefore Yaw that faces (dx,dy) is atan2(dy, dx). // // User feedback (2026-05-15): 'I should face that object and then // start moving. Now it starts running before facing is complete.' // Track the current heading delta — if we're more than the // walk-while-turning threshold off, suppress Forward this frame // so the body turns IN PLACE first. Once we're within the // threshold, the synthesised Forward+Run kicks in below. bool aligned = true; bool walkAligned = true; if (dist > 1e-4f) { float desiredYaw = MathF.Atan2(dy, dx); float delta = desiredYaw - Yaw; while (delta > MathF.PI) delta -= 2f * MathF.PI; while (delta < -MathF.PI) delta += 2f * MathF.PI; // Retail-faithful local rotation: rotate continuously at // TurnRate, never snap until overshoot would occur. Retail's // MoveToManager::HandleTurnToHeading (0x0052a0c0) only snaps // when heading_greater() detects we've crossed the target — // there's no "snap when close" tolerance band. The earlier // 20° snap was borrowed wrongly from RemoteMoveToDriver // (which is the sparse-update-fudge path for remotes). // // MathF.Min(|delta|, maxStep) naturally clamps the final // fractional step to exactly delta, so we land on the // target heading without overshoot. // 2026-05-16 — retail-faithful turn rate. Auto-walk's // run/walk decision (one-shot at chain start) drives the // turn rate: running rotation is 50% faster per // run_turn_factor at retail 0x007c8914. float maxStep = RemoteMoveToDriver.TurnRateFor(_autoWalkInitiallyRunning) * dt; float yawStep = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep); Yaw += yawStep; while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI; while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI; // 2026-05-16 (issue #69) — record rotation direction so the // animation override can pick the TurnLeft/TurnRight cycle. // Sign convention matches user-driven A/D in Update: // yawStep > 0 ⇔ TurnLeft (Yaw increases) // yawStep < 0 ⇔ TurnRight (Yaw decreases) // Small dead-zone avoids flickering between Turn cycles // when the residual delta is effectively zero. if (MathF.Abs(yawStep) > 1e-5f) _autoWalkTurnDirectionThisFrame = yawStep > 0f ? +1 : -1; // Two alignment thresholds: // walkWhileTurning (30°): outside this, body turns in place. // Inside, body walks forward while // finishing residual alignment. // fullyAligned (5°): the arrival-fire alignment. ACE // rotates server-side via Rotate(target) // BEFORE invoking the Use callback — // user reported 'it does not face it // completely', so the final-alignment // check must be tighter than the // walking gate. const float WalkWhileTurningRad = 30f * MathF.PI / 180f; const float FullyAlignedRad = 5f * MathF.PI / 180f; walkAligned = MathF.Abs(delta) <= WalkWhileTurningRad; aligned = MathF.Abs(delta) <= FullyAlignedRad; } // End the auto-walk once the body is BOTH within use radius // AND aligned with the target. This is the alignment-gated // arrival the comment above flagged: a close-range Use on a // target behind the player still rotates the body first. if (withinArrival && aligned) { EndServerAutoWalk("arrived"); return false; } // Walk vs run uses the one-shot decision from BeginServerAutoWalk // (initial distance minus use-radius vs walkRunThreshold). // Held for the rest of the auto-walk so the body runs all // the way to a far target, or walks all the way to a near // one — matching user-observed retail behaviour. bool shouldRun = _autoWalkInitiallyRunning; // Turn-first gate: if not yet within the 30° walking band, // suppress forward motion so the body turns in place rather // than walking an arc. Also suppress when already within // arrival — we just turned to face it; no need to step forward // into it. bool moveForward = walkAligned && !withinArrival; if (!moveForward) { // Turn-in-place phase. Two sub-cases land here: // (a) initial turn — body must rotate to face the target // before we drive forward (walkAligned == false at chain // start, body is stationary). // (b) overshoot recovery — body crossed the destination, so // desiredYaw flipped ~180° and walkAligned dropped to // false; body needs to turn around before walking back. // (c) settling — body is within use-radius but not aligned // enough to fire arrival (withinArrival == true, // !aligned); body holds position while finishing rotation // so the arrival predicate fires on the next tick. // // Issue #77 fix: explicitly zero horizontal velocity. Without // this, in case (b) the body keeps the prior frame's running // velocity (RunAnimSpeed × runRate ≈ 11 m/s) and slides past // the destination by several meters before the turn-around // rotation completes — the "runs and slides away, runs back, // picks up" symptom reported in issue #77 / bug B. Cases (a) // and (c) zero a velocity that's already zero, so the change // is a no-op there. // // 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. _motion.DoMotion(MotionCommand.Ready, 1.0f); if (_body.OnWalkable) { float savedWorldVz = _body.Velocity.Z; _body.set_local_velocity(new Vector3(0f, 0f, savedWorldVz)); } return true; } // Drive motion state machine + body velocity directly. This // mirrors what the user-input section would have done with // synthesized Forward+Run, but without putting anything into // MovementInput — so the outbound-packet pipeline never builds // a MoveToState packet for auto-walk frames. uint forwardCmd; float forwardCmdSpeed; if (shouldRun && _weenie.InqRunRate(out float runRate)) { // Wire-compatible: WalkForward command @ runRate triggers // ACE's auto-upgrade to RunForward for observers. Same // shape as the user-input section's running path. forwardCmd = MotionCommand.WalkForward; forwardCmdSpeed = runRate; } else { forwardCmd = MotionCommand.WalkForward; forwardCmdSpeed = 1.0f; } _autoWalkMovingForwardThisFrame = true; // Update interpreted motion state — drives the animation cycle // via UpdatePlayerAnimation downstream + the MotionInterpreter's // state-velocity getter (used for our velocity assignment below). _motion.DoMotion(forwardCmd, forwardCmdSpeed); // Set body velocity directly. Only meaningful when grounded; // mirror the user-input section's `if (_body.OnWalkable)` gate // so we don't override gravity/jump velocity mid-air. if (_body.OnWalkable) { float savedWorldVz = _body.Velocity.Z; var stateVel = _motion.get_state_velocity(); _body.set_local_velocity(new Vector3(0f, stateVel.Y, savedWorldVz)); } return true; } // L.2a slice 1 (2026-05-12): centralized CellId mutation so the // [cell-transit] probe fires from a single chokepoint. Both the // server-snap path (SetPosition) and the per-frame resolver path // route through here. When PhysicsDiagnostics.ProbeCellEnabled is // off this collapses to a single bool-compare + assignment — zero // logging cost. private void UpdateCellId(uint newCellId, string reason) { if (newCellId != CellId && PhysicsDiagnostics.ProbeCellEnabled) { var pos = _body.Position; Console.WriteLine(System.FormattableString.Invariant( $"[cell-transit] 0x{CellId:X8} -> 0x{newCellId:X8} pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) reason={reason}")); } CellId = newCellId; } public void SetPosition(Vector3 pos, uint cellId) { _body.Position = pos; _prevPhysicsPos = pos; _currPhysicsPos = pos; UpdateCellId(cellId, "teleport"); // 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; _physicsAccum = 0f; } private Vector3 ComputeRenderPosition() { float alpha = Math.Clamp(_physicsAccum / PhysicsBody.MinQuantum, 0f, 1f); return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha); } public MovementResult Update(float dt, MovementInput input) { _simTimeSeconds += dt; // 2026-05-16 (issue #75 refactor): server-initiated auto-walk // drives the body's velocity + motion state machine DIRECTLY. // When _autoWalkActive, DriveServerAutoWalk steps Yaw, computes // velocity from wire-supplied runRate, calls _motion.DoMotion, // and sets _body.set_local_velocity. The user-input motion + // velocity sections below are SKIPPED so they don't override // the auto-walk's assignments. Critically, no synthesized input // gets put back into `input` — the outbound-packet pipeline at // GameWindow.cs:6410 sees user-input null/Ready throughout the // auto-walk and never builds a MoveToState packet, leaving // ACE's server-side MoveToChain to run uninterrupted until its // TryUseItem/TryPickUp callback fires. Retail equivalent: // MovementManager::PerformMovement case 6 (decomp 0x00524440) // calls CPhysicsObj::MoveToObject server-side; the local body // is moved without ever touching CommandInterpreter input. bool autoWalkConsumedMotion = DriveServerAutoWalk(dt, 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, RenderPosition: RenderPosition, 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 ──────────────────────────── // 2026-05-16 — retail-faithful turn rate. // Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt // - CMotionInterp::apply_run_to_command 0x00527be0 // multiplies turn_speed by run_turn_factor (1.5) under // HoldKey.Run on TurnRight/TurnLeft commands. // - Base rate ±π/2 rad/s comes from add_motion 0x005224b0 // with HasOmega-cleared MotionData fallback. // Effective: walking ≈ 90°/s, running ≈ 135°/s. // Previously: WalkAnimSpeed*0.5 ≈ 89.4°/s — coincidentally // close to retail walking but no run differentiation. float keyboardTurnRate = RemoteMoveToDriver.TurnRateFor(input.Run); if (input.TurnRight) Yaw -= keyboardTurnRate * dt; if (input.TurnLeft) Yaw += keyboardTurnRate * 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 ─────────────── // 2026-05-16 (issue #75): skip when DriveServerAutoWalk owns // motion control this frame — it has already called // _motion.DoMotion + _body.set_local_velocity from the auto- // walk's path data + runRate. Running this section would // overwrite the auto-walk velocity with the user-input // (Ready/Stand) velocity, freezing the body. if (!autoWalkConsumedMotion) { // 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)); } } // end of `if (!autoWalkConsumedMotion)` — section 2 // ── 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) { // Capture jump_v_z BEFORE LeaveGround() — that call resets // JumpExtent back to 0 (faithful to retail's FUN_00529710), // after which get_jump_v_z() returns 0 because the extent // gate at the top of the function fires. float jumpVz = _motion.get_jump_v_z(); _motion.LeaveGround(); outJumpExtent = _jumpExtent; // BODY-LOCAL jump-launch velocity, computed directly from input. // // Why not read _body.Velocity? Because _motion.LeaveGround() // routes through get_leave_ground_velocity → get_state_velocity, // which is a faithful port of retail's FUN_00528960. Retail's // version only handles WalkForward (0x45000005) / RunForward // (0x44000007) / SideStepRight (0x6500000F); WalkBackwards // and SideStepLeft return zero. Retail papers over this in // adjust_motion (FUN_00528010) by translating // WalkBackwards → WalkForward + speed × -0.65 // SideStepLeft → SideStepRight + speed × -1 // before they reach InterpretedState — but we don't yet port // adjust_motion, so InterpretedState holds the un-translated // command and get_state_velocity returns (0,0,0) for it. // LeaveGround then writes (0,0,jumpZ) to the body, wiping the // correct strafe/backward velocity the controller had just set // a few lines up. Result: backward/strafe jumps go straight up. // // Until adjust_motion is ported, we mirror the grounded-velocity // computation from the block above and stuff the result into // outJumpVelocity directly. Local frame: +Y forward, +X right, // +Z up — matches retail's body-frame convention. Server // rotates body→world on receive, so observers see the jump // in the correct world direction. float jumpRunMul = 1.0f; if (input.Run && _weenie.InqRunRate(out float jvrr)) jumpRunMul = jvrr; // Forward uses get_state_velocity (which knows Walk vs Run vs // animation-cycle pacing). Backward / Strafe use the same // hardcoded scaled formulas the grounded-velocity block above // uses (lines 397-408). float localY = 0f; if (input.Forward) { var stateVel = _motion.get_state_velocity(); localY = stateVel.Y; } else if (input.Backward) { localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * jumpRunMul); } float localX = 0f; if (input.StrafeRight) localX = MotionInterpreter.SidestepAnimSpeed * jumpRunMul; else if (input.StrafeLeft) localX = -MotionInterpreter.SidestepAnimSpeed * jumpRunMul; outJumpVelocity = new Vector3(localX, localY, jumpVz); // Local-prediction fix: LeaveGround above wrote (0, 0, jumpZ) // to the body for backward/strafe-left (same get_state_velocity // zero-for-non-canonical-motion bug as on the wire side). // Push the corrected body-local velocity back so the local // 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); } _jumpCharging = false; _jumpExtent = 0f; } // ── 4. Integrate physics (gravity, friction, sub-stepping) ──────────── // // L.5 retail-physics-tick gate (2026-04-30): retail's CPhysicsObj:: // update_object skips integration when accumulated dt is below // MinQuantum (1/30 s). Effective physics rate is 30 Hz even at 60+ Hz // render. We accumulate per-frame dt and only integrate (with the // accumulated dt) when the threshold is reached. See _physicsAccum // declaration for the full retail trace evidence. var preIntegratePos = _body.Position; bool physicsTickRan = false; Vector3 oldTickEndPos = _currPhysicsPos; _physicsAccum += dt; if (_physicsAccum > PhysicsBody.HugeQuantum) { // Stale frame (debugger break, GC pause). Discard accumulated dt. _physicsAccum = 0f; _prevPhysicsPos = _body.Position; _currPhysicsPos = _body.Position; } else if (_physicsAccum >= PhysicsBody.MinQuantum) { // Integrate accumulated dt, clamped to MaxQuantum so a long // pause doesn't produce one giant integration step. float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum); _body.calc_acceleration(); _body.UpdatePhysicsInternal(tickDt); _physicsAccum -= tickDt; physicsTickRan = true; } // Else: dt below MinQuantum threshold — skip integration. Position // and velocity remain unchanged; Resolve below runs as a zero-distance // sphere sweep (no collision possible) and the rest of the frame // (motion commands, animation, return) runs normally. 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, // Fix #42: skip self in FindObjCollisions. Wired by GameWindow // when the local player entity spawns (or stays 0 in tests, in // which case there's no registered ShadowEntry to collide with // anyway). movingEntityId: LocalEntityId); // 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 (AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled && 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; if (physicsTickRan) { _prevPhysicsPos = oldTickEndPos; _currPhysicsPos = _body.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-diag (2026-04-30): per-frame bounce trace for steep-roof bug. bool diagSteep = AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled; 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; UpdateCellId(resolveResult.CellId, "resolver"); // ── 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 (always while in-world, not just while moving) ─ // 2026-05-16 (closes #74) — retail-faithful AP cadence per // CommandInterpreter::ShouldSendPositionEvent at // acclient_2013_pseudo_c.txt:700233-700285. Two-branch: // // Branch 1 — interval NOT yet elapsed (< 1 sec since last // send): send only if cell changed OR contact-plane changed // (mid-walk events that matter — stair / hill / cell cross). // // Branch 2 — interval HAS elapsed (>= 1 sec): send only if // cell OR position frame changed. Truly idle = no send // (retail's `last_sent.frame == player.frame` check at // acclient_2013_pseudo_c.txt:700248-700265). // // SendPositionEvent (line 700327) gates the actual send on // (state & 1) != 0 && (state & 2) != 0 — Contact AND // OnWalkable both set. We mirror that gate so airborne and // wall-contact-without-walkable suppress AP entirely; // MoveToState carries jump/fall snapshots while airborne. // // Effective rates: // Truly idle (grounded, no movement) : 0 Hz // Smooth movement (no cell/plane changes) : ~1 Hz (interval) // Cell crossings + stair/hill steps : per-event // Airborne : 0 Hz // // Bootstrap: when NotePositionSent has never been called // (_lastSentInitialized=false), every state-changed branch is // forced true so the first AP gets a chance to fire. bool intervalElapsed = !_lastSentInitialized || (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval; bool cellChanged = !_lastSentInitialized || _lastSentCellId != CellId; bool planeChanged = !_lastSentInitialized || !ApproxPlaneEqual(_lastSentContactPlane, _body.ContactPlane); bool frameChanged = !_lastSentInitialized || !ApproxPositionEqual(_lastSentPos, _body.Position); bool sendThisFrame = intervalElapsed ? (cellChanged || frameChanged) : (cellChanged || planeChanged); // Grounded-on-walkable gate per acclient_2013_pseudo_c.txt:700327 // (`(state & 1) != 0 && (state & 2) != 0`). Both flags must be // set simultaneously, NOT a bitwise-OR mask test. bool groundedOnWalkable = _body.InContact && _body.OnWalkable; 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. 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) { if (_autoWalkInitiallyRunning && _weenie.InqRunRate(out float autoWalkRunRate)) { localAnimCmd = MotionCommand.RunForward; localAnimSpeed = autoWalkRunRate; } else { localAnimCmd = MotionCommand.WalkForward; localAnimSpeed = 1f; } } else if (_autoWalkTurnDirectionThisFrame != 0) { localAnimCmd = _autoWalkTurnDirectionThisFrame > 0 ? MotionCommand.TurnLeft : MotionCommand.TurnRight; localAnimSpeed = 1f; } return new MovementResult( Position: Position, RenderPosition: RenderPosition, CellId: CellId, IsOnGround: _body.OnWalkable, MotionStateChanged: changed, ForwardCommand: outForwardCmd, SidestepCommand: outSidestepCmd, TurnCommand: outTurnCmd, ForwardSpeed: outForwardSpeed, SidestepSpeed: outSidestepSpeed, TurnSpeed: outTurnSpeed, // Run hold-key applies to ANY active directional axis, not just // forward (per holtburger's build_motion_state_raw_motion_state: // "uses the same value for every active per-axis hold key"). The // pre-fix condition `input.Run && input.Forward` made strafe-run // and backward-run incorrectly broadcast as walk to observers, // 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); } /// /// 2026-05-16. Position-equality test for diff-driven AP cadence. /// Retail uses Frame::is_equal at acclient_2013_pseudo_c.txt:700263 /// which is essentially exact float comparison after a memcmp of /// the frame struct. For floating-point safety we use a tiny epsilon /// — sub-millimeter — that's well below any movement we'd want to /// suppress sending for. /// private static bool ApproxPositionEqual( System.Numerics.Vector3 a, System.Numerics.Vector3 b) { const float Epsilon = 0.001f; // 1 mm return MathF.Abs(a.X - b.X) < Epsilon && MathF.Abs(a.Y - b.Y) < Epsilon && MathF.Abs(a.Z - b.Z) < Epsilon; } /// /// 2026-05-16. Contact-plane-equality test for retail's /// sub-interval AP gate. Retail's SendPositionEvent stores /// last_sent_contact_plane and ShouldSendPositionEvent re-sends /// during the sub-interval window if the plane has changed (e.g., /// player stepped onto stairs / a hill — same cell but different /// contact normal). Tiny epsilon on normal + distance covers /// floating-point noise from the physics integration. /// private static bool ApproxPlaneEqual( System.Numerics.Plane a, System.Numerics.Plane b) { const float NormalEpsilon = 1e-4f; const float DistanceEpsilon = 0.001f; return MathF.Abs(a.Normal.X - b.Normal.X) < NormalEpsilon && MathF.Abs(a.Normal.Y - b.Normal.Y) < NormalEpsilon && MathF.Abs(a.Normal.Z - b.Normal.Z) < NormalEpsilon && MathF.Abs(a.D - b.D) < DistanceEpsilon; } }