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, // CommandInterpreter::SendMovementEvent @ 0x006B4680 passes the // MovementManager's complete RawMotionState into MoveToStatePack. An // absent style bit unpacks as NonCombat, so the canonical raw style must // travel with every input-boundary snapshot sent to ACE. uint CurrentStyle = 0x8000003Du); /// /// 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; /// /// Retail CPhysicsObj::m_scale. Grounded CSequence root /// displacement is multiplied by this value before PositionManager /// composition (UpdatePositionInternal @ 0x00512C30). /// public float ObjectScale { get; set; } = 1f; /// /// 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; /// /// Horizontal projection of the authoritative body quaternion. Assigning /// a yaw is the explicit retail Frame::set_heading seam and therefore /// intentionally replaces pitch/roll; ordinary object ticks never rebuild /// the quaternion from this projection. /// public float Yaw { get => AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading( AcDream.Core.Physics.Motion.MoveToMath.GetHeading(_body.Orientation)); set { float wrapped = value; while (wrapped > MathF.PI) wrapped -= 2f * MathF.PI; while (wrapped < -MathF.PI) wrapped += 2f * MathF.PI; _body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading( _body.Orientation, AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(wrapped)); } } public Vector3 Position => _body.Position; public Vector3 RenderPosition => ComputeRenderPosition(); public uint CellId { get; private set; } public AcDream.Core.Physics.Position CellPosition => _body.CellPosition; /// /// True only when the most recent visible or Hidden object update admitted /// at least one complete retail quantum. Presentation uses this to rebuild /// a Hidden part pose after HandleEnterWorld without doing per-render-frame /// work while the object clock is retaining a fragment. /// internal bool AdvancedObjectQuantumLastTick { get; private set; } /// /// 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( out AcDream.Core.Physics.Position outboundPosition) { AcDream.Core.Physics.Position canonical = _body.CellPosition; outboundPosition = new AcDream.Core.Physics.Position( canonical.ObjCellId, canonical.Frame.Origin, _body.Orientation); return PositionFrameValidation.IsValid( outboundPosition.ObjCellId, outboundPosition.Frame.Origin, outboundPosition.Frame.Orientation); } /// /// 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; // R6 retail complete-object scheduler (2026-07-19). // // Retail's CPhysicsObj::update_object subdivides elapsed time into // MaxQuantum-sized complete object updates plus a remainder, retaining // that remainder while it is at or below MinQuantum. The 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 readonly RetailObjectQuantumClock _objectClock; private Vector3 _prevPhysicsPos; private Vector3 _currPhysicsPos; private Action? _advanceAnimationRootMotion; private Action? _processAnimationHooks; private readonly AcDream.Core.Physics.Motion.MotionDeltaFrame _animationRootMotionScratch = new(); private readonly AcDream.Core.Physics.Motion.MotionDeltaFrame _positionManagerDeltaScratch = new(); // ── 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, RetailObjectQuantumClock? objectClock = null) { _physics = physics; _objectClock = objectClock ?? new RetailObjectQuantumClock(); _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); Movement.ActivatePhysicsObject = ActivateFromMovement; // 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; } /// /// Host half of retail MovementManager::PerformMovement's /// unconditional CPhysicsObj::set_active(1) head. Static objects /// reject activation; an inactive ordinary object rebases its canonical /// object clock before setting the body's transient bit. /// private void ActivateFromMovement() { if ((_body.State & PhysicsStateFlags.Static) != 0) return; _objectClock.Activate(); _body.TransientState |= TransientStateFlags.Active; } /// /// Applies retail's parent/cell-less/Frozen early gate: clear Active and /// advance only wall-clock presentation time. The next eligible frame /// reactivates through set_active(1) and rebases the object clock. /// internal void SuspendObjectUpdate(float elapsedSeconds) { AdvancedObjectQuantumLastTick = false; if (float.IsFinite(elapsedSeconds) && elapsedSeconds > 0f) _simTimeSeconds += elapsedSeconds; _objectClock.Deactivate(); _body.TransientState &= ~TransientStateFlags.Active; } /// /// 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: complete body orientation for the /// manager's position seam. is /// only its horizontal projection. internal Quaternion BodyOrientation => _body.Orientation; /// /// Install a complete authoritative frame rotation (spawn, teleport, or /// server correction) without collapsing it through the yaw projection. /// internal void SetBodyOrientation(Quaternion orientation) { _body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate( _body.Position, _body.Orientation, 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; } /// /// Binds the owning PartArray/CSequence update to the local physics tick. /// The callback runs after this frame's input edges have reached the motion /// table and writes the complete local Frame emitted by /// CSequence::update. The callback runs only for admitted object /// quanta; render-frame fragments are retained by the object clock rather /// than advancing PartArray early. /// public void AttachAnimationRootMotionSource( Action advance, Action? processHooks = null) { _advanceAnimationRootMotion = advance ?? throw new ArgumentNullException(nameof(advance)); _processAnimationHooks = processHooks; _animationRootMotionScratch.Reset(); } /// /// 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, CurrentStyle: raw.CurrentStyle); } /// /// Captures the current controller state for presentation without /// advancing physics or requesting an outbound movement event. /// public MovementResult CapturePresentationResult() { MovementResult current = CaptureMovementResult(mouseLookEvent: false); return current with { MotionStateChanged = false, ShouldSendMovementEvent = false, IsMouseLookMovementEvent = false, }; } /// /// 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, bool publishRenderRoot = true) { 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]). if (publishRenderRoot) _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) => SetPositionCore( pos, cellId, cellLocal, publishSharedState: true); /// /// Builds a new local controller's canonical body pose without publishing /// the renderer's global current cell or mutating the incarnation-stable /// PositionManager. Player-mode entry commits those shared edges only /// after camera, shadow, and host preparation succeeds. /// internal void PreparePositionForCommit( Vector3 pos, uint cellId, Vector3 cellLocal) => SetPositionCore( pos, cellId, cellLocal, publishSharedState: false); internal void CommitPreparedPosition() { _physics.UpdatePlayerCurrCell(CellId); PositionManager?.UnStick(); } private void SetPositionCore( Vector3 pos, uint cellId, Vector3 cellLocal, bool publishSharedState) { _body.SnapToCell(cellId, pos, cellLocal); _prevPhysicsPos = pos; _currPhysicsPos = pos; UpdateCellId( _body.CellPosition.ObjCellId, "teleport", publishSharedState); // Treat as grounded after a server-side position snap. _body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active; _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.) if (publishSharedState) 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; _objectClock.ResetForEnterWorld(); } /// /// 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( (float)(_objectClock.PendingSeconds / 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, Action? handleTargeting = null) { AdvancedObjectQuantumLastTick = false; if (!float.IsFinite(dt) || dt <= 0f) { return CapturePresentationResult() with { RenderPosition = _body.Position, IsOnGround = _body.OnWalkable, }; } _simTimeSeconds += dt; bool reactivated = _objectClock.Activate(); _body.TransientState |= TransientStateFlags.Active; RetailObjectQuantumBatch batch = reactivated ? default : _objectClock.Advance(dt); AdvancedObjectQuantumLastTick = batch.Count > 0; if (batch.Discarded) { _prevPhysicsPos = _body.Position; _currPhysicsPos = _body.Position; } for (int qi = 0; qi < batch.Count; qi++) { float quantum = batch.GetQuantum(qi); Vector3 previousPosition = _body.Position; bool previousContact = _body.InContact; bool previousOnWalkable = _body.OnWalkable; if (PositionManager is { } manager) { var delta = _positionManagerDeltaScratch; delta.Reset(); manager.AdjustOffset(delta, quantum); if (delta.Origin != Vector3.Zero) _body.Position += Vector3.Transform(delta.Origin, _body.Orientation); if (!delta.Orientation.IsIdentity) { _body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate( _body.Position, _body.Orientation, _body.Orientation * delta.Orientation); } } // CPhysicsObj::process_hooks remains inside UpdatePositionInternal // even when Hidden suppresses PartArray and physics advancement. _processAnimationHooks?.Invoke(); 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.CommitTransitionPosition(resolved.CellId, 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"); } RetailObjectManagerTail.Run( handleTargeting, Movement, _motion.CheckForCompletedMotions, PositionManager); _prevPhysicsPos = _body.Position; _currPhysicsPos = _body.Position; } _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, CurrentStyle: _motion.RawState.CurrentStyle); } public MovementResult Update( float dt, MovementInput input, Action? handleTargeting = null) { AdvancedObjectQuantumLastTick = false; // Reject a malformed host-frame duration at the controller boundary. // The retail object clock cannot sanitize state that input/jump/yaw // code already mutated; in particular Infinity would never converge // in heading wrap. A rejected frame is a pure presentation read. if (!float.IsFinite(dt) || dt <= 0f) return CapturePresentationResult(); _simTimeSeconds += dt; // 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, CurrentStyle: _motion.RawState.CurrentStyle); } // ── 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; // Retail input reaches MotionTableManager before this object's // CPartArray update. The complete-object scheduler below advances it // once per admitted retail quantum, after every input edge for this // render frame has reached the motion table. bool hasAnimationRootMotion = _advanceAnimationRootMotion is not null; // ── 1. Jump input (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; } // ── 2. Run admitted complete-object quanta ──────────────────────────── // CPhysicsObj::update_object (0x00515D10) retains a remainder at or // below MinQuantum, splits elapsed time above MaxQuantum, and discards // stale gaps above HugeQuantum. Every admitted quantum executes the // whole object update below; animation never runs on a render-only // fragment. bool reactivated = _objectClock.Activate(); _body.TransientState |= TransientStateFlags.Active; RetailObjectQuantumBatch quantumBatch = reactivated ? default : _objectClock.Advance(dt); AdvancedObjectQuantumLastTick = quantumBatch.Count > 0; bool justLanded = false; if (quantumBatch.Discarded) { _prevPhysicsPos = _body.Position; _currPhysicsPos = _body.Position; } for (int qi = 0; qi < quantumBatch.Count; qi++) { float tickDt = quantumBatch.GetQuantum(qi); // CPhysicsObj::UpdatePositionInternal (0x00512C30): visible objects // advance their PartArray first. The complete Frame survives; only its // origin is scaled while OnWalkable or zeroed while airborne. var pmDelta = _positionManagerDeltaScratch; pmDelta.Reset(); if (_advanceAnimationRootMotion is { } advanceRootMotion) { _animationRootMotionScratch.Reset(); advanceRootMotion(tickDt, _animationRootMotionScratch); pmDelta.Origin = _body.OnWalkable ? _animationRootMotionScratch.Origin * ObjectScale : Vector3.Zero; pmDelta.Orientation = _animationRootMotionScratch.Orientation; } else if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight) { // AP-77: explicit no-PartArray/headless fallback. Production // humanoids consume the DAT-authored complete CSequence Frame. Yaw -= 1.5f * _motion.InterpretedState.TurnSpeed * tickDt; } if (_body.OnWalkable) { float savedWorldVz = _body.Velocity.Z; if (hasAnimationRootMotion) { _body.Velocity = new Vector3(0f, 0f, savedWorldVz); } else { Vector3 stateVelocity = _motion.get_state_velocity(); _body.set_local_velocity( new Vector3(stateVelocity.X, stateVelocity.Y, savedWorldVz), autonomous: _body.LastMoveWasAutonomous); } } var preIntegratePos = _body.Position; Vector3 oldTickEndPos = _currPhysicsPos; // PositionManager::adjust_offset (0x00512D0E) mutates the SAME // complete Frame after PartArray. Interpolation may replace it; // Sticky/Constraint then compose according to their retail rules. PositionManager?.AdjustOffset(pmDelta, tickDt); if (pmDelta.Origin != Vector3.Zero) _body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation); if (!pmDelta.Orientation.IsIdentity) { _body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate( _body.Position, _body.Orientation, _body.Orientation * pmDelta.Orientation); } _body.calc_acceleration(); _body.UpdatePhysicsInternal(tickDt); // Retail process_hooks is the final UpdatePositionInternal step: // after physics, before the transition and manager tail. _processAnimationHooks?.Invoke(); 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; // ── 3. 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 ? (resolveResult.Position - preIntegratePos) / tickDt : 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.CommitTransitionPosition(resolveResult.CellId, resolveResult.Position); _prevPhysicsPos = oldTickEndPos; _currPhysicsPos = _body.Position; // 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 landedThisQuantum = 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(); landedThisQuantum = 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; justLanded |= landedThisQuantum; UpdateCellId(resolveResult.CellId, "resolver"); // Named retail CPhysicsObj::UpdateObjectInternal (0x005156B0): // process_hooks has already completed inside UpdatePositionInternal, // then the transition commits, and only then does the ordered manager // tail runs in its exact Detection/Target/Movement/PartArray/ // Position order. DetectionManager is not ported, so its slot is // presently null. RetailObjectManagerTail.Run( handleTargeting, Movement, _motion.CheckForCompletedMotions, PositionManager); } // ── 4. 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, CurrentStyle: _motion.RawState.CurrentStyle); } /// /// 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; } }