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); /// /// 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 gmPowerbarUI. /// public readonly record struct JumpChargeSnapshot(bool IsCharging, float Power); /// /// 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, // 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); /// /// 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; /// /// 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; } public AcDream.Core.Physics.Position CellPosition => _body.CellPosition; /// /// Returns retail's canonical outbound Position: the physics body's /// carried cell id plus its landblock-local frame origin. Retail /// CommandInterpreter::SendMovementEvent @ 0x006B4680 and /// SendPositionEvent @ 0x006B4770 serialize /// CPhysicsObj::m_position directly; render/streaming origins are not /// part of the protocol frame. /// 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); } /// /// 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; } /// /// Applies the canonical server PhysicsState to the local body. Retail's /// CPhysicsObj::SetState replaces these persistent bits before its /// next acceleration/integration decision. /// public void ApplyPhysicsState(PhysicsStateFlags state) { _body.State = state; _body.calc_acceleration(); } 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; /// /// Current retail jump-powerbar state. Power is always zero when no jump is /// pending and otherwise lies in [0,1]. /// 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; /// /// Retail's mouse-look movement report interval from /// CameraSet::Rotate and MouseLookHandler. /// 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. /// /// 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. /// 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; /// 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; // ── 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. /// /// R5-V5: retail CPhysicsObj::movement_manager (acclient.h /// /* 3463 */) — the ONE owner of this controller's /// + pair. Constructed in the /// ctor around the interp; the MoveToManager side arrives via /// MoveToFactory + MakeMoveToManager() in /// GameWindow.EnterPlayerModeNow (the same facade shape /// EnsureRemoteMotionBindings gives remotes). /// ticks UseTime() (0x005242f0) at the slot the deleted /// DriveServerAutoWalk occupied and relays HitGround() /// (0x00524300, minterp first then moveto). /// public AcDream.Core.Physics.Motion.MovementManager Movement { get; } /// /// R4-V5: the local player's verbatim retail MoveToManager /// (decomp 0x00529010-0x0052a987), constructed + seam-bound by /// GameWindow.EnterPlayerModeNow against this controller's /// /body/Yaw (the same wiring shape /// EnsureRemoteMotionBindings uses for remotes). GameWindow /// routes inbound mt 6-9 movement events through /// . /// User input cancels a moveto through the retail chain: key edge → /// DoMotion (ctor-default params, CancelMoveTo bit set) → /// → /// CancelMoveTo(ActionCancelled) (register TS-36 retired). /// R5-V5: a view of 's moveto child; the setter is /// sugar over the facade's factory path (kept for the /// PlayerMoveToCutoverTests rig and any pre-facade bind shape). /// public AcDream.Core.Physics.Motion.MoveToManager? MoveTo { get => Movement.MoveTo; set { var mtm = value ?? throw new ArgumentNullException(nameof(value)); Movement.MoveToFactory = () => mtm; Movement.MakeMoveToManager(); } } /// /// R5-V3 (#171): the player's PositionManager facade (retail /// CPhysicsObj::position_manager — owned by the player's /// EntityPhysicsHost, handed here by EnterPlayerModeNow). /// drives it at the two retail per-tick points: /// AdjustOffset inside the physics-tick block (retail /// UpdatePositionInternal @0x00512d0e, BEFORE /// UpdatePhysicsInternal so the sticky steer is part of the swept /// motion) and UseTime after the completed-motions sweep (retail /// UpdateObjectInternal tail @0x005159b3 — the sticky 1 s lease /// watchdog). tears any /// stick down (retail teleport_hook @0x00514eee). /// 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; } /// /// Begins retail instant mouse-look. CameraSet::ToggleMouseLook /// (0x00457490) sends a movement event on both transitions, even before a /// horizontal turn sample arrives. /// 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; } /// /// Supplies the latest signed, filtered horizontal mouse adjustment. /// Negative means TurnRight in acdream's yaw convention; positive means /// TurnLeft. Retail CameraSet::Rotate doubles the magnitude, applies /// a 0.02 dead zone, and caps the raw turn speed at 1.5. /// public void SubmitMouseTurnAdjustment( float signedAdjustment, MovementInput input) { if (!_mouseLookActive || !float.IsFinite(signedAdjustment)) return; TakeControlFromServer(input); _mouseTurnAdjustment = signedAdjustment; _mouseTurnSamplePending = true; _mouseMovementEventCandidate = true; } /// /// Retail's delayed zero-input MouseLookHandler calls /// CommandInterpreter::StopDrift @ 0x006B4510 before filtering the /// zero sample. It stops both turn directions and participates in the /// same half-second movement-report cadence. /// 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; } /// /// 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. /// 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, }; /// /// Retail CommandInterpreter::TakeControlFromServer /// (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. /// 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); } /// /// Retail ClientCombatSystem::StartAttackRequest (0x0056C040) /// invokes CommandInterpreter::MaybeStopCompletely before building /// the attack. This removes a mouse-origin drift immediately and schedules /// the final authoritative heading ahead of the eventual attack send. /// 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); } /// /// R3-W2 (r3-port-plan.md §4): the player's /// — 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. /// internal MotionInterpreter Motion => _motion; /// /// Retail CPhysicsObj::StopCompletely (0x00510180) enters through /// MovementManager::PerformMovement (0x005240D0), not directly /// through CMotionInterp::StopCompletely. The facade's type-5 path /// performs the required zero-duration animation completion sweep after /// the interpreter has queued its matching pending-motion node. /// internal WeenieError StopCompletelyAtPhysicsObjectBoundary() => Movement.PerformMovement(new MovementStruct { Type = MovementType.StopCompletely, }); /// /// Retail CPhysicsObj::DoMotion (0x00510020) constructs a type-1 /// MovementStruct carrying the original parameters pointer and /// routes it through MovementManager::PerformMovement. That outer /// boundary is observable: CMotionInterp::PerformMovement /// (0x00528E80) performs the synchronous completed-animation sweep after /// the motion dispatch. /// 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, }); } /// /// Retail CPhysicsObj::StopMotion (0x005100D0), the type-3 peer of /// . Player input must use /// this facade; MoveToManager's private _DoMotion/_StopMotion path stays /// directly on CMotionInterp, matching retail. /// 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, }); } /// R4-V5: CONTACT transient-state bit (retail /// transient_state & 1) — the manager's /// UseTime tick gate reads exactly this bit (decomp §6a @307781; a /// strict subset of the funnel's Contact+OnWalkable gate). internal bool BodyInContact => _body.InContact; /// Retail SendPositionEvent admission gate: both Contact /// and OnWalkable must be present on the local physics body. internal bool CanSendPositionEvent => _body.InContact && _body.OnWalkable; /// R4-V5: body orientation for the /// manager's position seam (re-derived from every /// Update — heading reads/writes go through Yaw, not this). internal Quaternion BodyOrientation => _body.Orientation; /// R4-V5 wedge fix — the P1 unpack store /// (CPhysics::SetObjectMovement @00509730: /// last_move_was_autonomous = arg7, 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. internal void SetLastMoveWasAutonomous(bool autonomous) { _body.LastMoveWasAutonomous = autonomous; _controlledByServer = !autonomous; } /// /// 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; } /// /// Retail CommandInterpreter::SendMovementEvent (0x006B4680) /// stamps only last_sent_position_time. The carried frame and /// contact plane remain those from the last AutonomousPosition packet. /// public void NoteMovementSent(float nowSeconds, bool mouseLookEvent = false) { _lastSentTime = nowSeconds; if (mouseLookEvent) { _mouseMovementEventPending = false; _lastMouseMovementEventTime = nowSeconds; } } /// /// 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. /// 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); } /// /// Retail CommandInterpreter::SendPositionEvent (0x006B4770) /// stamps the send time, complete cell-local frame (origin and /// orientation), and contact plane after an AutonomousPosition packet. /// public void NotePositionSent(AcDream.Core.Physics.Position position, System.Numerics.Plane contactPlane, float nowSeconds) { _lastSentPosition = position; _lastSentContactPlane = contactPlane; _lastSentTime = nowSeconds; _lastSentInitialized = true; } /// /// Faithful port of retail /// CommandInterpreter::ShouldSendPositionEvent (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. /// 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); /// /// Server-snap / teleport placement. is the /// LANDBLOCK-relative position (the wire's local, or world − landblock origin) /// which seeds the body's cell-relative CellPosition WITHOUT any streaming /// center (#145). A teleport is a large jump, so this snaps the cell frame /// directly via SnapToCell rather than delta-syncing through the setter. /// 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; } /// /// Retail SmartBox::BlipPlayer (0x00453940): apply a server /// FORCE_POSITION correction through CPhysicsObj::SetPositionSimple /// without the teleport hook. Active motion, velocity, contact state, and /// PositionManager stick relationships deliberately survive the blip. /// 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); } /// /// Retail Hidden slice of CPhysicsObj::UpdatePositionInternal /// (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. /// 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); } /// /// Retail Frame::is_equal (0x00424C30) compares both the origin /// and all four quaternion components with a 0.0002-unit epsilon. It does /// not treat the mathematically equivalent q/-q pair as the /// same stored frame. /// 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; } /// /// 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 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; } }