acdream/src/AcDream.App/Input/PlayerMovementController.cs
Erik 7b7ffcd278 fix(gameplay): reconcile wield ownership and target facing
Preserve PlayerDescription inventory/equipment ownership across authoritative manifest replacement, make weapon switching and combat/UI consumers read the same canonical object state, and carry the complete outbound player position frame across landblocks.

Route target-facing and mouse-look through the shared MovementManager and MotionInterpreter completion owner. Match retail input aggregation, toggle ordering, turn/sidestep remapping, per-axis hold keys, and synchronous movement publication without render-only heading state.

Initialize the live streaming origin from the first accepted canonical player Position, defer other projections until that origin exists, and retain logical entity identity through hydration.

Advance the project ledger from completed M2 to active M3, synchronize CLAUDE.md/AGENTS.md and durable memory, and record the next cast-lifecycle, spellbook/enchantment, and two-client portal gates.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-15 08:19:23 +02:00

1895 lines
91 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.App.Input;
/// <summary>
/// Input state for a single frame of player movement.
/// </summary>
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);
/// <summary>
/// Read-only presentation snapshot of retail's pending jump build. The movement
/// controller remains the sole owner of charge timing; retained UI only projects
/// this state into <c>gmPowerbarUI</c>.
/// </summary>
public readonly record struct JumpChargeSnapshot(bool IsCharging, float Power);
/// <summary>
/// Result of a single frame's movement update.
///
/// <para>
/// <b>Wire vs. local animation command.</b> ACE's <c>MovementData</c>
/// (<c>ACE.Server/Network/Motion/MovementData.cs</c>) only computes
/// <c>interpState.ForwardSpeed</c> for raw <c>WalkForward</c>/
/// <c>WalkBackwards</c> — on every other command the <c>else</c> branch
/// passes through command without setting speed, leaving observers with
/// <c>speed=0</c>. The client therefore has to send <c>WalkForward</c>
/// (with <c>HoldKey.Run</c> for running) and let ACE auto-upgrade to
/// <c>RunForward</c> for broadcast. But the LOCAL view wants the run
/// cycle immediately, so we carry a separate
/// <see cref="LocalAnimationCommand"/> for the player's own renderer.
/// </para>
/// <para>
/// <see cref="IsRunning"/> — 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).
/// </para>
/// </summary>
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,
// 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.
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.
// Retail updates mouse-origin turn motions continuously but sends the
// resulting movement state only on ToggleMouseLook start/stop and the
// CameraSet half-second cadence. Keep packet ownership separate from the
// local motion-state change so mouse sampling cannot flood the server.
bool ShouldSendMovementEvent = false,
// CameraSet::Rotate always calls MovePlayer with holdRun=true for the turn
// axis, independently of the player's ordinary walk/run toggle.
bool TurnUsesRunHold = false,
// CameraInstantMouseLook remaps keyboard turn to sidestep with the same
// per-axis Run hold, independently of the global walk/run toggle.
bool SidestepUsesRunHold = false,
// The host acknowledges this clock/queue only after the MoveToState has
// actually been put on the wire.
bool IsMouseLookMovementEvent = false);
/// <summary>
/// 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.
/// </summary>
public enum PlayerState { InWorld, PortalSpace }
/// <summary>
/// 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.
/// </summary>
public sealed class PlayerMovementController
{
private readonly PhysicsEngine _physics;
private readonly PhysicsBody _body;
private readonly MotionInterpreter _motion;
private readonly PlayerWeenie _weenie;
/// <summary>
/// Maximum Z increase per movement step before the move is rejected.
/// Retail's <c>step_up_height</c> 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 <c>Setup.StepUpHeight</c> set
/// in GameWindow.cs at world-entry time.
/// </summary>
public float StepUpHeight { get; set; } = 0.4f;
/// <summary>
/// L.2.3a (2026-04-29): how far below the foot the step-down probe
/// reaches when transitioning between surfaces. Retail's
/// <c>step_down_height</c> 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.
/// </summary>
public float StepDownHeight { get; set; } = 0.4f;
/// <summary>
/// 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.
/// </summary>
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; }
public AcDream.Core.Physics.Position CellPosition => _body.CellPosition;
/// <summary>
/// Returns retail's canonical outbound <c>Position</c>: the physics body's
/// carried cell id plus its landblock-local frame origin. Retail
/// <c>CommandInterpreter::SendMovementEvent @ 0x006B4680</c> and
/// <c>SendPositionEvent @ 0x006B4770</c> serialize
/// <c>CPhysicsObj::m_position</c> directly; render/streaming origins are not
/// part of the protocol frame.
/// </summary>
internal bool TryGetOutboundPosition(
Quaternion rotation,
out uint cellId,
out Vector3 localOrigin)
{
AcDream.Core.Physics.Position canonical = _body.CellPosition;
cellId = canonical.ObjCellId;
localOrigin = canonical.Frame.Origin;
return PositionFrameValidation.IsValid(cellId, localOrigin, rotation);
}
/// <summary>
/// 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.
/// </summary>
public uint LocalEntityId { get; set; }
/// <summary>
/// Applies the canonical server PhysicsState to the local body. Retail's
/// <c>CPhysicsObj::SetState</c> replaces these persistent bits before its
/// next acceleration/integration decision.
/// </summary>
public void ApplyPhysicsState(PhysicsStateFlags state)
{
_body.State = state;
_body.calc_acceleration();
}
public bool IsAirborne => !_body.OnWalkable;
/// <summary>
/// Current vertical (Z-axis) velocity of the physics body.
/// Positive = rising, negative = falling. Exposed for tests and HUD.
/// </summary>
public float VerticalVelocity => _body.Velocity.Z;
/// <summary>Full 3D world-space velocity of the physics body. Exposed for diagnostic logging.</summary>
public Vector3 BodyVelocity => _body.Velocity;
/// <summary>
/// 2026-05-16 — current contact plane (normal + distance) for the
/// physics body. Exposed so the network outbound layer can stamp
/// it into <see cref="NotePositionSent"/> for retail's diff-driven
/// AP cadence: SendPositionEvent re-sends if cell OR contact-plane
/// changed since last_sent, per
/// <c>acclient_2013_pseudo_c.txt:700233 ShouldSendPositionEvent</c>.
/// </summary>
public System.Numerics.Plane ContactPlane => _body.ContactPlane;
// Jump charge state.
private bool _jumpCharging;
private float _jumpExtent;
/// <summary>
/// Current retail jump-powerbar state. Power is always zero when no jump is
/// pending and otherwise lies in [0,1].
/// </summary>
public JumpChargeSnapshot JumpCharge
=> new(_jumpCharging, _jumpCharging ? _jumpExtent : 0f);
// Matching v11.4186 x86 resolves GetPowerBarLevel's collapsed x87
// operands: ATTACK_POWERUP_TIME=1.0 s, DUAL_WIELD_POWERUP_TIME=0.8 s.
// Jump uses the same shared powerbar function, so its normal fill rate is
// 1 extent/s and DualWieldCombat is 1/0.8 = 1.25 extent/s.
private const float JumpChargeRate =
1f / (float)AcDream.Core.Combat.CombatInputPlanner.AttackPowerUpSeconds;
private const float DualWieldJumpChargeRate =
1f / (float)AcDream.Core.Combat.CombatInputPlanner.DualWieldPowerUpSeconds;
// 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 (wire cadence).
private uint? _prevForwardCmd;
private uint? _prevSidestepCmd;
private uint? _prevTurnCmd;
private float? _prevForwardSpeed;
private bool _prevRunHold;
// R3-W6: previous frame's HELD-KEY state — the edge detector feeding
// retail's DoMotion/StopMotion/set_hold_run calls (retail's
// CommandInterpreter altitude: motion dispatch happens on key EDGES,
// never level-triggered per-frame).
private bool _prevForwardHeld;
private bool _prevBackwardHeld;
private bool _prevStrafeLeftHeld;
private bool _prevStrafeRightHeld;
private bool _prevTurnLeftHeld;
private bool _prevTurnRightHeld;
private bool _prevRunHeld;
private bool _hasInputSnapshot;
// Retail CameraSet::ToggleMouseLook / Rotate (0x00457490 / 0x00458310).
// Mouse look owns a transient turn command in the SAME MotionInterpreter
// as keyboard and server turns; it never writes Yaw directly. The latest
// filtered sample replaces the preceding sample, matching CameraSet's
// per-mouse-message MovePlayer call.
private bool _mouseLookActive;
private bool _mouseTurnSamplePending;
private float _mouseTurnAdjustment;
private uint? _activeInputTurnCommand;
private float _activeInputTurnSpeed;
private bool _activeInputTurnFromMouse;
private uint? _activeInputSidestepCommand;
private bool _activeInputSidestepUsesRunHold;
private bool _mouseMovementEventCandidate;
private bool _mouseMovementEventPending;
private float _lastMouseMovementEventTime;
private bool _controlledByServer = true;
/// <summary>
/// Retail's mouse-look movement report interval from
/// <c>CameraSet::Rotate</c> and <c>MouseLookHandler</c>.
/// </summary>
public const float MouseMovementEventInterval = 0.5f;
private const float MouseTurnDeadZone = 0.02f;
private const float MouseTurnSpeedScale = 2.0f;
private const float MouseTurnMaximumSpeed = 1.5f;
// Position-event comparison interval. Retail does not send an idle packet
// every second: after the interval elapses it compares the complete frame
// and sends only when the cell, origin, or orientation changed.
/// <summary>
/// 2026-05-16 — retail-faithful AP cadence. Matches retail's
/// CommandInterpreter::ShouldSendPositionEvent (acclient_2013_pseudo_c.txt
/// at address 0x006b45e0). Inside the interval it reacts to cell/contact
/// changes; after the interval it compares cell plus the complete frame.
/// `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.
/// </summary>
public const float HeartbeatInterval = 1.0f; // retail 0x006b3efb
private AcDream.Core.Physics.Position _lastSentPosition;
private System.Numerics.Plane _lastSentContactPlane;
private float _lastSentTime;
private bool _lastSentInitialized;
private float _simTimeSeconds;
/// <summary>Sim-time accumulator (advanced by dt at the top of Update).
/// Exposed for the network outbound layer to stamp NotePositionSent.</summary>
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;
// ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ──
// The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase,
// 30° walk-while-turning band, one-shot walk/run decision, invented
// arrival epsilon — registers AD-26/AD-8-local) is DELETED; server
// MoveTos for the local player now run through the SAME verbatim
// MoveToManager remotes use (R4-V4), bound below by GameWindow's
// EnterPlayerModeNow beside the R3-W6 DefaultSink bind.
/// <summary>
/// R5-V5: retail <c>CPhysicsObj::movement_manager</c> (acclient.h
/// <c>/* 3463 */</c>) — the ONE owner of this controller's
/// <see cref="Motion"/> + <see cref="MoveTo"/> pair. Constructed in the
/// ctor around the interp; the MoveToManager side arrives via
/// <c>MoveToFactory</c> + <c>MakeMoveToManager()</c> in
/// <c>GameWindow.EnterPlayerModeNow</c> (the same facade shape
/// <c>EnsureRemoteMotionBindings</c> gives remotes). <see cref="Update"/>
/// ticks <c>UseTime()</c> (0x005242f0) at the slot the deleted
/// <c>DriveServerAutoWalk</c> occupied and relays <c>HitGround()</c>
/// (0x00524300, minterp first then moveto).
/// </summary>
public AcDream.Core.Physics.Motion.MovementManager Movement { get; }
/// <summary>
/// R4-V5: the local player's verbatim retail <c>MoveToManager</c>
/// (decomp 0x00529010-0x0052a987), constructed + seam-bound by
/// <c>GameWindow.EnterPlayerModeNow</c> against this controller's
/// <see cref="Motion"/>/body/Yaw (the same wiring shape
/// <c>EnsureRemoteMotionBindings</c> uses for remotes). GameWindow
/// routes inbound mt 6-9 movement events through
/// <see cref="AcDream.Core.Physics.Motion.MovementManager.PerformMovement"/>.
/// User input cancels a moveto through the retail chain: key edge →
/// DoMotion (ctor-default params, CancelMoveTo bit set) →
/// <see cref="MotionInterpreter.InterruptCurrentMovement"/> →
/// <c>CancelMoveTo(ActionCancelled)</c> (register TS-36 retired).
/// R5-V5: a view of <see cref="Movement"/>'s moveto child; the setter is
/// sugar over the facade's factory path (kept for the
/// PlayerMoveToCutoverTests rig and any pre-facade bind shape).
/// </summary>
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo
{
get => Movement.MoveTo;
set
{
var mtm = value ?? throw new ArgumentNullException(nameof(value));
Movement.MoveToFactory = () => mtm;
Movement.MakeMoveToManager();
}
}
/// <summary>
/// R5-V3 (#171): the player's <c>PositionManager</c> facade (retail
/// <c>CPhysicsObj::position_manager</c> — owned by the player's
/// <c>EntityPhysicsHost</c>, handed here by <c>EnterPlayerModeNow</c>).
/// <see cref="Update"/> drives it at the two retail per-tick points:
/// <c>AdjustOffset</c> inside the physics-tick block (retail
/// <c>UpdatePositionInternal</c> @0x00512d0e, BEFORE
/// <c>UpdatePhysicsInternal</c> so the sticky steer is part of the swept
/// motion) and <c>UseTime</c> after the completed-motions sweep (retail
/// <c>UpdateObjectInternal</c> tail @0x005159b3 — the sticky 1 s lease
/// watchdog). <see cref="SetPosition(Vector3, uint, Vector3)"/> tears any
/// stick down (retail <c>teleport_hook</c> @0x00514eee).
/// </summary>
public AcDream.Core.Physics.Motion.PositionManager? PositionManager { get; set; }
public PlayerMovementController(PhysicsEngine physics)
{
_physics = physics;
_body = new PhysicsBody
{
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
// Default skills — tuned toward mid-retail feel. Real characters'
// skills come from PlayerDescription (0xF7B0/0x0013) — GameWindow
// pushes them via SetCharacterSkills once the controller exists
// (K-fix7; PD arrives at login before auto-entry). These env-var
// defaults only cover tests / pre-PD frames:
// 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);
// R5-V5: the MovementManager facade owns the interp from birth
// (retail CPhysicsObj::movement_manager); the moveto child binds
// later via MoveToFactory (EnterPlayerModeNow / the test rigs).
Movement = new AcDream.Core.Physics.Motion.MovementManager(_motion);
// R3-W4 (A3): the local player's movement is input-driven —
// movement_is_autonomous true so apply_current_movement's dual
// dispatch routes apply_raw_movement (IsThePlayer && autonomous).
// R3-W6 refines this per-motion (server-controlled MoveTo clears it).
_body.LastMoveWasAutonomous = true;
}
/// <summary>
/// Begins retail instant mouse-look. <c>CameraSet::ToggleMouseLook</c>
/// (0x00457490) sends a movement event on both transitions, even before a
/// horizontal turn sample arrives.
/// </summary>
public bool BeginMouseLook(MovementInput input)
{
if (_mouseLookActive || State != PlayerState.InWorld)
return false;
_mouseLookActive = true;
// ToggleMouseLook stores the new mode before MovePlayer enters
// TakeControlFromServer, so its ApplyCurrentMovement observes the
// remapped turn→sidestep channels immediately.
TakeControlFromServer(input);
_mouseTurnSamplePending = false;
_mouseTurnAdjustment = 0f;
ApplyMouseLookToggleMovement(input, entering: true);
return true;
}
/// <summary>
/// Supplies the latest signed, filtered horizontal mouse adjustment.
/// Negative means TurnRight in acdream's yaw convention; positive means
/// TurnLeft. Retail <c>CameraSet::Rotate</c> doubles the magnitude, applies
/// a 0.02 dead zone, and caps the raw turn speed at 1.5.
/// </summary>
public void SubmitMouseTurnAdjustment(
float signedAdjustment,
MovementInput input)
{
if (!_mouseLookActive || !float.IsFinite(signedAdjustment))
return;
TakeControlFromServer(input);
_mouseTurnAdjustment = signedAdjustment;
_mouseTurnSamplePending = true;
_mouseMovementEventCandidate = true;
}
/// <summary>
/// Retail's delayed zero-input <c>MouseLookHandler</c> calls
/// <c>CommandInterpreter::StopDrift @ 0x006B4510</c> before filtering the
/// zero sample. It stops both turn directions and participates in the
/// same half-second movement-report cadence.
/// </summary>
public void StopMouseDrift(MovementInput input)
{
if (!_mouseLookActive)
return;
TakeControlFromServer(input);
var p = MouseAxisParameters(_activeInputTurnSpeed == 0f
? 1f
: _activeInputTurnSpeed);
StopMotionAtPhysicsObjectBoundary(MotionCommand.TurnRight, p);
StopMotionAtPhysicsObjectBoundary(MotionCommand.TurnLeft, p);
_activeInputTurnCommand = null;
_activeInputTurnSpeed = 0f;
_activeInputTurnFromMouse = false;
_mouseTurnSamplePending = false;
_mouseTurnAdjustment = 0f;
_mouseMovementEventCandidate = true;
}
/// <summary>
/// Ends retail instant mouse-look. The next update restores any held
/// keyboard turn (or stops the mouse-origin turn) and publishes the final
/// heading through MoveToState.
/// </summary>
public bool EndMouseLook(MovementInput input)
{
if (!_mouseLookActive && !_activeInputTurnFromMouse)
return false;
_mouseLookActive = false;
// Retail clears the mode before TakeControlFromServer/ApplyCurrentMovement
// so a held sidestep-remapped turn is restored directly as turn.
TakeControlFromServer(input);
_mouseTurnSamplePending = false;
_mouseTurnAdjustment = 0f;
ApplyMouseLookToggleMovement(input, entering: false);
return true;
}
private void ApplyMouseLookToggleMovement(MovementInput input, bool entering)
{
// CameraSet::ToggleMouseLook @ 0x00457490 routes the pseudo-command
// CameraInstantMouseLook through CommandInterpreter::MovePlayer
// @ 0x006B3F40. While entering, held keyboard turns move to the
// sidestep channel. On exit, MovePlayer reverses that mapping and
// ApplyCurrentMovement replays every currently held channel before the
// synchronous movement packet is captured.
(uint? sidestep, bool useRunHold) = DesiredInputSidestep(input, entering);
ApplyInputSidestep(sidestep, useRunHold, reapply: !entering);
uint? turn = entering
? null
: input.TurnRight
? MotionCommand.TurnRight
: input.TurnLeft
? MotionCommand.TurnLeft
: null;
ApplyInputTurn(turn, speed: 1f, fromMouse: false, reapply: !entering);
// TakeControlFromServer::ApplyCurrentMovement and both sides of
// ToggleMouseLook replay the complete current input, not merely the
// turn/sidestep channels affected by CameraInstantMouseLook.
bool runHeld = _motion.RawState.CurrentHoldKey == HoldKey.Run;
if (runHeld != input.Run)
_motion.set_hold_run(input.Run, interrupt: true);
uint? desiredForward = input.Forward
? MotionCommand.WalkForward
: input.Backward
? MotionCommand.WalkBackward
: null;
uint rawForward = _motion.RawState.ForwardCommand;
uint? activeForward = rawForward == RawMotionState.Default.ForwardCommand
? null
: rawForward;
if (activeForward is { } active && active != desiredForward)
{
StopMotionAtPhysicsObjectBoundary(
active,
new AcDream.Core.Physics.Motion.MovementParameters());
}
if (desiredForward is { } command
&& (activeForward != desiredForward || !entering))
DoMotionAtPhysicsObjectBoundary(
command,
new AcDream.Core.Physics.Motion.MovementParameters());
_hasInputSnapshot = true;
_prevForwardHeld = input.Forward;
_prevBackwardHeld = input.Backward;
_prevStrafeLeftHeld = input.StrafeLeft;
_prevStrafeRightHeld = input.StrafeRight;
_prevTurnLeftHeld = input.TurnLeft;
_prevTurnRightHeld = input.TurnRight;
_prevRunHeld = input.Run;
_prevRunHold = input.Run;
_body.LastMoveWasAutonomous = true;
}
private static (uint? Command, bool UseRunHold) DesiredInputSidestep(
MovementInput input,
bool mouseLookActive)
{
if (input.StrafeRight)
return (MotionCommand.SideStepRight, false);
if (input.StrafeLeft)
return (MotionCommand.SideStepLeft, false);
if (mouseLookActive && input.TurnRight)
return (MotionCommand.SideStepRight, true);
if (mouseLookActive && input.TurnLeft)
return (MotionCommand.SideStepLeft, true);
return (null, false);
}
private bool ApplyInputSidestep(
uint? desired,
bool useRunHold,
bool reapply = false)
{
bool changed = desired != _activeInputSidestepCommand;
if (_activeInputSidestepCommand is { } active
&& (changed || reapply))
{
StopMotionAtPhysicsObjectBoundary(
active,
_activeInputSidestepUsesRunHold
? MouseAxisParameters(1f)
: new());
}
if (desired is { } command && (changed || reapply))
{
DoMotionAtPhysicsObjectBoundary(
command,
useRunHold ? MouseAxisParameters(1f) : new());
}
_activeInputSidestepCommand = desired;
_activeInputSidestepUsesRunHold = desired.HasValue && useRunHold;
return changed || (reapply && desired.HasValue);
}
private bool ApplyInputTurn(
uint? desired,
float speed,
bool fromMouse,
bool reapply = false)
{
bool commandChanged = desired != _activeInputTurnCommand;
bool bothPresent = desired.HasValue && _activeInputTurnCommand.HasValue;
bool speedChanged = bothPresent
&& MathF.Abs(speed - _activeInputTurnSpeed) >= 0.0001f;
bool ownerChanged = bothPresent && fromMouse != _activeInputTurnFromMouse;
bool apply = commandChanged || speedChanged || ownerChanged
|| (reapply && desired.HasValue);
if (_activeInputTurnCommand is { } active && apply)
{
StopMotionAtPhysicsObjectBoundary(
active,
_activeInputTurnFromMouse
? MouseAxisParameters(_activeInputTurnSpeed)
: new());
}
if (desired is { } command && apply)
{
DoMotionAtPhysicsObjectBoundary(
command,
fromMouse ? MouseAxisParameters(speed) : new());
}
_activeInputTurnCommand = desired;
_activeInputTurnSpeed = desired.HasValue ? speed : 0f;
_activeInputTurnFromMouse = desired.HasValue && fromMouse;
return apply;
}
private static AcDream.Core.Physics.Motion.MovementParameters MouseAxisParameters(
float speed) => new()
{
Speed = speed,
SetHoldKey = false,
HoldKeyToApply = HoldKey.Run,
};
/// <summary>
/// Retail <c>CommandInterpreter::TakeControlFromServer</c>
/// (0x006B32D0). A genuine user movement first terminates the server-owned
/// movement state, marks subsequent movement autonomous, and lets the next
/// input update re-apply every key that is still physically held.
/// </summary>
private void TakeControlFromServer(MovementInput? currentInput = null)
{
if (!_controlledByServer)
return;
_controlledByServer = false;
_body.LastMoveWasAutonomous = true;
StopCompletelyAtPhysicsObjectBoundary();
_activeInputTurnCommand = null;
_activeInputTurnSpeed = 0f;
_activeInputTurnFromMouse = false;
_activeInputSidestepCommand = null;
_activeInputSidestepUsesRunHold = false;
// Retail follows StopCompletely with ApplyCurrentMovement. Our key
// snapshot is supplied to Update, so clearing the edge history makes
// that same held input re-enter through the normal DoMotion boundary.
_prevForwardHeld = false;
_prevBackwardHeld = false;
_prevStrafeLeftHeld = false;
_prevStrafeRightHeld = false;
_prevTurnLeftHeld = false;
_prevTurnRightHeld = false;
_prevRunHeld = _motion.RawState.CurrentHoldKey == HoldKey.Run;
// MouseLookHandler runs before the normal per-frame input edge pass.
// When it is the action that takes control, replay the device levels
// synchronously just as retail ApplyCurrentMovement does; otherwise a
// StopCompletely would erase held movement while the edge history is
// simultaneously advanced past it.
if (currentInput is { } input)
ApplyMouseLookToggleMovement(input, entering: _mouseLookActive);
}
/// <summary>
/// Retail <c>ClientCombatSystem::StartAttackRequest</c> (0x0056C040)
/// invokes <c>CommandInterpreter::MaybeStopCompletely</c> before building
/// the attack. This removes a mouse-origin drift immediately and schedules
/// the final authoritative heading ahead of the eventual attack send.
/// </summary>
public bool PrepareForAttackRequest()
{
// CommandInterpreter::MaybeStopCompletely @ 0x006B3B90 is a no-op
// while an authoritative server movement owns the player.
if (_controlledByServer)
return false;
StopCompletelyAtPhysicsObjectBoundary();
_activeInputTurnCommand = null;
_activeInputTurnSpeed = 0f;
_activeInputTurnFromMouse = false;
_activeInputSidestepCommand = null;
_activeInputSidestepUsesRunHold = false;
_mouseTurnSamplePending = false;
_mouseTurnAdjustment = 0f;
_body.LastMoveWasAutonomous = true;
return true;
}
public void SetCharacterSkills(int runSkill, int jumpSkill)
{
_weenie.SetSkills(runSkill, jumpSkill);
}
/// <summary>
/// R3-W2 (r3-port-plan.md §4): the player's <see cref="MotionInterpreter"/>
/// — GameWindow binds the player sequencer's MotionDone seam to it so the
/// pending_motions queue pops in step with animation completion, same
/// path remotes use. R3-W6 widens this into the full local-player
/// unification.
/// </summary>
internal MotionInterpreter Motion => _motion;
/// <summary>
/// Retail <c>CPhysicsObj::StopCompletely</c> (0x00510180) enters through
/// <c>MovementManager::PerformMovement</c> (0x005240D0), not directly
/// through <c>CMotionInterp::StopCompletely</c>. The facade's type-5 path
/// performs the required zero-duration animation completion sweep after
/// the interpreter has queued its matching pending-motion node.
/// </summary>
internal WeenieError StopCompletelyAtPhysicsObjectBoundary() =>
Movement.PerformMovement(new MovementStruct
{
Type = MovementType.StopCompletely,
});
/// <summary>
/// Retail <c>CPhysicsObj::DoMotion</c> (0x00510020) constructs a type-1
/// <c>MovementStruct</c> carrying the original parameters pointer and
/// routes it through <c>MovementManager::PerformMovement</c>. That outer
/// boundary is observable: <c>CMotionInterp::PerformMovement</c>
/// (0x00528E80) performs the synchronous completed-animation sweep after
/// the motion dispatch.
/// </summary>
private WeenieError DoMotionAtPhysicsObjectBoundary(
uint motion,
AcDream.Core.Physics.Motion.MovementParameters parameters)
{
_body.LastMoveWasAutonomous = true;
return Movement.PerformMovement(new MovementStruct
{
Type = MovementType.RawCommand,
Motion = motion,
Params = parameters,
});
}
/// <summary>
/// Retail <c>CPhysicsObj::StopMotion</c> (0x005100D0), the type-3 peer of
/// <see cref="DoMotionAtPhysicsObjectBoundary"/>. Player input must use
/// this facade; MoveToManager's private _DoMotion/_StopMotion path stays
/// directly on CMotionInterp, matching retail.
/// </summary>
private WeenieError StopMotionAtPhysicsObjectBoundary(
uint motion,
AcDream.Core.Physics.Motion.MovementParameters parameters)
{
_body.LastMoveWasAutonomous = true;
return Movement.PerformMovement(new MovementStruct
{
Type = MovementType.StopRawCommand,
Motion = motion,
Params = parameters,
});
}
/// <summary>R4-V5: CONTACT transient-state bit (retail
/// <c>transient_state &amp; 1</c>) — the <see cref="MoveTo"/> manager's
/// UseTime tick gate reads exactly this bit (decomp §6a @307781; a
/// strict subset of the funnel's Contact+OnWalkable gate).</summary>
internal bool BodyInContact => _body.InContact;
/// <summary>Retail <c>SendPositionEvent</c> admission gate: both Contact
/// and OnWalkable must be present on the local physics body.</summary>
internal bool CanSendPositionEvent => _body.InContact && _body.OnWalkable;
/// <summary>R4-V5: body orientation for the <see cref="MoveTo"/>
/// manager's position seam (re-derived from <see cref="Yaw"/> every
/// Update — heading reads/writes go through Yaw, not this).</summary>
internal Quaternion BodyOrientation => _body.Orientation;
/// <summary>R4-V5 wedge fix — the P1 unpack store
/// (<c>CPhysics::SetObjectMovement</c> @00509730:
/// <c>last_move_was_autonomous = arg7</c>, written on the unpack path
/// for every applied movement event). GameWindow's local-player 0xF74C
/// branch stores the wire autonomous byte here (always false — the P1
/// gate drops autonomous echoes); the speculative use install stores
/// false too (it models the wire mt-6 ACE sends moments later). Routes
/// the per-tick pump's A3 dual dispatch to the INTERPRETED branch
/// during a server moveto so apply_raw can't clobber the manager's
/// dispatched motions with the idle raw state.</summary>
internal void SetLastMoveWasAutonomous(bool autonomous)
{
_body.LastMoveWasAutonomous = autonomous;
_controlledByServer = !autonomous;
}
/// <summary>
/// Wire the player's AnimationSequencer current cycle velocity into
/// <see cref="MotionInterpreter.GetCycleVelocity"/>. When attached,
/// <c>get_state_velocity</c> uses <c>MotionData.Velocity * speedMod</c>
/// as the primary forward-axis drive, keeping the body's world velocity
/// locked to the animation's baked-in root-motion velocity.
///
/// <para>
/// Without this accessor, the decompiled constant path
/// (<c>RunAnimSpeed * ForwardSpeed</c>) 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 <see cref="MotionInterpreter.GetCycleVelocity"/>
/// for the full rationale.
/// </para>
///
/// <para>
/// Called once from <c>GameWindow.CreateAnimatedEntity</c> after the
/// player's <c>AnimatedEntity.Sequencer</c> is constructed.
/// </para>
/// </summary>
public void AttachCycleVelocityAccessor(Func<Vector3> accessor)
{
if (accessor is null) throw new ArgumentNullException(nameof(accessor));
_motion.GetCycleVelocity = accessor;
}
/// <summary>
/// Retail <c>CommandInterpreter::SendMovementEvent</c> (0x006B4680)
/// stamps only <c>last_sent_position_time</c>. The carried frame and
/// contact plane remain those from the last AutonomousPosition packet.
/// </summary>
public void NoteMovementSent(float nowSeconds, bool mouseLookEvent = false)
{
_lastSentTime = nowSeconds;
if (mouseLookEvent)
{
_mouseMovementEventPending = false;
_lastMouseMovementEventTime = nowSeconds;
}
}
/// <summary>
/// Captures the current raw movement axes for an input-boundary
/// SendMovementEvent. ToggleMouseLook sends synchronously, so the host
/// cannot wait for the next physics update to reconstruct this state.
/// </summary>
public MovementResult CaptureMovementResult(bool mouseLookEvent)
{
var raw = _motion.RawState;
uint? forward = raw.ForwardCommand == RawMotionState.Default.ForwardCommand
? null : raw.ForwardCommand;
uint? sidestep = raw.SidestepCommand == RawMotionState.Default.SidestepCommand
? null : raw.SidestepCommand;
uint? turn = raw.TurnCommand == RawMotionState.Default.TurnCommand
? null : raw.TurnCommand;
return new MovementResult(
Position: Position,
RenderPosition: RenderPosition,
CellId: CellId,
IsOnGround: CanSendPositionEvent,
MotionStateChanged: true,
ForwardCommand: forward,
SidestepCommand: sidestep,
TurnCommand: turn,
ForwardSpeed: forward.HasValue ? raw.ForwardSpeed : null,
SidestepSpeed: sidestep.HasValue ? raw.SidestepSpeed : null,
TurnSpeed: turn.HasValue ? raw.TurnSpeed : null,
IsRunning: raw.CurrentHoldKey == HoldKey.Run,
ShouldSendMovementEvent: true,
TurnUsesRunHold: turn.HasValue && raw.TurnHoldKey == HoldKey.Run,
SidestepUsesRunHold: sidestep.HasValue
&& raw.SidestepHoldKey == HoldKey.Run,
IsMouseLookMovementEvent: mouseLookEvent);
}
/// <summary>
/// Retail <c>CommandInterpreter::SendPositionEvent</c> (0x006B4770)
/// stamps the send time, complete cell-local frame (origin and
/// orientation), and contact plane after an AutonomousPosition packet.
/// </summary>
public void NotePositionSent(AcDream.Core.Physics.Position position,
System.Numerics.Plane contactPlane,
float nowSeconds)
{
_lastSentPosition = position;
_lastSentContactPlane = contactPlane;
_lastSentTime = nowSeconds;
_lastSentInitialized = true;
}
/// <summary>
/// Faithful port of retail
/// <c>CommandInterpreter::ShouldSendPositionEvent</c> (0x006B45E0).
/// Before the one-second interval expires, only a cell or contact-plane
/// change sends. Once it expires, the complete frame is compared, including
/// orientation; this is what publishes a stationary heading change.
/// </summary>
internal bool ShouldSendPositionEvent(
AcDream.Core.Physics.Position currentPosition,
System.Numerics.Plane currentContactPlane,
float nowSeconds)
{
if (!_lastSentInitialized)
return true;
bool cellChanged = _lastSentPosition.ObjCellId != currentPosition.ObjCellId;
if ((_lastSentTime + HeartbeatInterval) >= nowSeconds)
{
return cellChanged
|| !ApproxPlaneEqual(_lastSentContactPlane, currentContactPlane);
}
return cellChanged
|| !ApproxFrameEqual(_lastSentPosition.Frame, currentPosition.Frame);
}
// 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;
// Render root: CellGraph.CurrCell IS "the player's cell" — it roots the indoor render
// (GameWindow.OnRender). Set it HERE, the single PLAYER-only chokepoint for CellId
// (teleport / server snap @ SetPosition + per-frame resolver), NOT in the per-entity
// PhysicsEngine.ResolveWithTransition. That ran for EVERY entity, so a Holtburg NPC
// jump-looping near the cottage doorway clobbered the render root every tick → the render
// rooted at the NPC's tiny connector cell → only its ~8-tri shell drew, rest = GL clear
// color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 via [flap-cam]/[shell]).
_physics.UpdatePlayerCurrCell(newCellId);
}
public void SetPosition(Vector3 pos, uint cellId)
// #145: tests + legacy callers run in the world==block-local frame (no
// streaming center), so the cell-local seed IS the world position. This
// makes the carried anchor (body.Position CellPosition.Origin) == (0,0,0),
// identical to the legacy Zero terrain-origin fallback → behaviour unchanged.
=> SetPosition(pos, cellId, pos);
/// <summary>
/// Server-snap / teleport placement. <paramref name="cellLocal"/> is the
/// LANDBLOCK-relative position (the wire's local, or world landblock origin)
/// which seeds the body's cell-relative <c>CellPosition</c> WITHOUT any streaming
/// center (#145). A teleport is a large jump, so this snaps the cell frame
/// directly via <c>SnapToCell</c> rather than delta-syncing through the setter.
/// </summary>
public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
{
_body.SnapToCell(cellId, pos, cellLocal);
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
UpdateCellId(_body.CellPosition.ObjCellId, "teleport");
// Treat as grounded after a server-side position snap.
_body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
_body.Velocity = Vector3.Zero;
// #145 Slice 7: idle the motion interpreter on a server snap / teleport arrival.
// SetPosition zeros the body velocity, but the motion interpreter still holds the
// PRE-teleport ForwardCommand (e.g. RunForward), so the next Update() would
// reconstruct that run vector via get_state_velocity and the player would sprint
// off in the old direction the instant input resumes. Resetting the forward
// command to Ready makes the player arrive at rest.
// R3-W6: retail's teleport idle is a FULL stop (StopCompletely
// 0x00527e40: resets fwd/sidestep/turn COMMANDS, zeroes velocity,
// enqueues the A9 jump-snapshot node) — not a bare DoMotion(Ready).
StopCompletelyAtPhysicsObjectBoundary();
_activeInputTurnCommand = null;
_activeInputTurnSpeed = 0f;
_activeInputTurnFromMouse = false;
_activeInputSidestepCommand = null;
_activeInputSidestepUsesRunHold = false;
_mouseLookActive = false;
_mouseTurnSamplePending = false;
_mouseTurnAdjustment = 0f;
_mouseMovementEventCandidate = false;
_mouseMovementEventPending = false;
// R5-V3 (#171): retail teleport_hook (0x00514ed0) — PositionManager::
// UnStick (@0x00514eee) right after the moveto cancel: a teleport
// tears down any active stick. (StopInterpolating/UnConstrain have no
// armed acdream counterparts — no local-player InterpolationManager,
// constraint leash unarmed per #167.)
PositionManager?.UnStick();
// Reset the edge tracker: the stop wiped the motion state, so keys
// still physically held must re-fire as press edges on the next
// Update (matches the pre-W6 level-triggered behavior of walking
// straight out of a teleport while W stays held).
_prevForwardHeld = false;
_prevBackwardHeld = false;
_prevStrafeLeftHeld = false;
_prevStrafeRightHeld = false;
_prevTurnLeftHeld = false;
_prevTurnRightHeld = false;
_prevRunHeld = false;
_hasInputSnapshot = false;
// Reset physics clock so any subsequent update_object calls start fresh.
_body.LastUpdateTime = 0.0;
_physicsAccum = 0f;
}
/// <summary>
/// Retail <c>SmartBox::BlipPlayer</c> (0x00453940): apply a server
/// FORCE_POSITION correction through <c>CPhysicsObj::SetPositionSimple</c>
/// without the teleport hook. Active motion, velocity, contact state, and
/// PositionManager stick relationships deliberately survive the blip.
/// </summary>
public void BlipPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
{
_body.SnapToCell(cellId, pos, cellLocal);
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
UpdateCellId(_body.CellPosition.ObjCellId, "force-position");
}
private Vector3 ComputeRenderPosition()
{
float alpha = Math.Clamp(_physicsAccum / PhysicsBody.MinQuantum, 0f, 1f);
return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha);
}
/// <summary>
/// Retail Hidden slice of <c>CPhysicsObj::UpdatePositionInternal</c>
/// (0x00512C30). Input, PartArray root motion, and physics integration are
/// skipped, while PositionManager composition and the manager tail remain
/// live. A composed offset still commits through CTransition so cell
/// membership and contact state cannot become stale behind the hidden mesh.
/// </summary>
public MovementResult TickHidden(float dt)
{
_simTimeSeconds += dt;
_physicsAccum = 0f;
Vector3 previousPosition = _body.Position;
bool previousContact = _body.InContact;
bool previousOnWalkable = _body.OnWalkable;
if (PositionManager is { } manager)
{
var delta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
manager.AdjustOffset(delta, dt);
if (delta.Origin != Vector3.Zero)
_body.Position += Vector3.Transform(delta.Origin, _body.Orientation);
if (!delta.Orientation.IsIdentity)
{
Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(
AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw)
+ delta.GetHeading());
_body.Orientation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
Yaw - MathF.PI / 2f);
}
}
if (_body.Position != previousPosition && CellId != 0 && _physics.LandblockCount > 0)
{
ResolveResult resolved = _physics.ResolveWithTransition(
previousPosition,
_body.Position,
CellId,
sphereRadius: 0.48f,
sphereHeight: 1.835f,
stepUpHeight: StepUpHeight,
stepDownHeight: StepDownHeight,
isOnGround: previousOnWalkable,
body: _body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: LocalEntityId);
_body.Position = resolved.Position;
PhysicsObjUpdate.CommitSetPositionTransition(
_body,
resolved.InContact,
resolved.OnWalkable,
resolved.CollisionNormalValid,
resolved.CollisionNormal,
previousContact,
previousOnWalkable,
Movement.HitGround,
_motion.LeaveGround);
UpdateCellId(resolved.CellId, "hidden-position-manager");
}
Movement.UseTime();
PositionManager?.UseTime();
_prevPhysicsPos = _body.Position;
_currPhysicsPos = _body.Position;
_wasAirborneLastFrame = !_body.OnWalkable;
return new MovementResult(
Position: _body.Position,
RenderPosition: _body.Position,
CellId: CellId,
IsOnGround: _body.OnWalkable,
MotionStateChanged: false,
ForwardCommand: null,
SidestepCommand: null,
TurnCommand: null,
ForwardSpeed: null,
SidestepSpeed: null,
TurnSpeed: null);
}
public MovementResult Update(float dt, MovementInput input)
{
_simTimeSeconds += dt;
// R4-V5: tick the verbatim MoveToManager at the slot the deleted
// DriveServerAutoWalk occupied — after inbound wire routing, before
// the input-driven motion sections. UseTime runs the retail per-tick
// moveto drivers (HandleMoveToPosition aux-turn steering + arrival +
// fail-distance / HandleTurnToHeading); the motions it dispatches
// land in InterpretedState through _DoMotion → DoInterpretedMotion,
// and sections 1/2 below turn that state into Yaw rotation + body
// velocity — the same per-frame apply user input gets. No
// synthesized input exists, so the outbound-packet pipeline sees no
// user motion and never builds a MoveToState mid-moveto (the #75
// invariant, now by construction). Provisional tick placement until
// R6 ports retail's UpdateObjectInternal ordering (r4-port-plan.md
// §3 placement decision). R5-V5: the facade relay
// (MovementManager::UseTime 0x005242f0 — moveto side only).
Movement.UseTime();
// R4-V5 wedge fix (2026-07-03 live door bug), retail's per-tick
// completion slot: CPartArray::HandleMovement — a tailcall chain to
// MotionTableManager::CheckForCompletedMotions (0x00517d60 →
// 0x0051bfd0) — runs every tick right AFTER MovementManager::UseTime
// in UpdateObjectInternal (@005159a4). It flushes pending_animations
// entries whose animations already ran out so their AnimationDone →
// MotionDone pops land each tick even when no op fired that frame.
// (apply_current_movement is NOT part of the per-tick order — its
// retail callers are all event-driven: hold-key toggles, HitGround/
// LeaveGround, ReportExhaustion.) Full tick ordering remains R6
// scope; the companion fix is StopCompletely's previously-missing
// StopCompletely_Internal animation dispatch (see
// MotionInterpreter.StopCompletely), whose completable entry this
// slot drains.
_motion.CheckForCompletedMotions?.Invoke();
// 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);
}
// ── R3-W6: EDGE-DRIVEN retail input (replaces the D6.2 per-frame
// RawMotionState rebuild — the level-triggered substitute for
// retail's edge-triggered CommandInterpreter). Each key EDGE fires
// DoMotion/StopMotion (0x00528d20/0x00528530) which mutate the
// interpreter's OWN RawState via ApplyMotion/RemoveMotion and
// dispatch through the funnel + DefaultSink — the SAME pipeline
// remotes use. The Shift edge is retail's set_hold_run
// (0x00528b70, caller 0x006b33ca shape: interrupt=true). RAW speeds
// stay 1.0 (apply_run_to_command applies the run rate — pre-scaling
// would double-scale; TS-22 unchanged). R4-V5: the ctor-default
// params carry retail's 0x1EE0F bitfield whose CancelMoveTo bit
// (0x8000) is SET — any key edge mid-moveto fires
// InterruptCurrentMovement → MoveTo.CancelMoveTo(ActionCancelled),
// the retail user-input cancel chain (TS-36 retired; set_hold_run's
// interrupt:true is the same chain for the Shift edge).
if (!_hasInputSnapshot)
{
// GameWindow supplies the configured default walk/run mode as a
// level, not a physical key edge. Seed that first snapshot without
// claiming movement control; a simultaneous directional edge below
// still takes control normally.
_hasInputSnapshot = true;
_prevRunHeld = input.Run;
_prevRunHold = input.Run;
_motion.set_hold_run(input.Run, interrupt: false);
}
bool motionEdgeFired = false;
bool movementEventRequested = false;
{
bool userInputEdge = input.Run != _prevRunHeld
|| input.Forward != _prevForwardHeld
|| input.Backward != _prevBackwardHeld
|| input.StrafeLeft != _prevStrafeLeftHeld
|| input.StrafeRight != _prevStrafeRightHeld
|| input.TurnLeft != _prevTurnLeftHeld
|| input.TurnRight != _prevTurnRightHeld;
if (userInputEdge)
TakeControlFromServer();
var p = new AcDream.Core.Physics.Motion.MovementParameters();
// Shift/run edge FIRST — retail's set_hold_run re-applies
// movement, so the hold-key state is current before any
// same-frame directional dispatch.
if (input.Run != _prevRunHeld)
{
_motion.set_hold_run(input.Run, interrupt: true);
motionEdgeFired = true;
}
// Forward channel (W / S share one raw channel — retail
// RawMotionState.ApplyMotion's forward-class switch). W wins on
// a same-frame double-press; releasing one key while the
// opposite is still held re-issues the survivor (equivalent to
// the old level-triggered build, which always reflected the
// currently-held set).
if (input.Forward && !_prevForwardHeld)
{ DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkForward, p); motionEdgeFired = true; }
else if (input.Backward && !_prevBackwardHeld && !input.Forward)
{ DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); motionEdgeFired = true; }
if (!input.Forward && _prevForwardHeld)
{
if (input.Backward)
DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p);
else
StopMotionAtPhysicsObjectBoundary(MotionCommand.WalkForward, p);
motionEdgeFired = true;
}
else if (!input.Backward && _prevBackwardHeld && !input.Forward)
{ StopMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); motionEdgeFired = true; }
// Sidestep channel. CameraInstantMouseLook remaps held keyboard
// TurnLeft/TurnRight into this channel while MMB is active.
(uint? desiredSidestep, bool sidestepUsesRunHold) =
DesiredInputSidestep(input, _mouseLookActive);
if (ApplyInputSidestep(desiredSidestep, sidestepUsesRunHold))
motionEdgeFired = true;
// Everything above is an ordinary input edge and therefore owns
// an immediate SendMovementEvent. Mouse-origin turn updates below
// deliberately do not: CameraSet reports them on its separate
// half-second cadence.
movementEventRequested = motionEdgeFired;
// Turn channel. Retail CameraSet::Rotate (0x00458310) feeds mouse
// input through CommandInterpreter::MovePlayer as TurnLeft /
// TurnRight, so character yaw, physics orientation, raw wire state,
// and ACE authority all share this one MotionInterpreter owner.
bool keyboardTurnEdge = input.TurnRight != _prevTurnRightHeld
|| input.TurnLeft != _prevTurnLeftHeld;
if (keyboardTurnEdge)
movementEventRequested = true;
uint? desiredTurnCommand = _mouseLookActive
? null
: input.TurnRight
? MotionCommand.TurnRight
: input.TurnLeft
? MotionCommand.TurnLeft
: null;
float desiredTurnSpeed = 1f;
bool desiredTurnFromMouse = false;
if (_mouseLookActive && _mouseTurnSamplePending)
{
float adjustment = _mouseTurnAdjustment;
_mouseTurnSamplePending = false;
_mouseTurnAdjustment = 0f;
if (MathF.Abs(adjustment) >= MouseTurnDeadZone)
{
desiredTurnCommand = adjustment < 0f
? MotionCommand.TurnRight
: MotionCommand.TurnLeft;
desiredTurnSpeed = MathF.Min(
MathF.Abs(adjustment) * MouseTurnSpeedScale,
MouseTurnMaximumSpeed);
desiredTurnFromMouse = true;
}
}
else if (_mouseLookActive && _activeInputTurnFromMouse)
{
// Eventless render frames do not synthesize a zero. Preserve
// the last Rotate motion until the delayed retail idle handler
// explicitly calls StopMouseDrift.
desiredTurnCommand = _activeInputTurnCommand;
desiredTurnSpeed = _activeInputTurnSpeed;
desiredTurnFromMouse = true;
}
if (ApplyInputTurn(
desiredTurnCommand,
desiredTurnSpeed,
desiredTurnFromMouse))
motionEdgeFired = true;
// Retail stores last_move_was_autonomous = 1 at the CPhysicsObj
// INPUT boundary — CPhysicsObj::DoMotion @00510030 /
// CPhysicsObj::StopMotion @005100e0 (per call, before routing
// to MovementManager) and CommandInterpreter::
// TakeControlFromServer @006b32f4. The MoveToManager's
// _DoMotion goes through CMotionInterp internals and NEVER
// touches the flag; the wire unpack path stores the wire byte
// (P1, 00509730). This edge block IS acdream's input boundary,
// so an edge firing is exactly retail's store site. The flag
// routes the per-tick pump's A3 dual dispatch (raw for
// input-driven motion, interpreted for server/manager-driven).
if (motionEdgeFired)
{
_body.LastMoveWasAutonomous = true;
_controlledByServer = false;
}
}
_prevForwardHeld = input.Forward;
_prevBackwardHeld = input.Backward;
_prevStrafeLeftHeld = input.StrafeLeft;
_prevStrafeRightHeld = input.StrafeRight;
_prevTurnLeftHeld = input.TurnLeft;
_prevTurnRightHeld = input.TurnRight;
_prevRunHeld = input.Run;
// ── 1. Apply turning from keyboard + mouse ────────────────────────────
// 2026-05-16 — retail-faithful turn rate.
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
// - 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.
// D6.2: local keyboard turn rate now comes from the interpreted turn
// state (adjust_motion normalized TurnLeft→TurnRight with sign +
// apply_run_to_command ×RunTurnFactor when running). omega.Z =
// BaseTurnRateRadPerSec × turn_speed — the same π/2 formula the remote
// path uses (GameWindow ObservedOmega seed). Numerically identical to the
// former TurnRateFor(input.Run); AP-9 (π/2 base rate) is unchanged.
// R4-V5: runs during a moveto too — the manager's aux-turn steering
// and TurnToHeading nodes dispatch TurnRight/TurnLeft through
// _DoMotion into the SAME interpreted turn state, so this one
// integrator rotates the player for both input and moveto turns.
// Mouse-origin turns now enter the same interpreted turn state above.
// Packet cadence is carried separately by ShouldSendMovementEvent, so
// local mouse sampling cannot flood MoveToState.
if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
{
Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver
* _motion.InterpretedState.TurnSpeed * dt;
}
// 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 ───────────────
// R4-V5: runs during a moveto too — the manager's BeginMoveForward
// dispatched WalkForward/RunForward through _DoMotion into the SAME
// interpreted state, so this one per-frame apply turns it into body
// velocity exactly like input-driven motion (the #75 "two writers"
// hazard is gone: there is only this writer now).
// D6.2: the forward/sidestep command determination + DoMotion +
// DoInterpretedMotion moved into the apply_raw_movement call above, so
// the interpreted state (and thus get_state_velocity) is already
// populated + normalized for all directions.
// 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;
// D6.2: velocity for ALL directions comes from the normalized
// interpreted state — backward (WalkForward ×-0.65) and strafe-left
// (SideStepRight ×-1×1.248) are handled inside get_state_velocity now
// that adjust_motion ran above. The hand-mirrored formulas are gone
// (register TS-22 retired).
var stateVel = _motion.get_state_velocity();
// R4-V5 wedge fix: PRESERVE the flag — retail's
// last_move_was_autonomous is stored at EVENT boundaries only
// (input DoMotion/StopMotion edges above, the P1 wire-unpack
// store, LeaveGround's own autonomous=1 write), never re-stamped
// by the per-tick velocity write. V5's first cut stamped it per
// frame here, which mis-routed the pump's A3 dual dispatch on
// event-transition frames (apply_raw clobbering a just-armed
// moveto's dispatched motion with the raw Ready state).
_body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz),
autonomous: _body.LastMoveWasAutonomous);
}
// ── 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 GetLeaveGroundVelocity.
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;
// R3-W6 (map R1): retail's charge_jump fires at charge
// START (SmartBox/input boundary 0x0056afac) — the ONLY
// place StandingLongJump arms (grounded + Ready + no
// sidestep/turn). Never called by production code before
// this line despite the W3 port.
_motion.ChargeJump();
}
float chargeRate = _motion.InterpretedState.CurrentStyle
== AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle
? DualWieldJumpChargeRate
: JumpChargeRate;
_jumpExtent = MathF.Min(_jumpExtent + dt * chargeRate, 1.0f);
}
else if (_jumpCharging)
{
// Spacebar released (or left ground during charge) — fire jump.
var jumpResult = _motion.jump(_jumpExtent);
if (jumpResult == WeenieError.None)
{
// R3-W4 (J7): the manual LeaveGround call is DELETED —
// jump() clears OnWalkable, and the SAME frame's
// grounded→airborne edge (section 5's transition detection)
// fires _motion.LeaveGround() exactly where retail's
// transition sweep does. Capture jump_v_z NOW: the edge's
// LeaveGround resets JumpExtent to 0 later this frame.
float jumpVz = _motion.GetJumpVZ();
outJumpExtent = _jumpExtent;
// D6.2: get_state_velocity() is now correct for all directions
// (apply_raw_movement normalized backward/strafe above), so the
// jump-launch velocity is get_state_velocity() + the vertical jump
// component. Retires the duplicated hand-mirrored formulas that
// existed only until adjust_motion was ported (register TS-22).
var jumpVel = _motion.get_state_velocity();
outJumpVelocity = new Vector3(jumpVel.X, jumpVel.Y, 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, autonomous: true);
}
_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;
float lastTickDt = 0f; // the dt actually integrated this frame (for cached_velocity)
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);
lastTickDt = tickDt;
// R5-V3 (#171): retail UpdatePositionInternal (0x00512c30) —
// PositionManager::adjust_offset (@0x00512d0e) composes the
// sticky steer into this quantum's motion BEFORE
// UpdatePhysicsInternal, so the transition sweep below resolves
// it like any other movement (preIntegratePos was captured
// above). The delta's Origin is mover-LOCAL (rotate out by the
// body orientation); the heading is RELATIVE and writes Yaw —
// the authoritative facing the body quaternion is re-derived
// from every frame (a quaternion-only write would be clobbered).
// No-op while nothing is stuck (untouched delta frame).
if (PositionManager is { } ppm)
{
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
ppm.AdjustOffset(pmDelta, tickDt);
if (pmDelta.Origin != Vector3.Zero)
_body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation);
if (!pmDelta.Orientation.IsIdentity)
Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(
AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw)
+ pmDelta.GetHeading());
}
_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;
// retail UpdateObjectInternal (pc:283657): the transition + handle_all_collisions are
// reached ONLY when the integrated candidate actually MOVED off m_position. This gate
// is load-bearing for the #182 bleed: after fsf>1 zeros a blocked jump's velocity, the
// next frame integrates zero motion (velMag2==0 → no position step, just v += gravity),
// so the candidate hasn't moved yet — handle_all_collisions MUST be skipped that frame
// or it re-zeros the gravity velocity and the body re-wedges instead of falling off.
bool candidateMoved = postIntegratePos != preIntegratePos;
// ── 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 Setup 0x02000001 sphere radius (dat: 0.480)
// #137 window climb (2026-07-06): sphereHeight is the CAPSULE TOP
// (InitPath places the head sphere center at height radius). The
// dat human Setup 0x02000001 has Spheres[1].Origin.Z = 1.350
// (top 1.830) and Height = 1.835 — retail collides with that
// sphere list verbatim (CPhysicsObj::transition 0x00512dc0 →
// init_sphere(GetNumSphere, GetSphere, scale)). The old 1.2f put
// the head sphere center at 0.72 — the top 0.63 m of the
// character had NO collision, letting the player climb into a
// 1.3 m window alcove head-through-lintel. Register TS-46.
sphereHeight: 1.835f,
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 the resolve: retail UpdateObjectInternal (0x005156b0) →
// SetPositionInternal (0x00515330) → handle_all_collisions (0x00514780).
// #182 verbatim rebuild: this REPLACES the ad-hoc airborne-only bounce with the
// frames_stationary_fall-driven velocity model — the airborne-stuck bleed
// (fsf>1 → velocity zeroed) that lets a blocked jump fall/glide off a monster crowd.
// cached_velocity = realized displacement / dt (retail's SEPARATE reporting/DR value,
// pc:005158cb-5158ff; not fed back into the integrator velocity).
_body.CachedVelocity = (candidateMoved && physicsTickRan && lastTickDt > 0f)
? (resolveResult.Position - preIntegratePos) / lastTickDt
: Vector3.Zero;
// Capture prev contact/walkable BEFORE committing the new state — retail
// SetPositionInternal reads transient_state at entry for handle_all_collisions' args.
bool prevContact = _body.InContact;
bool prevOnWalkable = _body.OnWalkable;
_body.Position = resolveResult.Position;
if (physicsTickRan)
{
_prevPhysicsPos = oldTickEndPos;
_currPhysicsPos = _body.Position;
// R5-V3 (#171): retail UpdateObjectInternal TAIL — PositionManager::
// UseTime (@0x005159b3) runs AFTER the quantum's integration
// (whose head hosts adjust_offset) and only on frames where a
// physics quantum executed (retail's update_object MinQuantum
// gate skips the whole UpdateObjectInternal).
PositionManager?.UseTime();
}
// SetPositionInternal contact determination (pc:283468-510). acdream's resolver
// reports IsOnGround even during an UPWARD jump (it always step-downs), so the
// contact-plane intent stays gated by Velocity.Z<=0 (documented adaptation AD-25): a
// jump stays airborne until it descends. Determined BEFORE handle_all_collisions so the
// landing state is committed before any reflect — this ordering plus the ungated
// small-velocity-zero (Slice 1a) is what retires AD-25's micro-bounce death spiral
// (the old code reflected FIRST, so the reflected +Z defeated the landing gate).
bool justLanded = false;
if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f)
{
bool wasAirborne = !_body.OnWalkable;
_body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
_body.calc_acceleration();
// Stop the fall on landing (retail settles the into-ground component via
// calc_friction next frame; the hand-zero avoids a one-frame floor dip and makes
// handle_all_collisions' landing reflect a no-op — dot(v,n)=0).
if (_body.Velocity.Z < 0f)
_body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f);
if (wasAirborne)
{
// R4-V5 → R5-V5: retail order — minterp then moveto
// (MovementManager::HitGround 0x00524300). Re-arms a moveto suspended by the
// airborne UseTime contact gate. LeaveGround has NO moveto side (§2e).
Movement.HitGround();
justLanded = true;
}
}
else
{
// Airborne: jumping up (IsOnGround but v.z>0) OR no ground found.
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
_body.calc_acceleration();
}
// handle_all_collisions (0x00514780): reflect the into-surface velocity (fsf≤1) or
// ZERO it entirely (fsf>1 — THE airborne-stuck fix). The Stationary* bit round-trip is
// owned by the Core resolve writeback. Restores retail's should_reflect rule; on a
// landing the Velocity.Z hand-zero above makes the reflect a no-op (no micro-bounce).
// Gated on candidateMoved (retail SetPositionInternal is only reached when the candidate
// moved) so a no-move frame doesn't re-zero the gravity velocity rebuilding after a bleed.
if (candidateMoved)
{
PhysicsObjUpdate.HandleAllCollisions(
_body,
resolveResult.CollisionNormalValid, resolveResult.CollisionNormal,
prevContact, prevOnWalkable, nowOnWalkable: _body.OnWalkable);
}
// R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's LeaveGround (0x00528b00) —
// jump launches and walk-off-a-ledge both route here. The landing branch above fires
// HitGround on the opposite edge.
if (!_body.OnWalkable && !_wasAirborneLastFrame)
_motion.LeaveGround();
_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 — the wire carries the RAW motion state:
// - Forward (walk): WalkForward @ 1.0
// - Forward (run): WalkForward @ 1.0 + HoldKey.Run
// - Backward: WalkBackward @ 1.0
// D6.2b (echo-test 2026-07-01): ACE RECOMPUTES the broadcast run speed
// from the character's run skill and auto-upgrades WalkForward+HoldKey.Run
// → RunForward for observers. Sending the raw forward_speed=1.0 (omitted by
// default-difference packing) still broadcasts RunForward @ runRate — a
// retail observer saw +Acdream run at full pace.
// R3-W6: the LOCAL animation no longer needs a separate
// LocalAnimationCommand — the walk→run promotion happens inside the
// ported machinery (apply_raw_movement → apply_run_to_command
// promotes WalkForward+HoldKey.Run → RunForward @ my_run_rate on the
// interpreted side, which the DefaultSink dispatch plays).
// NOTE (R7 scope): the wire values below stay derived from input —
// the L.2b-verified byte stream is untouched; sourcing them from
// _motion.RawState needs the cdb CommandInterpreter-boundary capture
// the W6 map's §2b TODO flags, and R7 owns outbound anyway.
if (input.Forward)
{
outForwardCmd = MotionCommand.WalkForward;
outForwardSpeed = 1.0f; // RAW — ACE recomputes the broadcast speed
}
else if (input.Backward)
{
outForwardCmd = MotionCommand.WalkBackward;
outForwardSpeed = 1.0f;
}
// Source the outbound axis from the canonical input-owned raw channel.
// CameraInstantMouseLook maps keyboard TurnLeft/TurnRight into this
// same channel, so reconstructing it from only physical strafe keys
// would send ACE an idle sidestep while the local body moved.
if (_activeInputSidestepCommand is { } activeInputSidestep)
{
outSidestepCmd = activeInputSidestep;
outSidestepSpeed = _motion.RawState.SidestepSpeed;
}
// Turn commands come from the current user-owned turn channel. This is
// keyboard A/D or CameraSet's transient mouse-origin turn, never a
// server-owned MoveTo turn. The raw speed is the exact value passed to
// MotionInterpreter before hold-run adjustment.
if (_activeInputTurnCommand is { } activeInputTurn)
{
outTurnCmd = activeInputTurn;
outTurnSpeed = _activeInputTurnSpeed;
}
// ── 7. Detect motion state change ─────────────────────────────────────
// ForwardCommand can stay WalkForward while only the run-hold bit changes
// (walk W held, then Shift → run). Since D6.2b the wire forward_speed is
// always the RAW 1.0 (ACE recomputes the broadcast run speed), so the
// walk↔run toggle is detected via the HoldKey (runHold) and the
// LocalAnimationCommand change (Walk↔Run cycle), NOT via forward_speed. A
// fresh MoveToState on that toggle lets ACE's BroadcastMovement re-pick
// WalkForward vs RunForward (via HoldKey) and recompute the run speed for
// observers. The forward_speed comparison below is retained (harmless — it
// never fires now that forward_speed is constant) for defensiveness.
bool runHold = input.Run;
// R3-W6: the localAnimCmd leg is deleted with the synthesis layer;
// motionEdgeFired (any DoMotion/StopMotion/set_hold_run edge this
// frame) is OR-ed in — by construction an edge IS a state change
// (retail dispatches only on edges), keeping the wire cadence
// identical to the old output-comparison.
bool changed = outForwardCmd != _prevForwardCmd
|| outSidestepCmd != _prevSidestepCmd
|| outTurnCmd != _prevTurnCmd
|| !FloatsEqual(outForwardSpeed, _prevForwardSpeed)
|| runHold != _prevRunHold
|| motionEdgeFired;
bool mouseMovementEventDue = _mouseMovementEventPending
|| (_mouseMovementEventCandidate
&& _simTimeSeconds > _lastMouseMovementEventTime + MouseMovementEventInterval);
_mouseMovementEventCandidate = false;
if (mouseMovementEventDue)
_mouseMovementEventPending = true;
bool shouldSendMovementEvent = movementEventRequested || mouseMovementEventDue;
_prevForwardCmd = outForwardCmd;
_prevSidestepCmd = outSidestepCmd;
_prevTurnCmd = outTurnCmd;
_prevForwardSpeed = outForwardSpeed;
_prevRunHold = runHold;
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;
}
// R3-W6: the K-fix5 LocalAnimationSpeed synthesis is DELETED — the
// run pacing now comes from the ported machinery itself
// (apply_run_to_command scales the interpreted speed by my_run_rate;
// the DefaultSink dispatch plays the cycle at that speed — the same
// source remotes use).
// R4-V5: the #69 auto-walk turn-cycle edge synthesizer is DELETED —
// the MoveToManager's own aux-turn steering and TurnToHeading nodes
// dispatch TurnRight/TurnLeft through _DoMotion (the retail
// mechanism), so turn cycles during a moveto come from the same
// pipeline as everything else.
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,
// CurrentHoldKey is a level independent of active axes; every
// active ordinary axis inherits it during wire projection. This
// preserves idle run/walk toggles as well as strafe/backward run.
IsRunning: input.Run,
JustLanded: justLanded,
JumpExtent: outJumpExtent,
JumpVelocity: outJumpVelocity,
ShouldSendMovementEvent: shouldSendMovementEvent,
TurnUsesRunHold: _activeInputTurnFromMouse && outTurnCmd.HasValue,
SidestepUsesRunHold: _activeInputSidestepUsesRunHold
&& outSidestepCmd.HasValue,
IsMouseLookMovementEvent: mouseMovementEventDue);
}
/// <summary>
/// Retail <c>Frame::is_equal</c> (0x00424C30) compares both the origin
/// and all four quaternion components with a 0.0002-unit epsilon. It does
/// not treat the mathematically equivalent <c>q</c>/<c>-q</c> pair as the
/// same stored frame.
/// </summary>
private static bool ApproxFrameEqual(CellFrame a, CellFrame b)
{
const float Epsilon = 0.000199999995f;
return MathF.Abs(a.Origin.X - b.Origin.X) <= Epsilon
&& MathF.Abs(a.Origin.Y - b.Origin.Y) <= Epsilon
&& MathF.Abs(a.Origin.Z - b.Origin.Z) <= Epsilon
&& MathF.Abs(a.Orientation.W - b.Orientation.W) < Epsilon
&& MathF.Abs(a.Orientation.X - b.Orientation.X) < Epsilon
&& MathF.Abs(a.Orientation.Y - b.Orientation.Y) < Epsilon
&& MathF.Abs(a.Orientation.Z - b.Orientation.Z) < Epsilon;
}
/// <summary>
/// 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.
/// </summary>
private static bool ApproxPlaneEqual(
System.Numerics.Plane a, System.Numerics.Plane b)
{
const float Epsilon = 0.000199999995f;
return MathF.Abs(a.Normal.X - b.Normal.X) <= Epsilon
&& MathF.Abs(a.Normal.Y - b.Normal.Y) <= Epsilon
&& MathF.Abs(a.Normal.Z - b.Normal.Z) <= Epsilon
&& MathF.Abs(a.D - b.D) < Epsilon;
}
}