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;
}
}