using System; using System.Numerics; using AcDream.Core.Physics; namespace AcDream.App.Input; /// /// Input state for a single frame of player movement. /// public readonly record struct MovementInput( bool Forward = false, bool Backward = false, bool StrafeLeft = false, bool StrafeRight = false, bool TurnLeft = false, bool TurnRight = false, bool Run = false, float MouseDeltaX = 0f, bool Jump = false); /// /// Result of a single frame's movement update. /// /// /// Wire vs. local animation command. ACE's MovementData /// (ACE.Server/Network/Motion/MovementData.cs) only computes /// interpState.ForwardSpeed for raw WalkForward/ /// WalkBackwards — on every other command the else branch /// passes through command without setting speed, leaving observers with /// speed=0. The client therefore has to send WalkForward /// (with HoldKey.Run for running) and let ACE auto-upgrade to /// RunForward for broadcast. But the LOCAL view wants the run /// cycle immediately, so we carry a separate /// for the player's own renderer. /// /// /// — true when the player is holding Shift to run. /// Used by the GameWindow when building the outbound MoveToState's /// CURRENT_HOLD_KEY (2=Run) vs (1=None). /// /// public readonly record struct MovementResult( Vector3 Position, Vector3 RenderPosition, uint CellId, bool IsOnGround, bool MotionStateChanged, uint? ForwardCommand, // wire-side command (WalkForward / WalkBackward / …) uint? SidestepCommand, uint? TurnCommand, float? ForwardSpeed, float? SidestepSpeed, float? TurnSpeed, bool IsRunning = false, // 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. /// /// Portal-space state for the player movement controller. /// PortalSpace freezes all movement input while the server is moving the /// player through a portal — resumed once the destination UpdatePosition /// arrives and the player is snapped to the new location. /// While in PortalSpace, Update returns immediately with a zero-movement /// result so no WASD input or physics is processed. /// public enum PlayerState { InWorld, PortalSpace } /// /// Per-frame player movement controller. Reads input, drives the /// ported PhysicsBody + MotionInterpreter, tracks motion state for /// animation + server messages. /// /// Architecture: /// - PhysicsBody owns integration: gravity, friction, sub-stepping, /// velocity clamping — all from the decompiled retail client. /// - MotionInterpreter owns the motion state machine: walk/run/jump /// validation, state tracking, speed constants from the retail dat. /// - PhysicsEngine.Resolve is still used each frame to snap the player /// to terrain/cell floor Z and detect ground contact. /// public sealed class PlayerMovementController { private readonly PhysicsEngine _physics; private readonly PhysicsBody _body; private readonly MotionInterpreter _motion; private readonly PlayerWeenie _weenie; public float MouseTurnSensitivity { get; set; } = 0.003f; /// /// Maximum Z increase per movement step before the move is rejected. /// Retail's step_up_height for human characters is ~0.4 m (hip- /// level). Setting this too high lets the player teleport up small /// buildings via the step-up scan finding any walkable polygon within /// reach (Bug 3 in L.2.3 testing — walking into a steep slope mounted /// the building's flat top instead of sliding off the slope). /// Authoritative source is the player's Setup.StepUpHeight set /// in GameWindow.cs at world-entry time. /// public float StepUpHeight { get; set; } = 0.4f; /// /// L.2.3a (2026-04-29): how far below the foot the step-down probe /// reaches when transitioning between surfaces. Retail's /// step_down_height for human characters is ~0.4 m. With the /// previous 4 cm hardcoded value, walking off the top of a stair onto /// the ground 25 cm below produced a one-frame contact-plane gap — the /// animation system briefly flickered to falling. /// public float StepDownHeight { get; set; } = 0.4f; /// /// Current portal-space state. Set to PortalSpace when the server sends /// PlayerTeleport (0xF751); set back to InWorld once the destination /// UpdatePosition arrives and the player is snapped to the new cell. /// While in PortalSpace, Update returns immediately with a zero-movement /// result so no WASD input or physics is processed. /// public PlayerState State { get; set; } = PlayerState.InWorld; public float Yaw { get; set; } public Vector3 Position => _body.Position; public Vector3 RenderPosition => ComputeRenderPosition(); public uint CellId { get; private set; } /// /// Local-player entity id used to skip self-collision in the /// airborne sweep. GameWindow updates this whenever the local /// `+Acdream` entity (re)spawns. Default 0 = no filter (matches /// retail's CObjCell::find_obj_collisions self-skip when the /// caller's OBJECTINFO::object pointer is null). Without this the /// sweep collides with its own ShadowEntry registered at /// GameWindow.cs:2545 — see #42. /// public uint LocalEntityId { get; set; } public bool IsAirborne => !_body.OnWalkable; /// /// Current vertical (Z-axis) velocity of the physics body. /// Positive = rising, negative = falling. Exposed for tests and HUD. /// public float VerticalVelocity => _body.Velocity.Z; /// Full 3D world-space velocity of the physics body. Exposed for diagnostic logging. public Vector3 BodyVelocity => _body.Velocity; /// /// 2026-05-16 — current contact plane (normal + distance) for the /// physics body. Exposed so the network outbound layer can stamp /// it into for retail's diff-driven /// AP cadence: SendPositionEvent re-sends if cell OR contact-plane /// changed since last_sent, per /// acclient_2013_pseudo_c.txt:700233 ShouldSendPositionEvent. /// public System.Numerics.Plane ContactPlane => _body.ContactPlane; // Jump charge state. private bool _jumpCharging; private float _jumpExtent; // K-fix6 (2026-04-26): retail's PowerBar charge constant for jump is // not legible in the named decomp (the divisor was clobbered in // GetPowerBarLevel's FPU stack reordering at FUN_0056ade0). 2.0/s // (full charge in 0.5s) feels matches retail muscle memory better // than the previous 1.0/s — a tap gives a noticeable hop, half-hold // a meaningful jump, full-hold the maximum extent. The vertical // velocity formula itself (height × 19.6 → vz) is unchanged and // matches retail byte-for-byte; only the time-to-fill is faster. private const float JumpChargeRate = 2.0f; // Airborne → grounded transition detection. Flipped on every frame where // the body transitions from airborne to on-walkable; used by the GameWindow // to drive the landing animation cycle. private bool _wasAirborneLastFrame; // Previous frame's motion commands for change detection (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; // Heartbeat timer. // Cadence is 1.0 sec to match holtburger's // AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the retail trace // (2026-05-01 motion-trace findings.md): retail sends ~1 Hz at rest, // not the 5 Hz our pre-fix code used. Sending at 5 Hz was harmless // but wasteful and probably looked like jitter to observers. /// /// 2026-05-16 — retail-faithful AP cadence. Matches retail's /// CommandInterpreter::ShouldSendPositionEvent (acclient_2013_pseudo_c.txt /// at address 0x006b45e0) which gates on either (a) position-or-cell /// change since the last send, or (b) at-rest 1 sec heartbeat elapsed. /// `time_between_position_events` constant at 0x006b3efb = 1.0 sec. /// /// Old model: a 1 Hz idle / 10 Hz active flat accumulator. That /// missed retail's per-frame-while-moving behaviour and forced the /// four B.6 workarounds (arrival margin, re-send on arrival, AP /// flush, retry flag) to compensate for the lag in ACE's server-side /// WithinUseRadius poll. Replaced by diff-driven cadence below. /// public const float HeartbeatInterval = 1.0f; // retail 0x006b3efb private System.Numerics.Vector3 _lastSentPos; private uint _lastSentCellId; private System.Numerics.Plane _lastSentContactPlane; private float _lastSentTime; private bool _lastSentInitialized; private float _simTimeSeconds; public bool HeartbeatDue { get; private set; } /// Sim-time accumulator (advanced by dt at the top of Update). /// Exposed for the network outbound layer to stamp NotePositionSent. public float SimTimeSeconds => _simTimeSeconds; // L.5 retail physics-tick gate (2026-04-30). // // Retail's CPhysicsObj::update_object subdivides per-frame dt into // MinQuantum (1/30s) sized integration steps, SKIPPING entirely when // accumulated dt is below MinQuantum. The retail debugger trace // confirmed this: UpdatePhysicsInternal fires only ~61% as often as // update_object — i.e., retail's effective physics tick rate is 30Hz // even when the renderer runs at 60+Hz. // // Without this gate our acdream integrates at the full render rate // (60+Hz), which compresses bounce-energy / gravity-tangent // accumulation into half the time. Per-frame V grows ~2x faster than // retail's. On a steep-slope tangent that produces the wedge: V grows // tangent + huge while position reverts each frame, body locks in // place. Retail's slower integration cadence (and larger per-tick // position deltas) lets the body geometrically escape the tangent. // // Source: retail debugger trace 2026-04-30 // update_object = 40,960 calls // UpdatePhysicsInternal = 25,087 calls (61%) // ratio implies 39% of frames return early via the MinQuantum gate. // // ACE: PhysicsObj.UpdateObject (Physics.cs). // Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950). private float _physicsAccum; private Vector3 _prevPhysicsPos; private Vector3 _currPhysicsPos; // ── 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; } 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; /// 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; /// 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; /// /// 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; } /// /// 2026-05-16. Called by the network outbound layer after every /// AutonomousPosition or MoveToState that carries the player's /// position. Resets the diff-driven heartbeat clock so the next /// `HeartbeatDue` evaluation requires either a fresh state change /// (cell, contact-plane, or frame) OR another full HeartbeatInterval. /// Mirrors retail's SendPositionEvent at /// acclient_2013_pseudo_c.txt:700345-700348 which updates /// `last_sent_position`, `last_sent_position_time`, AND /// `last_sent_contact_plane` after every send. /// public void NotePositionSent(System.Numerics.Vector3 worldPos, uint cellId, System.Numerics.Plane contactPlane, float nowSeconds) { _lastSentPos = worldPos; _lastSentCellId = cellId; _lastSentContactPlane = contactPlane; _lastSentTime = nowSeconds; _lastSentInitialized = true; } // 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(cellId, "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). _motion.StopCompletely(); // 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; // Reset physics clock so any subsequent update_object calls start fresh. _body.LastUpdateTime = 0.0; _physicsAccum = 0f; } private Vector3 ComputeRenderPosition() { float alpha = Math.Clamp(_physicsAccum / PhysicsBody.MinQuantum, 0f, 1f); return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha); } public MovementResult Update(float dt, MovementInput input) { _simTimeSeconds += dt; // 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). bool motionEdgeFired = false; { 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) { _motion.DoMotion(MotionCommand.WalkForward, p); motionEdgeFired = true; } else if (input.Backward && !_prevBackwardHeld && !input.Forward) { _motion.DoMotion(MotionCommand.WalkBackward, p); motionEdgeFired = true; } if (!input.Forward && _prevForwardHeld) { if (input.Backward) _motion.DoMotion(MotionCommand.WalkBackward, p); else _motion.StopMotion(MotionCommand.WalkForward, p); motionEdgeFired = true; } else if (!input.Backward && _prevBackwardHeld && !input.Forward) { _motion.StopMotion(MotionCommand.WalkBackward, p); motionEdgeFired = true; } // Sidestep channel. if (input.StrafeRight && !_prevStrafeRightHeld) { _motion.DoMotion(MotionCommand.SideStepRight, p); motionEdgeFired = true; } else if (input.StrafeLeft && !_prevStrafeLeftHeld) { _motion.DoMotion(MotionCommand.SideStepLeft, p); motionEdgeFired = true; } else if (!input.StrafeRight && !input.StrafeLeft && (_prevStrafeRightHeld || _prevStrafeLeftHeld)) { _motion.StopMotion(MotionCommand.SideStepRight, p); motionEdgeFired = true; } // Turn channel. if (input.TurnRight && !_prevTurnRightHeld) { _motion.DoMotion(MotionCommand.TurnRight, p); motionEdgeFired = true; } else if (input.TurnLeft && !_prevTurnLeftHeld) { _motion.DoMotion(MotionCommand.TurnLeft, p); motionEdgeFired = true; } else if (!input.TurnRight && !input.TurnLeft && (_prevTurnRightHeld || _prevTurnLeftHeld)) { _motion.StopMotion(MotionCommand.TurnRight, p); 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; } _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 turn stays a direct Yaw delta (deliberately generates no // turn command — avoids MoveToState spam from mouse jitter). if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight) { Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver * _motion.InterpretedState.TurnSpeed * dt; } Yaw -= input.MouseDeltaX * MouseTurnSensitivity; // Wrap yaw to [-PI, PI] so it doesn't grow unbounded. while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI; while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI; // Sync the body's orientation quaternion with our Yaw (rotation about Z). // Convention: Yaw=0 faces +X. Local body +Y is "forward", so we rotate // by (Yaw - PI/2) about Z to map local +Y → world (cos Yaw, sin Yaw, 0). _body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f); // ── 2. Set velocity via MotionInterpreter state machine ─────────────── // 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(); } _jumpExtent = MathF.Min(_jumpExtent + dt * JumpChargeRate, 1.0f); } else if (_jumpCharging) { // Spacebar released (or left ground during charge) — fire jump. var jumpResult = _motion.jump(_jumpExtent); if (jumpResult == WeenieError.None) { // 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; 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); // 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; // ── 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 resolved position. _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). Diff-review find: // a head-of-frame placement tore an expiring stick down BEFORE // the crossing quantum's final steer/turn — retail applies that // last adjust_offset, THEN the tail watchdog tears down. PositionManager?.UseTime(); } // L.3a (2026-04-30): retail wall-bounce / velocity reflection. // // Retail's CPhysicsObj::handle_all_collisions runs after every // SetPositionInternal. It reads the wall normal that the // transition's slide computed and reflects the body's velocity: // // v_new = v - (1 + elasticity) * dot(v, n) * n // // This is what gives retail its "bouncy" feel — fast head-on // jumps push the player back from the wall, glancing angles // produce a small deflection. acdream's transition resolver // SLID position correctly but never updated velocity, so the // player kept driving into walls until the controller's input // changed direction. Felt sticky / fragile. // // Suppression rule (apply_bounce): grounded movement on a wall // SHOULDN'T bounce — sliding along a corridor is expected. Only // airborne wall hits reflect. Mirrors retail's `var_10_1` guard // and ACE PhysicsObj.cs:2656-2660 `apply_bounce`. // // Inelastic flag (spell projectiles, missiles) zeros velocity // entirely instead of reflecting. The player never has it set. // // Sources: // acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions) // acclient.h:2834 (INELASTIC_PS = 0x20000) // ACE PhysicsObj.cs:2656-2721 (line-for-line port) // PhysicsGlobals.DefaultElasticity = 0.05f, MaxElasticity = 0.1f if (resolveResult.CollisionNormalValid) { bool prevOnWalkable = _body.OnWalkable; bool nowOnWalkable = resolveResult.IsOnGround; // apply_bounce: bounce ONLY when the body stays airborne both // before and after this step. That is: jumping into a wall // mid-flight, hitting a ceiling, etc. Specifically NOT: // // - prev grounded + now grounded → wall-slide along corridor // (bounce would feel sticky on every wall touch). // - prev airborne + now grounded → terrain landing // (terrain normal is mostly +Z; reflecting downward velocity // would push the body upward and prevent the landing snap // from firing — player perpetually micro-bouncing on the // floor instead of resting). // - prev grounded + now airborne → walked off cliff // (gravity should take over, not lateral bounce). // // Sledding mode reverts to retail's broader rule (bounce // unless both grounded), since sledding intentionally bounces // off ramps. // // This is more conservative than retail's strict // `!(prev && now && !sledding)` rule — retail bounces on // landing too, but at elasticity 0.05 the visual effect is // imperceptible there. acdream's per-frame architecture // amplifies the artifact (the post-reflection upward Z // defeats the controller's `Velocity.Z <= 0` landing-snap // gate), so we suppress it on landing to avoid the // micro-bounce death spiral. bool applyBounce = _body.State.HasFlag(PhysicsStateFlags.Sledding) ? !(prevOnWalkable && nowOnWalkable) : (!prevOnWalkable && !nowOnWalkable); // L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug. bool diagSteep = AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled; if (diagSteep && resolveResult.CollisionNormalValid) { var n0 = resolveResult.CollisionNormal; var v0 = _body.Velocity; Console.WriteLine( $"[steep-roof] BOUNCE-CHECK applyBounce={applyBounce} " + $"prevWalk={prevOnWalkable} nowWalk={nowOnWalkable} " + $"N=({n0.X:F2},{n0.Y:F2},{n0.Z:F2}) FloorZ={PhysicsGlobals.FloorZ:F2} " + $"V=({v0.X:F2},{v0.Y:F2},{v0.Z:F2}) " + $"dot={Vector3.Dot(v0, n0):F3} " + $"isOnGround={resolveResult.IsOnGround}"); } if (applyBounce) { if (_body.State.HasFlag(PhysicsStateFlags.Inelastic)) { // Full stop on impact. Spell projectiles / missiles. _body.Velocity = Vector3.Zero; } else { var v = _body.Velocity; var n = resolveResult.CollisionNormal; float dotVN = Vector3.Dot(v, n); if (dotVN < 0f) { // Reflect the into-wall component back out. // Player elasticity is 0.05 → 105% of perpendicular // velocity reflects (subtle bounce). float k = -(dotVN * (_body.Elasticity + 1f)); _body.Velocity = v + n * k; if (diagSteep) { var v1 = _body.Velocity; Console.WriteLine( $"[steep-roof] BOUNCE-APPLIED V_after=({v1.X:F2},{v1.Y:F2},{v1.Z:F2}) k={k:F3}"); } } } } } bool justLanded = false; if (resolveResult.IsOnGround) { if (_body.Velocity.Z <= 0f) { // Grounded — snap to resolved position and land. bool wasAirborne = !_body.OnWalkable; _body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; _body.calc_acceleration(); if (_body.Velocity.Z < 0f) _body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f); if (wasAirborne) { // R4-V5 → R5-V5: retail order — minterp first, then // moveto (MovementManager::HitGround 0x00524300, decomp // §2d — now the literal facade relay); re-arms a moveto // suspended by the airborne UseTime contact gate. // LeaveGround has NO moveto side (COMDAT no-op, §2e) — // do not add one. Movement.HitGround(); justLanded = true; } } else { // Moving upward (jump) — stay airborne even though terrain is below. _body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); _body.calc_acceleration(); } } else { // No ground found — airborne. _body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); _body.calc_acceleration(); } // R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's // LeaveGround (0x00528b00) — jump launches (jump() cleared // OnWalkable earlier this frame) AND walk-off-a-ledge both route // here, replacing the old manual jump-block call (and giving // ledge departures the momentum fallback + link strip they never // had). Edge = grounded last frame, airborne now; the landing // branch above already 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; } // Strafe: retail uses speed=1.0 for SideStep (see holtburger // common.rs::locomotion_command_for_state). 0.5 was our earlier guess // and made strafing feel lethargic; the retail feel is full-speed // sidestep matching the walk forward pace. if (input.StrafeRight) { outSidestepCmd = MotionCommand.SideStepRight; outSidestepSpeed = 1.0f; } else if (input.StrafeLeft) { outSidestepCmd = MotionCommand.SideStepLeft; outSidestepSpeed = 1.0f; } // Turn commands from KEYBOARD only (A/D). Mouse turning is applied // directly to Yaw above and doesn't generate a turn command — if it // did, mouse jitter would flip turnCmd between TurnRight/TurnLeft // every frame, causing stateChanged=True on every frame and flooding // the server with MoveToState spam. if (input.TurnRight) { outTurnCmd = MotionCommand.TurnRight; outTurnSpeed = 1.0f; } else if (input.TurnLeft) { outTurnCmd = MotionCommand.TurnLeft; outTurnSpeed = 1.0f; } // ── 7. Detect motion state change ───────────────────────────────────── // 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; _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; } // ── 8. Heartbeat timer (always while in-world, not just while moving) ─ // 2026-05-16 (closes #74) — retail-faithful AP cadence per // CommandInterpreter::ShouldSendPositionEvent at // acclient_2013_pseudo_c.txt:700233-700285. Two-branch: // // Branch 1 — interval NOT yet elapsed (< 1 sec since last // send): send only if cell changed OR contact-plane changed // (mid-walk events that matter — stair / hill / cell cross). // // Branch 2 — interval HAS elapsed (>= 1 sec): send only if // cell OR position frame changed. Truly idle = no send // (retail's `last_sent.frame == player.frame` check at // acclient_2013_pseudo_c.txt:700248-700265). // // SendPositionEvent (line 700327) gates the actual send on // (state & 1) != 0 && (state & 2) != 0 — Contact AND // OnWalkable both set. We mirror that gate so airborne and // wall-contact-without-walkable suppress AP entirely; // MoveToState carries jump/fall snapshots while airborne. // // Effective rates: // Truly idle (grounded, no movement) : 0 Hz // Smooth movement (no cell/plane changes) : ~1 Hz (interval) // Cell crossings + stair/hill steps : per-event // Airborne : 0 Hz // // Bootstrap: when NotePositionSent has never been called // (_lastSentInitialized=false), every state-changed branch is // forced true so the first AP gets a chance to fire. bool intervalElapsed = !_lastSentInitialized || (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval; bool cellChanged = !_lastSentInitialized || _lastSentCellId != CellId; bool planeChanged = !_lastSentInitialized || !ApproxPlaneEqual(_lastSentContactPlane, _body.ContactPlane); bool frameChanged = !_lastSentInitialized || !ApproxPositionEqual(_lastSentPos, _body.Position); bool sendThisFrame = intervalElapsed ? (cellChanged || frameChanged) : (cellChanged || planeChanged); // Grounded-on-walkable gate per acclient_2013_pseudo_c.txt:700327 // (`(state & 1) != 0 && (state & 2) != 0`). Both flags must be // set simultaneously, NOT a bitwise-OR mask test. bool groundedOnWalkable = _body.InContact && _body.OnWalkable; HeartbeatDue = groundedOnWalkable && sendThisFrame; // 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). bool anyDirectional = input.Forward || input.Backward || input.StrafeLeft || input.StrafeRight; // 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, // Run hold-key applies to ANY active directional axis, not just // forward (per holtburger's build_motion_state_raw_motion_state: // "uses the same value for every active per-axis hold key"). The // pre-fix condition `input.Run && input.Forward` made strafe-run // and backward-run incorrectly broadcast as walk to observers, // who then animated walk + dead-reckoned at walk speed while the // server position moved at run speed — visible as observer lag. IsRunning: input.Run && anyDirectional, JustLanded: justLanded, JumpExtent: outJumpExtent, JumpVelocity: outJumpVelocity); } /// /// 2026-05-16. Position-equality test for diff-driven AP cadence. /// Retail uses Frame::is_equal at acclient_2013_pseudo_c.txt:700263 /// which is essentially exact float comparison after a memcmp of /// the frame struct. For floating-point safety we use a tiny epsilon /// — sub-millimeter — that's well below any movement we'd want to /// suppress sending for. /// private static bool ApproxPositionEqual( System.Numerics.Vector3 a, System.Numerics.Vector3 b) { const float Epsilon = 0.001f; // 1 mm return MathF.Abs(a.X - b.X) < Epsilon && MathF.Abs(a.Y - b.Y) < Epsilon && MathF.Abs(a.Z - b.Z) < Epsilon; } /// /// 2026-05-16. Contact-plane-equality test for retail's /// sub-interval AP gate. Retail's SendPositionEvent stores /// last_sent_contact_plane and ShouldSendPositionEvent re-sends /// during the sub-interval window if the plane has changed (e.g., /// player stepped onto stairs / a hill — same cell but different /// contact normal). Tiny epsilon on normal + distance covers /// floating-point noise from the physics integration. /// private static bool ApproxPlaneEqual( System.Numerics.Plane a, System.Numerics.Plane b) { const float NormalEpsilon = 1e-4f; const float DistanceEpsilon = 0.001f; return MathF.Abs(a.Normal.X - b.Normal.X) < NormalEpsilon && MathF.Abs(a.Normal.Y - b.Normal.Y) < NormalEpsilon && MathF.Abs(a.Normal.Z - b.Normal.Z) < NormalEpsilon && MathF.Abs(a.D - b.D) < DistanceEpsilon; } }