Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates. Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean. Co-authored-by: OpenAI Codex <codex@openai.com>
2007 lines
94 KiB
C#
2007 lines
94 KiB
C#
using System;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
|
||
namespace AcDream.App.Input;
|
||
|
||
/// <summary>
|
||
/// Input state for a single frame of player movement.
|
||
/// </summary>
|
||
public readonly record struct MovementInput(
|
||
bool Forward = false,
|
||
bool Backward = false,
|
||
bool StrafeLeft = false,
|
||
bool StrafeRight = false,
|
||
bool TurnLeft = false,
|
||
bool TurnRight = false,
|
||
bool Run = false,
|
||
float MouseDeltaX = 0f,
|
||
bool Jump = false);
|
||
|
||
/// <summary>
|
||
/// Read-only presentation snapshot of retail's pending jump build. The movement
|
||
/// controller remains the sole owner of charge timing; retained UI only projects
|
||
/// this state into <c>gmPowerbarUI</c>.
|
||
/// </summary>
|
||
public readonly record struct JumpChargeSnapshot(bool IsCharging, float Power);
|
||
|
||
/// <summary>
|
||
/// Result of a single frame's movement update.
|
||
///
|
||
/// <para>
|
||
/// <b>Wire vs. local animation command.</b> ACE's <c>MovementData</c>
|
||
/// (<c>ACE.Server/Network/Motion/MovementData.cs</c>) only computes
|
||
/// <c>interpState.ForwardSpeed</c> for raw <c>WalkForward</c>/
|
||
/// <c>WalkBackwards</c> — on every other command the <c>else</c> branch
|
||
/// passes through command without setting speed, leaving observers with
|
||
/// <c>speed=0</c>. The client therefore has to send <c>WalkForward</c>
|
||
/// (with <c>HoldKey.Run</c> for running) and let ACE auto-upgrade to
|
||
/// <c>RunForward</c> for broadcast. But the LOCAL view wants the run
|
||
/// cycle immediately, so we carry a separate
|
||
/// <see cref="LocalAnimationCommand"/> for the player's own renderer.
|
||
/// </para>
|
||
/// <para>
|
||
/// <see cref="IsRunning"/> — true when the player is holding Shift to run.
|
||
/// Used by the GameWindow when building the outbound MoveToState's
|
||
/// CURRENT_HOLD_KEY (2=Run) vs (1=None).
|
||
/// </para>
|
||
/// </summary>
|
||
public readonly record struct MovementResult(
|
||
Vector3 Position,
|
||
Vector3 RenderPosition,
|
||
uint CellId,
|
||
bool IsOnGround,
|
||
bool MotionStateChanged,
|
||
uint? ForwardCommand, // wire-side command (WalkForward / WalkBackward / …)
|
||
uint? SidestepCommand,
|
||
uint? TurnCommand,
|
||
float? ForwardSpeed,
|
||
float? SidestepSpeed,
|
||
float? TurnSpeed,
|
||
bool IsRunning = false,
|
||
// K-fix5 (2026-04-26): cycle-pace multiplier for the LOCAL animation
|
||
// sequencer. Decoupled from ForwardSpeed so the wire can keep sending
|
||
// 1.0 for WalkBackward (ACE-compatible) while the animation plays at
|
||
// runRate × so the cycle visually matches the run-speed velocity.
|
||
// Forward+Run = runRate (same as ForwardSpeed); Backward+Run, Strafe+Run
|
||
// = runRate (where ForwardSpeed is 1.0 / null); everything else = 1.0.
|
||
bool JustLanded = false, // true on the single frame we transitioned airborne → grounded
|
||
float? JumpExtent = null, // non-null when a jump was triggered this frame
|
||
Vector3? JumpVelocity = null, // BODY-LOCAL launch velocity (forward/right/up relative to facing) — see PlayerMovementController jump path for the inverse-yaw conversion. Server rotates body→world on broadcast.
|
||
// Retail updates mouse-origin turn motions continuously but sends the
|
||
// resulting movement state only on ToggleMouseLook start/stop and the
|
||
// CameraSet half-second cadence. Keep packet ownership separate from the
|
||
// local motion-state change so mouse sampling cannot flood the server.
|
||
bool ShouldSendMovementEvent = false,
|
||
// CameraSet::Rotate always calls MovePlayer with holdRun=true for the turn
|
||
// axis, independently of the player's ordinary walk/run toggle.
|
||
bool TurnUsesRunHold = false,
|
||
// CameraInstantMouseLook remaps keyboard turn to sidestep with the same
|
||
// per-axis Run hold, independently of the global walk/run toggle.
|
||
bool SidestepUsesRunHold = false,
|
||
// The host acknowledges this clock/queue only after the MoveToState has
|
||
// actually been put on the wire.
|
||
bool IsMouseLookMovementEvent = false,
|
||
// 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);
|
||
|
||
/// <summary>
|
||
/// Portal-space state for the player movement controller.
|
||
/// PortalSpace freezes all movement input while the server is moving the
|
||
/// player through a portal — resumed once the destination UpdatePosition
|
||
/// arrives and the player is snapped to the new location.
|
||
/// While in PortalSpace, Update returns immediately with a zero-movement
|
||
/// result so no WASD input or physics is processed.
|
||
/// </summary>
|
||
public enum PlayerState { InWorld, PortalSpace }
|
||
|
||
/// <summary>
|
||
/// Per-frame player movement controller. Reads input, drives the
|
||
/// ported PhysicsBody + MotionInterpreter, tracks motion state for
|
||
/// animation + server messages.
|
||
///
|
||
/// Architecture:
|
||
/// - PhysicsBody owns integration: gravity, friction, sub-stepping,
|
||
/// velocity clamping — all from the decompiled retail client.
|
||
/// - MotionInterpreter owns the motion state machine: walk/run/jump
|
||
/// validation, state tracking, speed constants from the retail dat.
|
||
/// - PhysicsEngine.Resolve is still used each frame to snap the player
|
||
/// to terrain/cell floor Z and detect ground contact.
|
||
/// </summary>
|
||
public sealed class PlayerMovementController
|
||
{
|
||
private readonly PhysicsEngine _physics;
|
||
private readonly PhysicsBody _body;
|
||
private readonly MotionInterpreter _motion;
|
||
private readonly PlayerWeenie _weenie;
|
||
|
||
/// <summary>
|
||
/// Maximum Z increase per movement step before the move is rejected.
|
||
/// Retail's <c>step_up_height</c> for human characters is ~0.4 m (hip-
|
||
/// level). Setting this too high lets the player teleport up small
|
||
/// buildings via the step-up scan finding any walkable polygon within
|
||
/// reach (Bug 3 in L.2.3 testing — walking into a steep slope mounted
|
||
/// the building's flat top instead of sliding off the slope).
|
||
/// Authoritative source is the player's <c>Setup.StepUpHeight</c> set
|
||
/// in GameWindow.cs at world-entry time.
|
||
/// </summary>
|
||
public float StepUpHeight { get; set; } = 0.4f;
|
||
|
||
/// <summary>
|
||
/// L.2.3a (2026-04-29): how far below the foot the step-down probe
|
||
/// reaches when transitioning between surfaces. Retail's
|
||
/// <c>step_down_height</c> for human characters is ~0.4 m. With the
|
||
/// previous 4 cm hardcoded value, walking off the top of a stair onto
|
||
/// the ground 25 cm below produced a one-frame contact-plane gap — the
|
||
/// animation system briefly flickered to falling.
|
||
/// </summary>
|
||
public float StepDownHeight { get; set; } = 0.4f;
|
||
|
||
/// <summary>
|
||
/// Retail <c>CPhysicsObj::m_scale</c>. Grounded CSequence root
|
||
/// displacement is multiplied by this value before PositionManager
|
||
/// composition (<c>UpdatePositionInternal @ 0x00512C30</c>).
|
||
/// </summary>
|
||
public float ObjectScale { get; set; } = 1f;
|
||
|
||
/// <summary>
|
||
/// Current portal-space state. Set to PortalSpace when the server sends
|
||
/// PlayerTeleport (0xF751); set back to InWorld once the destination
|
||
/// UpdatePosition arrives and the player is snapped to the new cell.
|
||
/// While in PortalSpace, Update returns immediately with a zero-movement
|
||
/// result so no WASD input or physics is processed.
|
||
/// </summary>
|
||
public PlayerState State { get; set; } = PlayerState.InWorld;
|
||
|
||
/// <summary>
|
||
/// Horizontal projection of the authoritative body quaternion. Assigning
|
||
/// a yaw is the explicit retail <c>Frame::set_heading</c> seam and therefore
|
||
/// intentionally replaces pitch/roll; ordinary object ticks never rebuild
|
||
/// the quaternion from this projection.
|
||
/// </summary>
|
||
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;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
internal bool AdvancedObjectQuantumLastTick { get; private set; }
|
||
|
||
/// <summary>
|
||
/// Returns retail's canonical outbound <c>Position</c>: the physics body's
|
||
/// carried cell id plus its landblock-local frame origin. Retail
|
||
/// <c>CommandInterpreter::SendMovementEvent @ 0x006B4680</c> and
|
||
/// <c>SendPositionEvent @ 0x006B4770</c> serialize
|
||
/// <c>CPhysicsObj::m_position</c> directly; render/streaming origins are not
|
||
/// part of the protocol frame.
|
||
/// </summary>
|
||
internal bool TryGetOutboundPosition(
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Local-player entity id used to skip self-collision in the
|
||
/// airborne sweep. GameWindow updates this whenever the local
|
||
/// `+Acdream` entity (re)spawns. Default 0 = no filter (matches
|
||
/// retail's CObjCell::find_obj_collisions self-skip when the
|
||
/// caller's OBJECTINFO::object pointer is null). Without this the
|
||
/// sweep collides with its own ShadowEntry registered at
|
||
/// GameWindow.cs:2545 — see #42.
|
||
/// </summary>
|
||
public uint LocalEntityId { get; set; }
|
||
|
||
/// <summary>
|
||
/// Applies the canonical server PhysicsState to the local body. Retail's
|
||
/// <c>CPhysicsObj::SetState</c> replaces these persistent bits before its
|
||
/// next acceleration/integration decision.
|
||
/// </summary>
|
||
public void ApplyPhysicsState(PhysicsStateFlags state)
|
||
{
|
||
_body.State = state;
|
||
_body.calc_acceleration();
|
||
}
|
||
|
||
public bool IsAirborne => !_body.OnWalkable;
|
||
|
||
/// <summary>
|
||
/// Current vertical (Z-axis) velocity of the physics body.
|
||
/// Positive = rising, negative = falling. Exposed for tests and HUD.
|
||
/// </summary>
|
||
public float VerticalVelocity => _body.Velocity.Z;
|
||
|
||
/// <summary>Full 3D world-space velocity of the physics body. Exposed for diagnostic logging.</summary>
|
||
public Vector3 BodyVelocity => _body.Velocity;
|
||
|
||
/// <summary>
|
||
/// 2026-05-16 — current contact plane (normal + distance) for the
|
||
/// physics body. Exposed so the network outbound layer can stamp
|
||
/// it into <see cref="NotePositionSent"/> for retail's diff-driven
|
||
/// AP cadence: SendPositionEvent re-sends if cell OR contact-plane
|
||
/// changed since last_sent, per
|
||
/// <c>acclient_2013_pseudo_c.txt:700233 ShouldSendPositionEvent</c>.
|
||
/// </summary>
|
||
public System.Numerics.Plane ContactPlane => _body.ContactPlane;
|
||
|
||
// Jump charge state.
|
||
private bool _jumpCharging;
|
||
private float _jumpExtent;
|
||
|
||
/// <summary>
|
||
/// Current retail jump-powerbar state. Power is always zero when no jump is
|
||
/// pending and otherwise lies in [0,1].
|
||
/// </summary>
|
||
public JumpChargeSnapshot JumpCharge
|
||
=> new(_jumpCharging, _jumpCharging ? _jumpExtent : 0f);
|
||
// Matching v11.4186 x86 resolves GetPowerBarLevel's collapsed x87
|
||
// operands: ATTACK_POWERUP_TIME=1.0 s, DUAL_WIELD_POWERUP_TIME=0.8 s.
|
||
// Jump uses the same shared powerbar function, so its normal fill rate is
|
||
// 1 extent/s and DualWieldCombat is 1/0.8 = 1.25 extent/s.
|
||
private const float JumpChargeRate =
|
||
1f / (float)AcDream.Core.Combat.CombatInputPlanner.AttackPowerUpSeconds;
|
||
private const float DualWieldJumpChargeRate =
|
||
1f / (float)AcDream.Core.Combat.CombatInputPlanner.DualWieldPowerUpSeconds;
|
||
|
||
// Airborne → grounded transition detection. Flipped on every frame where
|
||
// the body transitions from airborne to on-walkable; used by the GameWindow
|
||
// to drive the landing animation cycle.
|
||
private bool _wasAirborneLastFrame;
|
||
|
||
// Previous frame's motion commands for change detection (wire cadence).
|
||
private uint? _prevForwardCmd;
|
||
private uint? _prevSidestepCmd;
|
||
private uint? _prevTurnCmd;
|
||
private float? _prevForwardSpeed;
|
||
private bool _prevRunHold;
|
||
|
||
// R3-W6: previous frame's HELD-KEY state — the edge detector feeding
|
||
// retail's DoMotion/StopMotion/set_hold_run calls (retail's
|
||
// CommandInterpreter altitude: motion dispatch happens on key EDGES,
|
||
// never level-triggered per-frame).
|
||
private bool _prevForwardHeld;
|
||
private bool _prevBackwardHeld;
|
||
private bool _prevStrafeLeftHeld;
|
||
private bool _prevStrafeRightHeld;
|
||
private bool _prevTurnLeftHeld;
|
||
private bool _prevTurnRightHeld;
|
||
private bool _prevRunHeld;
|
||
private bool _hasInputSnapshot;
|
||
|
||
// Retail CameraSet::ToggleMouseLook / Rotate (0x00457490 / 0x00458310).
|
||
// Mouse look owns a transient turn command in the SAME MotionInterpreter
|
||
// as keyboard and server turns; it never writes Yaw directly. The latest
|
||
// filtered sample replaces the preceding sample, matching CameraSet's
|
||
// per-mouse-message MovePlayer call.
|
||
private bool _mouseLookActive;
|
||
private bool _mouseTurnSamplePending;
|
||
private float _mouseTurnAdjustment;
|
||
private uint? _activeInputTurnCommand;
|
||
private float _activeInputTurnSpeed;
|
||
private bool _activeInputTurnFromMouse;
|
||
private uint? _activeInputSidestepCommand;
|
||
private bool _activeInputSidestepUsesRunHold;
|
||
private bool _mouseMovementEventCandidate;
|
||
private bool _mouseMovementEventPending;
|
||
private float _lastMouseMovementEventTime;
|
||
private bool _controlledByServer = true;
|
||
|
||
/// <summary>
|
||
/// Retail's mouse-look movement report interval from
|
||
/// <c>CameraSet::Rotate</c> and <c>MouseLookHandler</c>.
|
||
/// </summary>
|
||
public const float MouseMovementEventInterval = 0.5f;
|
||
|
||
private const float MouseTurnDeadZone = 0.02f;
|
||
private const float MouseTurnSpeedScale = 2.0f;
|
||
private const float MouseTurnMaximumSpeed = 1.5f;
|
||
|
||
// Position-event comparison interval. Retail does not send an idle packet
|
||
// every second: after the interval elapses it compares the complete frame
|
||
// and sends only when the cell, origin, or orientation changed.
|
||
/// <summary>
|
||
/// 2026-05-16 — retail-faithful AP cadence. Matches retail's
|
||
/// CommandInterpreter::ShouldSendPositionEvent (acclient_2013_pseudo_c.txt
|
||
/// at address 0x006b45e0). Inside the interval it reacts to cell/contact
|
||
/// changes; after the interval it compares cell plus the complete frame.
|
||
/// `time_between_position_events` constant at 0x006b3efb = 1.0 sec.
|
||
///
|
||
/// Old model: a 1 Hz idle / 10 Hz active flat accumulator. That
|
||
/// missed retail's per-frame-while-moving behaviour and forced the
|
||
/// four B.6 workarounds (arrival margin, re-send on arrival, AP
|
||
/// flush, retry flag) to compensate for the lag in ACE's server-side
|
||
/// WithinUseRadius poll. Replaced by diff-driven cadence below.
|
||
/// </summary>
|
||
public const float HeartbeatInterval = 1.0f; // retail 0x006b3efb
|
||
|
||
private AcDream.Core.Physics.Position _lastSentPosition;
|
||
private System.Numerics.Plane _lastSentContactPlane;
|
||
private float _lastSentTime;
|
||
private bool _lastSentInitialized;
|
||
private float _simTimeSeconds;
|
||
|
||
/// <summary>Sim-time accumulator (advanced by dt at the top of Update).
|
||
/// Exposed for the network outbound layer to stamp NotePositionSent.</summary>
|
||
public float SimTimeSeconds => _simTimeSeconds;
|
||
|
||
// 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<float, AcDream.Core.Physics.Motion.MotionDeltaFrame>?
|
||
_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.
|
||
|
||
/// <summary>
|
||
/// R5-V5: retail <c>CPhysicsObj::movement_manager</c> (acclient.h
|
||
/// <c>/* 3463 */</c>) — the ONE owner of this controller's
|
||
/// <see cref="Motion"/> + <see cref="MoveTo"/> pair. Constructed in the
|
||
/// ctor around the interp; the MoveToManager side arrives via
|
||
/// <c>MoveToFactory</c> + <c>MakeMoveToManager()</c> in
|
||
/// <c>GameWindow.EnterPlayerModeNow</c> (the same facade shape
|
||
/// <c>EnsureRemoteMotionBindings</c> gives remotes). <see cref="Update"/>
|
||
/// ticks <c>UseTime()</c> (0x005242f0) at the slot the deleted
|
||
/// <c>DriveServerAutoWalk</c> occupied and relays <c>HitGround()</c>
|
||
/// (0x00524300, minterp first then moveto).
|
||
/// </summary>
|
||
public AcDream.Core.Physics.Motion.MovementManager Movement { get; }
|
||
|
||
/// <summary>
|
||
/// R4-V5: the local player's verbatim retail <c>MoveToManager</c>
|
||
/// (decomp 0x00529010-0x0052a987), constructed + seam-bound by
|
||
/// <c>GameWindow.EnterPlayerModeNow</c> against this controller's
|
||
/// <see cref="Motion"/>/body/Yaw (the same wiring shape
|
||
/// <c>EnsureRemoteMotionBindings</c> uses for remotes). GameWindow
|
||
/// routes inbound mt 6-9 movement events through
|
||
/// <see cref="AcDream.Core.Physics.Motion.MovementManager.PerformMovement"/>.
|
||
/// User input cancels a moveto through the retail chain: key edge →
|
||
/// DoMotion (ctor-default params, CancelMoveTo bit set) →
|
||
/// <see cref="MotionInterpreter.InterruptCurrentMovement"/> →
|
||
/// <c>CancelMoveTo(ActionCancelled)</c> (register TS-36 retired).
|
||
/// R5-V5: a view of <see cref="Movement"/>'s moveto child; the setter is
|
||
/// sugar over the facade's factory path (kept for the
|
||
/// PlayerMoveToCutoverTests rig and any pre-facade bind shape).
|
||
/// </summary>
|
||
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo
|
||
{
|
||
get => Movement.MoveTo;
|
||
set
|
||
{
|
||
var mtm = value ?? throw new ArgumentNullException(nameof(value));
|
||
Movement.MoveToFactory = () => mtm;
|
||
Movement.MakeMoveToManager();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// R5-V3 (#171): the player's <c>PositionManager</c> facade (retail
|
||
/// <c>CPhysicsObj::position_manager</c> — owned by the player's
|
||
/// <c>EntityPhysicsHost</c>, handed here by <c>EnterPlayerModeNow</c>).
|
||
/// <see cref="Update"/> drives it at the two retail per-tick points:
|
||
/// <c>AdjustOffset</c> inside the physics-tick block (retail
|
||
/// <c>UpdatePositionInternal</c> @0x00512d0e, BEFORE
|
||
/// <c>UpdatePhysicsInternal</c> so the sticky steer is part of the swept
|
||
/// motion) and <c>UseTime</c> after the completed-motions sweep (retail
|
||
/// <c>UpdateObjectInternal</c> tail @0x005159b3 — the sticky 1 s lease
|
||
/// watchdog). <see cref="SetPosition(Vector3, uint, Vector3)"/> tears any
|
||
/// stick down (retail <c>teleport_hook</c> @0x00514eee).
|
||
/// </summary>
|
||
public AcDream.Core.Physics.Motion.PositionManager? PositionManager { get; set; }
|
||
|
||
public PlayerMovementController(
|
||
PhysicsEngine physics,
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Host half of retail <c>MovementManager::PerformMovement</c>'s
|
||
/// unconditional <c>CPhysicsObj::set_active(1)</c> head. Static objects
|
||
/// reject activation; an inactive ordinary object rebases its canonical
|
||
/// object clock before setting the body's transient bit.
|
||
/// </summary>
|
||
private void ActivateFromMovement()
|
||
{
|
||
if ((_body.State & PhysicsStateFlags.Static) != 0)
|
||
return;
|
||
|
||
_objectClock.Activate();
|
||
_body.TransientState |= TransientStateFlags.Active;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Applies retail's parent/cell-less/Frozen early gate: clear Active and
|
||
/// advance only wall-clock presentation time. The next eligible frame
|
||
/// reactivates through <c>set_active(1)</c> and rebases the object clock.
|
||
/// </summary>
|
||
internal void SuspendObjectUpdate(float elapsedSeconds)
|
||
{
|
||
AdvancedObjectQuantumLastTick = false;
|
||
if (float.IsFinite(elapsedSeconds) && elapsedSeconds > 0f)
|
||
_simTimeSeconds += elapsedSeconds;
|
||
_objectClock.Deactivate();
|
||
_body.TransientState &= ~TransientStateFlags.Active;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Begins retail instant mouse-look. <c>CameraSet::ToggleMouseLook</c>
|
||
/// (0x00457490) sends a movement event on both transitions, even before a
|
||
/// horizontal turn sample arrives.
|
||
/// </summary>
|
||
public bool BeginMouseLook(MovementInput input)
|
||
{
|
||
if (_mouseLookActive || State != PlayerState.InWorld)
|
||
return false;
|
||
|
||
_mouseLookActive = true;
|
||
// ToggleMouseLook stores the new mode before MovePlayer enters
|
||
// TakeControlFromServer, so its ApplyCurrentMovement observes the
|
||
// remapped turn→sidestep channels immediately.
|
||
TakeControlFromServer(input);
|
||
_mouseTurnSamplePending = false;
|
||
_mouseTurnAdjustment = 0f;
|
||
ApplyMouseLookToggleMovement(input, entering: true);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Supplies the latest signed, filtered horizontal mouse adjustment.
|
||
/// Negative means TurnRight in acdream's yaw convention; positive means
|
||
/// TurnLeft. Retail <c>CameraSet::Rotate</c> doubles the magnitude, applies
|
||
/// a 0.02 dead zone, and caps the raw turn speed at 1.5.
|
||
/// </summary>
|
||
public void SubmitMouseTurnAdjustment(
|
||
float signedAdjustment,
|
||
MovementInput input)
|
||
{
|
||
if (!_mouseLookActive || !float.IsFinite(signedAdjustment))
|
||
return;
|
||
|
||
TakeControlFromServer(input);
|
||
_mouseTurnAdjustment = signedAdjustment;
|
||
_mouseTurnSamplePending = true;
|
||
_mouseMovementEventCandidate = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail's delayed zero-input <c>MouseLookHandler</c> calls
|
||
/// <c>CommandInterpreter::StopDrift @ 0x006B4510</c> before filtering the
|
||
/// zero sample. It stops both turn directions and participates in the
|
||
/// same half-second movement-report cadence.
|
||
/// </summary>
|
||
public void StopMouseDrift(MovementInput input)
|
||
{
|
||
if (!_mouseLookActive)
|
||
return;
|
||
|
||
TakeControlFromServer(input);
|
||
|
||
var p = MouseAxisParameters(_activeInputTurnSpeed == 0f
|
||
? 1f
|
||
: _activeInputTurnSpeed);
|
||
StopMotionAtPhysicsObjectBoundary(MotionCommand.TurnRight, p);
|
||
StopMotionAtPhysicsObjectBoundary(MotionCommand.TurnLeft, p);
|
||
_activeInputTurnCommand = null;
|
||
_activeInputTurnSpeed = 0f;
|
||
_activeInputTurnFromMouse = false;
|
||
_mouseTurnSamplePending = false;
|
||
_mouseTurnAdjustment = 0f;
|
||
_mouseMovementEventCandidate = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ends retail instant mouse-look. The next update restores any held
|
||
/// keyboard turn (or stops the mouse-origin turn) and publishes the final
|
||
/// heading through MoveToState.
|
||
/// </summary>
|
||
public bool EndMouseLook(MovementInput input)
|
||
{
|
||
if (!_mouseLookActive && !_activeInputTurnFromMouse)
|
||
return false;
|
||
|
||
_mouseLookActive = false;
|
||
// Retail clears the mode before TakeControlFromServer/ApplyCurrentMovement
|
||
// so a held sidestep-remapped turn is restored directly as turn.
|
||
TakeControlFromServer(input);
|
||
_mouseTurnSamplePending = false;
|
||
_mouseTurnAdjustment = 0f;
|
||
|
||
ApplyMouseLookToggleMovement(input, entering: false);
|
||
return true;
|
||
}
|
||
|
||
private void ApplyMouseLookToggleMovement(MovementInput input, bool entering)
|
||
{
|
||
// CameraSet::ToggleMouseLook @ 0x00457490 routes the pseudo-command
|
||
// CameraInstantMouseLook through CommandInterpreter::MovePlayer
|
||
// @ 0x006B3F40. While entering, held keyboard turns move to the
|
||
// sidestep channel. On exit, MovePlayer reverses that mapping and
|
||
// ApplyCurrentMovement replays every currently held channel before the
|
||
// synchronous movement packet is captured.
|
||
(uint? sidestep, bool useRunHold) = DesiredInputSidestep(input, entering);
|
||
ApplyInputSidestep(sidestep, useRunHold, reapply: !entering);
|
||
|
||
uint? turn = entering
|
||
? null
|
||
: input.TurnRight
|
||
? MotionCommand.TurnRight
|
||
: input.TurnLeft
|
||
? MotionCommand.TurnLeft
|
||
: null;
|
||
ApplyInputTurn(turn, speed: 1f, fromMouse: false, reapply: !entering);
|
||
|
||
// TakeControlFromServer::ApplyCurrentMovement and both sides of
|
||
// ToggleMouseLook replay the complete current input, not merely the
|
||
// turn/sidestep channels affected by CameraInstantMouseLook.
|
||
bool runHeld = _motion.RawState.CurrentHoldKey == HoldKey.Run;
|
||
if (runHeld != input.Run)
|
||
_motion.set_hold_run(input.Run, interrupt: true);
|
||
|
||
uint? desiredForward = input.Forward
|
||
? MotionCommand.WalkForward
|
||
: input.Backward
|
||
? MotionCommand.WalkBackward
|
||
: null;
|
||
|
||
uint rawForward = _motion.RawState.ForwardCommand;
|
||
uint? activeForward = rawForward == RawMotionState.Default.ForwardCommand
|
||
? null
|
||
: rawForward;
|
||
if (activeForward is { } active && active != desiredForward)
|
||
{
|
||
StopMotionAtPhysicsObjectBoundary(
|
||
active,
|
||
new AcDream.Core.Physics.Motion.MovementParameters());
|
||
}
|
||
|
||
if (desiredForward is { } command
|
||
&& (activeForward != desiredForward || !entering))
|
||
DoMotionAtPhysicsObjectBoundary(
|
||
command,
|
||
new AcDream.Core.Physics.Motion.MovementParameters());
|
||
|
||
_hasInputSnapshot = true;
|
||
_prevForwardHeld = input.Forward;
|
||
_prevBackwardHeld = input.Backward;
|
||
_prevStrafeLeftHeld = input.StrafeLeft;
|
||
_prevStrafeRightHeld = input.StrafeRight;
|
||
_prevTurnLeftHeld = input.TurnLeft;
|
||
_prevTurnRightHeld = input.TurnRight;
|
||
_prevRunHeld = input.Run;
|
||
|
||
_prevRunHold = input.Run;
|
||
_body.LastMoveWasAutonomous = true;
|
||
}
|
||
|
||
private static (uint? Command, bool UseRunHold) DesiredInputSidestep(
|
||
MovementInput input,
|
||
bool mouseLookActive)
|
||
{
|
||
if (input.StrafeRight)
|
||
return (MotionCommand.SideStepRight, false);
|
||
if (input.StrafeLeft)
|
||
return (MotionCommand.SideStepLeft, false);
|
||
if (mouseLookActive && input.TurnRight)
|
||
return (MotionCommand.SideStepRight, true);
|
||
if (mouseLookActive && input.TurnLeft)
|
||
return (MotionCommand.SideStepLeft, true);
|
||
return (null, false);
|
||
}
|
||
|
||
private bool ApplyInputSidestep(
|
||
uint? desired,
|
||
bool useRunHold,
|
||
bool reapply = false)
|
||
{
|
||
bool changed = desired != _activeInputSidestepCommand;
|
||
if (_activeInputSidestepCommand is { } active
|
||
&& (changed || reapply))
|
||
{
|
||
StopMotionAtPhysicsObjectBoundary(
|
||
active,
|
||
_activeInputSidestepUsesRunHold
|
||
? MouseAxisParameters(1f)
|
||
: new());
|
||
}
|
||
|
||
if (desired is { } command && (changed || reapply))
|
||
{
|
||
DoMotionAtPhysicsObjectBoundary(
|
||
command,
|
||
useRunHold ? MouseAxisParameters(1f) : new());
|
||
}
|
||
|
||
_activeInputSidestepCommand = desired;
|
||
_activeInputSidestepUsesRunHold = desired.HasValue && useRunHold;
|
||
return changed || (reapply && desired.HasValue);
|
||
}
|
||
|
||
private bool ApplyInputTurn(
|
||
uint? desired,
|
||
float speed,
|
||
bool fromMouse,
|
||
bool reapply = false)
|
||
{
|
||
bool commandChanged = desired != _activeInputTurnCommand;
|
||
bool bothPresent = desired.HasValue && _activeInputTurnCommand.HasValue;
|
||
bool speedChanged = bothPresent
|
||
&& MathF.Abs(speed - _activeInputTurnSpeed) >= 0.0001f;
|
||
bool ownerChanged = bothPresent && fromMouse != _activeInputTurnFromMouse;
|
||
bool apply = commandChanged || speedChanged || ownerChanged
|
||
|| (reapply && desired.HasValue);
|
||
|
||
if (_activeInputTurnCommand is { } active && apply)
|
||
{
|
||
StopMotionAtPhysicsObjectBoundary(
|
||
active,
|
||
_activeInputTurnFromMouse
|
||
? MouseAxisParameters(_activeInputTurnSpeed)
|
||
: new());
|
||
}
|
||
|
||
if (desired is { } command && apply)
|
||
{
|
||
DoMotionAtPhysicsObjectBoundary(
|
||
command,
|
||
fromMouse ? MouseAxisParameters(speed) : new());
|
||
}
|
||
|
||
_activeInputTurnCommand = desired;
|
||
_activeInputTurnSpeed = desired.HasValue ? speed : 0f;
|
||
_activeInputTurnFromMouse = desired.HasValue && fromMouse;
|
||
return apply;
|
||
}
|
||
|
||
private static AcDream.Core.Physics.Motion.MovementParameters MouseAxisParameters(
|
||
float speed) => new()
|
||
{
|
||
Speed = speed,
|
||
SetHoldKey = false,
|
||
HoldKeyToApply = HoldKey.Run,
|
||
};
|
||
|
||
/// <summary>
|
||
/// Retail <c>CommandInterpreter::TakeControlFromServer</c>
|
||
/// (0x006B32D0). A genuine user movement first terminates the server-owned
|
||
/// movement state, marks subsequent movement autonomous, and lets the next
|
||
/// input update re-apply every key that is still physically held.
|
||
/// </summary>
|
||
private void TakeControlFromServer(MovementInput? currentInput = null)
|
||
{
|
||
if (!_controlledByServer)
|
||
return;
|
||
|
||
_controlledByServer = false;
|
||
_body.LastMoveWasAutonomous = true;
|
||
StopCompletelyAtPhysicsObjectBoundary();
|
||
_activeInputTurnCommand = null;
|
||
_activeInputTurnSpeed = 0f;
|
||
_activeInputTurnFromMouse = false;
|
||
_activeInputSidestepCommand = null;
|
||
_activeInputSidestepUsesRunHold = false;
|
||
|
||
// Retail follows StopCompletely with ApplyCurrentMovement. Our key
|
||
// snapshot is supplied to Update, so clearing the edge history makes
|
||
// that same held input re-enter through the normal DoMotion boundary.
|
||
_prevForwardHeld = false;
|
||
_prevBackwardHeld = false;
|
||
_prevStrafeLeftHeld = false;
|
||
_prevStrafeRightHeld = false;
|
||
_prevTurnLeftHeld = false;
|
||
_prevTurnRightHeld = false;
|
||
_prevRunHeld = _motion.RawState.CurrentHoldKey == HoldKey.Run;
|
||
|
||
// MouseLookHandler runs before the normal per-frame input edge pass.
|
||
// When it is the action that takes control, replay the device levels
|
||
// synchronously just as retail ApplyCurrentMovement does; otherwise a
|
||
// StopCompletely would erase held movement while the edge history is
|
||
// simultaneously advanced past it.
|
||
if (currentInput is { } input)
|
||
ApplyMouseLookToggleMovement(input, entering: _mouseLookActive);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail <c>ClientCombatSystem::StartAttackRequest</c> (0x0056C040)
|
||
/// invokes <c>CommandInterpreter::MaybeStopCompletely</c> before building
|
||
/// the attack. This removes a mouse-origin drift immediately and schedules
|
||
/// the final authoritative heading ahead of the eventual attack send.
|
||
/// </summary>
|
||
public bool PrepareForAttackRequest()
|
||
{
|
||
// CommandInterpreter::MaybeStopCompletely @ 0x006B3B90 is a no-op
|
||
// while an authoritative server movement owns the player.
|
||
if (_controlledByServer)
|
||
return false;
|
||
|
||
StopCompletelyAtPhysicsObjectBoundary();
|
||
_activeInputTurnCommand = null;
|
||
_activeInputTurnSpeed = 0f;
|
||
_activeInputTurnFromMouse = false;
|
||
_activeInputSidestepCommand = null;
|
||
_activeInputSidestepUsesRunHold = false;
|
||
_mouseTurnSamplePending = false;
|
||
_mouseTurnAdjustment = 0f;
|
||
_body.LastMoveWasAutonomous = true;
|
||
return true;
|
||
}
|
||
|
||
public void SetCharacterSkills(int runSkill, int jumpSkill)
|
||
{
|
||
_weenie.SetSkills(runSkill, jumpSkill);
|
||
}
|
||
|
||
/// <summary>
|
||
/// R3-W2 (r3-port-plan.md §4): the player's <see cref="MotionInterpreter"/>
|
||
/// — GameWindow binds the player sequencer's MotionDone seam to it so the
|
||
/// pending_motions queue pops in step with animation completion, same
|
||
/// path remotes use. R3-W6 widens this into the full local-player
|
||
/// unification.
|
||
/// </summary>
|
||
internal MotionInterpreter Motion => _motion;
|
||
|
||
/// <summary>
|
||
/// Retail <c>CPhysicsObj::StopCompletely</c> (0x00510180) enters through
|
||
/// <c>MovementManager::PerformMovement</c> (0x005240D0), not directly
|
||
/// through <c>CMotionInterp::StopCompletely</c>. The facade's type-5 path
|
||
/// performs the required zero-duration animation completion sweep after
|
||
/// the interpreter has queued its matching pending-motion node.
|
||
/// </summary>
|
||
internal WeenieError StopCompletelyAtPhysicsObjectBoundary() =>
|
||
Movement.PerformMovement(new MovementStruct
|
||
{
|
||
Type = MovementType.StopCompletely,
|
||
});
|
||
|
||
/// <summary>
|
||
/// Retail <c>CPhysicsObj::DoMotion</c> (0x00510020) constructs a type-1
|
||
/// <c>MovementStruct</c> carrying the original parameters pointer and
|
||
/// routes it through <c>MovementManager::PerformMovement</c>. That outer
|
||
/// boundary is observable: <c>CMotionInterp::PerformMovement</c>
|
||
/// (0x00528E80) performs the synchronous completed-animation sweep after
|
||
/// the motion dispatch.
|
||
/// </summary>
|
||
private WeenieError DoMotionAtPhysicsObjectBoundary(
|
||
uint motion,
|
||
AcDream.Core.Physics.Motion.MovementParameters parameters)
|
||
{
|
||
_body.LastMoveWasAutonomous = true;
|
||
return Movement.PerformMovement(new MovementStruct
|
||
{
|
||
Type = MovementType.RawCommand,
|
||
Motion = motion,
|
||
Params = parameters,
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail <c>CPhysicsObj::StopMotion</c> (0x005100D0), the type-3 peer of
|
||
/// <see cref="DoMotionAtPhysicsObjectBoundary"/>. Player input must use
|
||
/// this facade; MoveToManager's private _DoMotion/_StopMotion path stays
|
||
/// directly on CMotionInterp, matching retail.
|
||
/// </summary>
|
||
private WeenieError StopMotionAtPhysicsObjectBoundary(
|
||
uint motion,
|
||
AcDream.Core.Physics.Motion.MovementParameters parameters)
|
||
{
|
||
_body.LastMoveWasAutonomous = true;
|
||
return Movement.PerformMovement(new MovementStruct
|
||
{
|
||
Type = MovementType.StopRawCommand,
|
||
Motion = motion,
|
||
Params = parameters,
|
||
});
|
||
}
|
||
|
||
/// <summary>R4-V5: CONTACT transient-state bit (retail
|
||
/// <c>transient_state & 1</c>) — the <see cref="MoveTo"/> manager's
|
||
/// UseTime tick gate reads exactly this bit (decomp §6a @307781; a
|
||
/// strict subset of the funnel's Contact+OnWalkable gate).</summary>
|
||
internal bool BodyInContact => _body.InContact;
|
||
|
||
/// <summary>Retail <c>SendPositionEvent</c> admission gate: both Contact
|
||
/// and OnWalkable must be present on the local physics body.</summary>
|
||
internal bool CanSendPositionEvent => _body.InContact && _body.OnWalkable;
|
||
|
||
/// <summary>R4-V5: complete body orientation for the
|
||
/// <see cref="MoveTo"/> manager's position seam. <see cref="Yaw"/> is
|
||
/// only its horizontal projection.</summary>
|
||
internal Quaternion BodyOrientation => _body.Orientation;
|
||
|
||
/// <summary>
|
||
/// Install a complete authoritative frame rotation (spawn, teleport, or
|
||
/// server correction) without collapsing it through the yaw projection.
|
||
/// </summary>
|
||
internal void SetBodyOrientation(Quaternion orientation)
|
||
{
|
||
_body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
|
||
_body.Position,
|
||
_body.Orientation,
|
||
orientation);
|
||
}
|
||
|
||
/// <summary>R4-V5 wedge fix — the P1 unpack store
|
||
/// (<c>CPhysics::SetObjectMovement</c> @00509730:
|
||
/// <c>last_move_was_autonomous = arg7</c>, written on the unpack path
|
||
/// for every applied movement event). GameWindow's local-player 0xF74C
|
||
/// branch stores the wire autonomous byte here (always false — the P1
|
||
/// gate drops autonomous echoes); the speculative use install stores
|
||
/// false too (it models the wire mt-6 ACE sends moments later). Routes
|
||
/// the per-tick pump's A3 dual dispatch to the INTERPRETED branch
|
||
/// during a server moveto so apply_raw can't clobber the manager's
|
||
/// dispatched motions with the idle raw state.</summary>
|
||
internal void SetLastMoveWasAutonomous(bool autonomous)
|
||
{
|
||
_body.LastMoveWasAutonomous = autonomous;
|
||
_controlledByServer = !autonomous;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Wire the player's AnimationSequencer current cycle velocity into
|
||
/// <see cref="MotionInterpreter.GetCycleVelocity"/>. When attached,
|
||
/// <c>get_state_velocity</c> uses <c>MotionData.Velocity * speedMod</c>
|
||
/// as the primary forward-axis drive, keeping the body's world velocity
|
||
/// locked to the animation's baked-in root-motion velocity.
|
||
///
|
||
/// <para>
|
||
/// Without this accessor, the decompiled constant path
|
||
/// (<c>RunAnimSpeed * ForwardSpeed</c>) is used — matches retail only
|
||
/// when the character's MotionTable happens to bake Velocity=4.0 on
|
||
/// RunForward, which is true for Humanoid but not for arbitrary
|
||
/// creatures. See <see cref="MotionInterpreter.GetCycleVelocity"/>
|
||
/// for the full rationale.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Called once from <c>GameWindow.CreateAnimatedEntity</c> after the
|
||
/// player's <c>AnimatedEntity.Sequencer</c> is constructed.
|
||
/// </para>
|
||
/// </summary>
|
||
public void AttachCycleVelocityAccessor(Func<Vector3> accessor)
|
||
{
|
||
if (accessor is null) throw new ArgumentNullException(nameof(accessor));
|
||
_motion.GetCycleVelocity = accessor;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <c>Frame</c> emitted by
|
||
/// <c>CSequence::update</c>. The callback runs only for admitted object
|
||
/// quanta; render-frame fragments are retained by the object clock rather
|
||
/// than advancing PartArray early.
|
||
/// </summary>
|
||
public void AttachAnimationRootMotionSource(
|
||
Action<float, AcDream.Core.Physics.Motion.MotionDeltaFrame> advance,
|
||
Action? processHooks = null)
|
||
{
|
||
_advanceAnimationRootMotion = advance
|
||
?? throw new ArgumentNullException(nameof(advance));
|
||
_processAnimationHooks = processHooks;
|
||
_animationRootMotionScratch.Reset();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail <c>CommandInterpreter::SendMovementEvent</c> (0x006B4680)
|
||
/// stamps only <c>last_sent_position_time</c>. The carried frame and
|
||
/// contact plane remain those from the last AutonomousPosition packet.
|
||
/// </summary>
|
||
public void NoteMovementSent(float nowSeconds, bool mouseLookEvent = false)
|
||
{
|
||
_lastSentTime = nowSeconds;
|
||
if (mouseLookEvent)
|
||
{
|
||
_mouseMovementEventPending = false;
|
||
_lastMouseMovementEventTime = nowSeconds;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Captures the current raw movement axes for an input-boundary
|
||
/// SendMovementEvent. ToggleMouseLook sends synchronously, so the host
|
||
/// cannot wait for the next physics update to reconstruct this state.
|
||
/// </summary>
|
||
public MovementResult CaptureMovementResult(bool mouseLookEvent)
|
||
{
|
||
var raw = _motion.RawState;
|
||
uint? forward = raw.ForwardCommand == RawMotionState.Default.ForwardCommand
|
||
? null : raw.ForwardCommand;
|
||
uint? sidestep = raw.SidestepCommand == RawMotionState.Default.SidestepCommand
|
||
? null : raw.SidestepCommand;
|
||
uint? turn = raw.TurnCommand == RawMotionState.Default.TurnCommand
|
||
? null : raw.TurnCommand;
|
||
return new MovementResult(
|
||
Position: Position,
|
||
RenderPosition: RenderPosition,
|
||
CellId: CellId,
|
||
IsOnGround: CanSendPositionEvent,
|
||
MotionStateChanged: true,
|
||
ForwardCommand: forward,
|
||
SidestepCommand: sidestep,
|
||
TurnCommand: turn,
|
||
ForwardSpeed: forward.HasValue ? raw.ForwardSpeed : null,
|
||
SidestepSpeed: sidestep.HasValue ? raw.SidestepSpeed : null,
|
||
TurnSpeed: turn.HasValue ? raw.TurnSpeed : null,
|
||
IsRunning: raw.CurrentHoldKey == HoldKey.Run,
|
||
ShouldSendMovementEvent: true,
|
||
TurnUsesRunHold: turn.HasValue && raw.TurnHoldKey == HoldKey.Run,
|
||
SidestepUsesRunHold: sidestep.HasValue
|
||
&& raw.SidestepHoldKey == HoldKey.Run,
|
||
IsMouseLookMovementEvent: mouseLookEvent,
|
||
CurrentStyle: raw.CurrentStyle);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Captures the current controller state for presentation without
|
||
/// advancing physics or requesting an outbound movement event.
|
||
/// </summary>
|
||
public MovementResult CapturePresentationResult()
|
||
{
|
||
MovementResult current = CaptureMovementResult(mouseLookEvent: false);
|
||
return current with
|
||
{
|
||
MotionStateChanged = false,
|
||
ShouldSendMovementEvent = false,
|
||
IsMouseLookMovementEvent = false,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail <c>CommandInterpreter::SendPositionEvent</c> (0x006B4770)
|
||
/// stamps the send time, complete cell-local frame (origin and
|
||
/// orientation), and contact plane after an AutonomousPosition packet.
|
||
/// </summary>
|
||
public void NotePositionSent(AcDream.Core.Physics.Position position,
|
||
System.Numerics.Plane contactPlane,
|
||
float nowSeconds)
|
||
{
|
||
_lastSentPosition = position;
|
||
_lastSentContactPlane = contactPlane;
|
||
_lastSentTime = nowSeconds;
|
||
_lastSentInitialized = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Faithful port of retail
|
||
/// <c>CommandInterpreter::ShouldSendPositionEvent</c> (0x006B45E0).
|
||
/// Before the one-second interval expires, only a cell or contact-plane
|
||
/// change sends. Once it expires, the complete frame is compared, including
|
||
/// orientation; this is what publishes a stationary heading change.
|
||
/// </summary>
|
||
internal bool ShouldSendPositionEvent(
|
||
AcDream.Core.Physics.Position currentPosition,
|
||
System.Numerics.Plane currentContactPlane,
|
||
float nowSeconds)
|
||
{
|
||
if (!_lastSentInitialized)
|
||
return true;
|
||
|
||
bool cellChanged = _lastSentPosition.ObjCellId != currentPosition.ObjCellId;
|
||
if ((_lastSentTime + HeartbeatInterval) >= nowSeconds)
|
||
{
|
||
return cellChanged
|
||
|| !ApproxPlaneEqual(_lastSentContactPlane, currentContactPlane);
|
||
}
|
||
|
||
return cellChanged
|
||
|| !ApproxFrameEqual(_lastSentPosition.Frame, currentPosition.Frame);
|
||
}
|
||
|
||
// L.2a slice 1 (2026-05-12): centralized CellId mutation so the
|
||
// [cell-transit] probe fires from a single chokepoint. Both the
|
||
// server-snap path (SetPosition) and the per-frame resolver path
|
||
// route through here. When PhysicsDiagnostics.ProbeCellEnabled is
|
||
// off this collapses to a single bool-compare + assignment — zero
|
||
// logging cost.
|
||
private void UpdateCellId(uint newCellId, string reason)
|
||
{
|
||
if (newCellId != CellId && PhysicsDiagnostics.ProbeCellEnabled)
|
||
{
|
||
var pos = _body.Position;
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[cell-transit] 0x{CellId:X8} -> 0x{newCellId:X8} pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) reason={reason}"));
|
||
}
|
||
CellId = newCellId;
|
||
|
||
// Render root: CellGraph.CurrCell IS "the player's cell" — it roots the indoor render
|
||
// (GameWindow.OnRender). Set it HERE, the single PLAYER-only chokepoint for CellId
|
||
// (teleport / server snap @ SetPosition + per-frame resolver), NOT in the per-entity
|
||
// PhysicsEngine.ResolveWithTransition. That ran for EVERY entity, so a Holtburg NPC
|
||
// jump-looping near the cottage doorway clobbered the render root every tick → the render
|
||
// rooted at the NPC's tiny connector cell → only its ~8-tri shell drew, rest = GL clear
|
||
// color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 via [flap-cam]/[shell]).
|
||
_physics.UpdatePlayerCurrCell(newCellId);
|
||
}
|
||
|
||
public void SetPosition(Vector3 pos, uint cellId)
|
||
// #145: tests + legacy callers run in the world==block-local frame (no
|
||
// streaming center), so the cell-local seed IS the world position. This
|
||
// makes the carried anchor (body.Position − CellPosition.Origin) == (0,0,0),
|
||
// identical to the legacy Zero terrain-origin fallback → behaviour unchanged.
|
||
=> SetPosition(pos, cellId, pos);
|
||
|
||
/// <summary>
|
||
/// Server-snap / teleport placement. <paramref name="cellLocal"/> is the
|
||
/// LANDBLOCK-relative position (the wire's local, or world − landblock origin)
|
||
/// which seeds the body's cell-relative <c>CellPosition</c> WITHOUT any streaming
|
||
/// center (#145). A teleport is a large jump, so this snaps the cell frame
|
||
/// directly via <c>SnapToCell</c> rather than delta-syncing through the setter.
|
||
/// </summary>
|
||
public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
|
||
{
|
||
_body.SnapToCell(cellId, pos, cellLocal);
|
||
_prevPhysicsPos = pos;
|
||
_currPhysicsPos = pos;
|
||
UpdateCellId(_body.CellPosition.ObjCellId, "teleport");
|
||
|
||
// Treat as grounded after a server-side position snap.
|
||
_body.TransientState = TransientStateFlags.Contact
|
||
| TransientStateFlags.OnWalkable
|
||
| 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.)
|
||
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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail <c>SmartBox::BlipPlayer</c> (0x00453940): apply a server
|
||
/// FORCE_POSITION correction through <c>CPhysicsObj::SetPositionSimple</c>
|
||
/// without the teleport hook. Active motion, velocity, contact state, and
|
||
/// PositionManager stick relationships deliberately survive the blip.
|
||
/// </summary>
|
||
public void BlipPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
|
||
{
|
||
_body.SnapToCell(cellId, pos, cellLocal);
|
||
_prevPhysicsPos = pos;
|
||
_currPhysicsPos = pos;
|
||
UpdateCellId(_body.CellPosition.ObjCellId, "force-position");
|
||
}
|
||
|
||
private Vector3 ComputeRenderPosition()
|
||
{
|
||
float alpha = Math.Clamp(
|
||
(float)(_objectClock.PendingSeconds / PhysicsBody.MinQuantum),
|
||
0f,
|
||
1f);
|
||
return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail Hidden slice of <c>CPhysicsObj::UpdatePositionInternal</c>
|
||
/// (0x00512C30). Input, PartArray root motion, and physics integration are
|
||
/// skipped, while PositionManager composition and the manager tail remain
|
||
/// live. A composed offset still commits through CTransition so cell
|
||
/// membership and contact state cannot become stale behind the hidden mesh.
|
||
/// </summary>
|
||
public MovementResult TickHidden(float dt, 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail <c>Frame::is_equal</c> (0x00424C30) compares both the origin
|
||
/// and all four quaternion components with a 0.0002-unit epsilon. It does
|
||
/// not treat the mathematically equivalent <c>q</c>/<c>-q</c> pair as the
|
||
/// same stored frame.
|
||
/// </summary>
|
||
private static bool ApproxFrameEqual(CellFrame a, CellFrame b)
|
||
{
|
||
const float Epsilon = 0.000199999995f;
|
||
return MathF.Abs(a.Origin.X - b.Origin.X) <= Epsilon
|
||
&& MathF.Abs(a.Origin.Y - b.Origin.Y) <= Epsilon
|
||
&& MathF.Abs(a.Origin.Z - b.Origin.Z) <= Epsilon
|
||
&& MathF.Abs(a.Orientation.W - b.Orientation.W) < Epsilon
|
||
&& MathF.Abs(a.Orientation.X - b.Orientation.X) < Epsilon
|
||
&& MathF.Abs(a.Orientation.Y - b.Orientation.Y) < Epsilon
|
||
&& MathF.Abs(a.Orientation.Z - b.Orientation.Z) < Epsilon;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 2026-05-16. Contact-plane-equality test for retail's
|
||
/// sub-interval AP gate. Retail's SendPositionEvent stores
|
||
/// last_sent_contact_plane and ShouldSendPositionEvent re-sends
|
||
/// during the sub-interval window if the plane has changed (e.g.,
|
||
/// player stepped onto stairs / a hill — same cell but different
|
||
/// contact normal). Tiny epsilon on normal + distance covers
|
||
/// floating-point noise from the physics integration.
|
||
/// </summary>
|
||
private static bool ApproxPlaneEqual(
|
||
System.Numerics.Plane a, System.Numerics.Plane b)
|
||
{
|
||
const float Epsilon = 0.000199999995f;
|
||
return MathF.Abs(a.Normal.X - b.Normal.X) <= Epsilon
|
||
&& MathF.Abs(a.Normal.Y - b.Normal.Y) <= Epsilon
|
||
&& MathF.Abs(a.Normal.Z - b.Normal.Z) <= Epsilon
|
||
&& MathF.Abs(a.D - b.D) < Epsilon;
|
||
}
|
||
}
|