acdream/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
Erik 1390f9477d fix: airborne jump refusal fires at RELEASE, not press (user retail
gate; supersedes CH round-1 item A)

The user's retail description matches the decomp exactly:
charge_jump @0x005281c0 has NO grounded check - it refuses only 0x49
(CanJump encumbrance) and 0x48 (fallen/crouch-family forward commands).
Pressing jump while airborne begins the powerbar and charges normally.
The 0x24 "You can't jump while in the air" comes exclusively from the
RELEASE path (ClientCombatSystem::DoJump @0x0056B110 ->
CMotionInterp::jump -> jump_is_allowed, whose airborne 0x24 our port
already carries test-pinned). A charge held through landing executes a
normal jump on the grounded release.

PlayerMovementController's input orchestration now mirrors
CommenceJump/DoJump:
- Press edge: ChargeJump() decides; a refused charge (0x48/0x49)
  reports and never begins the bar (retail's jump_pending stays 0).
  The invented airborne press-edge 0x24 report (CH user-gate round 1
  item A - added when the press/release split was not yet known) is
  deleted; CommenceJump's in-air fallback text is unreachable with a
  faithful charge_jump.
- Hold: accumulates grounded OR airborne; leaving the ground mid-charge
  no longer force-fires the jump.
- Release: fires jump(); an airborne release refuses 0x24 there.

Tests: the round-1 press-edge test is replaced by two release-semantics
tests (airborne release reports once; held-through-landing grounded
release jumps silently). Runtime 1,619, App 4,987/3, Core jump family
159.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:31:22 +02:00

3161 lines
146 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using System;
using System.Numerics;
using AcDream.Core.Chat;
using AcDream.Core.Physics;
namespace AcDream.Runtime.Gameplay;
/// <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>
/// Typed construction policy for the local movement owner. Server-authoritative
/// values come from <see cref="RuntimeMovementSkillState"/>; the fallback only
/// preserves the pre-description test/login baseline and never reads process
/// environment state.
/// </summary>
public readonly record struct PlayerMovementConstructionOptions(
int RunSkill,
int JumpSkill)
{
public const int FallbackRunSkill = 200;
public const int FallbackJumpSkill = 300;
public static PlayerMovementConstructionOptions Fallback =>
new(FallbackRunSkill, FallbackJumpSkill);
public static PlayerMovementConstructionOptions From(
RuntimeMovementSkillSnapshot skills) =>
new(
skills.RunSkill >= 0 ? skills.RunSkill : FallbackRunSkill,
skills.JumpSkill >= 0 ? skills.JumpSkill : FallbackJumpSkill);
}
/// <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 }
internal enum PlayerMovementControllerPublicationLifecycle
{
StandalonePublished,
CandidatePreparing,
CandidateSealed,
RuntimeOwnedDormant,
RuntimePublished,
RuntimeRetired,
Discarded,
}
/// <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.ResolveWithTransition is still used each frame to snap
/// the player to terrain/cell floor Z and detect ground contact (C5a,
/// 2026-08-05: the legacy PhysicsEngine.Resolve this note used to name
/// is deleted, zero production callers; ResolveWithTransition's
/// sphere-sweep resolver is, and always was, the real per-frame path).
/// </summary>
public sealed class PlayerMovementController
{
private readonly PhysicsEngine _physics;
private readonly PhysicsBody _body;
private readonly MotionInterpreter _motion;
private readonly PlayerWeenie _weenie;
private readonly AcDream.Core.Physics.Motion.MovementManager
_movementManager;
private float _stepUpHeight = 0.4f;
private float _stepDownHeight = 0.4f;
private ObjectInfoState _ownPvpFlags = ObjectInfoState.None;
private System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>
_sphereList;
private float _objectScale = 1f;
private PlayerState _state = PlayerState.InWorld;
private uint _localEntityId;
private AcDream.Core.Physics.Motion.PositionManager? _positionManager;
/// <summary>
/// Campaign CH slice CH2: reports a client-locally-detected jump refusal
/// (retail's <c>CommenceJump @0x0056AF90</c> / <c>DoJump @0x0056B110</c>
/// WeenieError family — research doc §4.2/§6.4) to the SpewBox router.
/// Wired by <c>RuntimeLocalPlayerMovementState</c>, whose
/// <c>OnInterfaceText</c> setter applies to every controller it installs
/// (including this one, at commit time). Never invoked directly for the
/// jump physics/charge behaviour itself — this is a REPORT of an
/// already-decided refusal, not a gate.
/// </summary>
public Action<string, RetailLogTextType>? OnInterfaceText { get; set; }
/// <summary>
/// Maximum Z increase per movement step before the move is rejected.
///
/// <para>
/// #338 (2026-08-07) — this comment previously claimed retail's
/// <c>step_up_height</c> for humans "is ~0.4 m" and that the value is
/// "set in GameWindow.cs at world-entry time". Both were false, and a
/// third false claim on the SphereList doc named a
/// <c>PlayerModeController.ApplyStepHeights</c> that has never existed
/// in the tree. The measured truth: the human Setup 0x02000001 authors
/// <b>0.600</b> up / <b>1.500</b> down; retail's fallback when NOT on
/// walkable ground is <b>0.04</b> (<c>CTransition::step_up</c>
/// @0x0050b655), and 0.4 appears nowhere in retail. The authoritative
/// writer is <c>RuntimeSetPositionMoverPreparation</c> (Setup-derived,
/// x scale) via <c>RuntimeLocalPlayerPhysicsPublicationState</c>'s
/// candidate, adopted by <c>CommitRuntimeOwnedController</c>. The 0.4f
/// construction default below survives only until that adoption — a
/// seconds-long window shared with AD-68's remote residency placeholder.
/// Live capture: 111,248 authored-pair resolves vs 358 placeholder ones.
/// </para>
/// </summary>
public float StepUpHeight
{
get => _stepUpHeight;
set
{
EnsureConfigurationMutable();
_stepUpHeight = value;
}
}
/// <summary>
/// L.2.3a (2026-04-29): how far below the foot the step-down probe
/// reaches when transitioning between surfaces. (The original "retail's
/// step_down_height is ~0.4 m" claim here was wrong — the human Setup
/// authors <b>1.500</b>; see <see cref="StepUpHeight"/>'s #338 note. The
/// historical observation stands: with the very first 4 cm hardcoded
/// value, walking off a stair onto ground 25 cm below produced a
/// one-frame contact-plane gap and a falling-animation flicker.)
/// </summary>
public float StepDownHeight
{
get => _stepDownHeight;
set
{
EnsureConfigurationMutable();
_stepDownHeight = value;
}
}
/// <summary>
/// TS-23 (Campaign P Slice P3, 2026-07-30): the local player's own
/// PK/PKLite/Impenetrable <see cref="ObjectInfoState"/> bits, decoded
/// from <c>PublicWeenieDesc._bitfield</c> (retail <c>OBJECTINFO::init</c>
/// 0x0050cf30 `state |= w-&gt;vtable-&gt;IsPK()/IsPKLite()/IsImpenetrable()
/// ? 0x800/0x1000/0x80`). Set at world-entry and refreshed reactively
/// whenever the player's own <c>ClientObject.PublicWeenieBitfield</c>
/// changes (same trigger set as <see cref="StepUpHeight"/>'s
/// server-skill pushes). Default <see cref="ObjectInfoState.None"/>
/// (no PK/PKLite/Impenetrable) is bit-identical to every pre-P3 caller's
/// hardcoded <c>IsPlayer | EdgeSlide</c> — the non-PK invariant this
/// port must not break.
/// </summary>
public ObjectInfoState OwnPvpFlags
{
get => _ownPvpFlags;
set
{
EnsureConfigurationMutable();
_ownPvpFlags = value;
}
}
/// <summary>
/// TS-46 (2026-07-30): the player's own Setup ≤2-sphere list (dat
/// <c>CSphere</c> Origin+Radius), verbatim per retail
/// <c>CPhysicsObj::transition</c> (0x00512dc0) →
/// <c>SPHEREPATH::init_sphere</c> (0x0050c670). Set alongside
/// <see cref="StepUpHeight"/>/<see cref="StepDownHeight"/> by
/// <c>RuntimeLocalPlayerPhysicsPublicationState</c>'s publication
/// candidate (#338: the previously-named
/// <c>PlayerModeController.ApplyStepHeights</c> never existed). Default
/// (empty) falls back to <c>ResolveWithTransition</c>'s legacy
/// (0.48, 1.835) two-scalar capsule reconstruction — the human Setup
/// 0x02000001's authored spheres are (0,0,0.475) r=.48 and
/// (0,0,1.350) r=.48, a 5 mm improvement over the reconstruction's
/// (0,0,0.48) + (0,0,1.355).
/// </summary>
public System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>
SphereList
{
get => _sphereList;
set
{
EnsureConfigurationMutable();
_sphereList = value;
}
}
/// <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 => _objectScale;
set
{
EnsureConfigurationMutable();
_objectScale = value;
}
}
/// <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 => _state;
set
{
EnsurePublishedForRuntimeOperation();
_state = value;
}
}
/// <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
{
EnsurePublishedForRuntimeOperation();
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)
{
EnsurePublishedForRuntimeOperation();
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 => _localEntityId;
set
{
// A same-value re-assertion is not a mutation:
// RuntimeLocalPlayerFrameController.AdvanceBeforeNetwork
// re-asserts the resolved id every advance tick, which on the
// headless host reached a still-dormant/sealed controller and
// quarantined the whole session (jump-probe repro, 2026-08-10).
// A DIFFERENT id while sealed remains the error it always was.
if (value == _localEntityId)
return;
EnsureConfigurationMutable();
_localEntityId = value;
}
}
/// <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)
{
EnsureConfigurationMutable();
_body.State = state;
_body.calc_acceleration();
}
/// <summary>
/// C3c-F1 (2026-08-02): the lifecycle-deciding inbound-SetState entry
/// for the local player. Live states apply the exact
/// <see cref="ApplyPhysicsState"/> body; the dormant window drops the
/// push because the activation transaction owns the dormant body's
/// physics state exclusively (<see cref="RefreshDormantRuntimePhysicsState"/>
/// re-reads the canonical record's FinalPhysicsState at both activation
/// phases, and while the accepted SetState is queued behind the initial
/// residence the App-side push carries that same unchanged record value
/// — the drop is value-preserving by construction); terminal states are
/// displaced pushes (J3.6 displaced-callback-rejection), never a fault.
/// </summary>
internal RuntimeServerPhysicsStateApplication ApplyServerPhysicsState(
PhysicsStateFlags state)
{
switch (_publicationLifecycle)
{
case PlayerMovementControllerPublicationLifecycle.StandalonePublished:
case PlayerMovementControllerPublicationLifecycle.CandidatePreparing:
case PlayerMovementControllerPublicationLifecycle.RuntimePublished:
_body.State = state;
_body.calc_acceleration();
return RuntimeServerPhysicsStateApplication.AppliedLive;
case PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant:
return RuntimeServerPhysicsStateApplication
.DroppedDormantActivationOwned;
default:
return RuntimeServerPhysicsStateApplication
.DroppedDisplacedController;
}
}
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;
// Campaign CH user-gate round 1 (item A, #329 sibling finding): previous
// frame's raw Jump input, so an airborne jump press can be reported on
// its RISING edge only — retail's jump_is_allowed (called from
// ClientCombatSystem::DoJump @0x0056B110) refuses once per press, not
// once per frame the key is held.
private bool _prevJumpHeld;
/// <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 RetailObjectQuantumClock _objectClock;
private PlayerMovementControllerPublicationLifecycle _publicationLifecycle;
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();
private bool _externalMovementEventPending;
// ── 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
{
EnsureConfigurationMutable();
return _movementManager;
}
}
/// <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
{
EnsureConfigurationMutable();
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) and, as of
/// Campaign P P5 (#167), also tears down and immediately re-arms the
/// constraint leash (<c>UnConstrain</c> then <c>ConstrainTo</c>);
/// <see cref="CommitCanonicalForcePositionFrame"/>'s ForcePosition route
/// does NOT touch the leash at all — retail's FORCE_POSITION branch of
/// <c>SmartBox::HandleReceivedPosition</c> (@0x00453FD0) returns at
/// 0x0045409D, before every <c>ConstrainTo</c> call (C4 route 2 review
/// fix, 2026-08-03 — the deleted <c>BlipPosition</c>'s unconditional
/// leash re-arm here was an unbacked deviation for that exact branch;
/// see docs/research/2026-08-03-c4-route-2-implementation-plan.md §1b).
/// </summary>
public AcDream.Core.Physics.Motion.PositionManager? PositionManager
{
get
{
EnsureConfigurationMutable();
return _positionManager;
}
set
{
EnsureConfigurationMutable();
_positionManager = value;
}
}
public PlayerMovementController(
PhysicsEngine physics,
RetailObjectQuantumClock? objectClock = null)
: this(
physics,
objectClock,
PlayerMovementConstructionOptions.Fallback,
PlayerMovementControllerPublicationLifecycle.StandalonePublished)
{
}
public PlayerMovementController(
PhysicsEngine physics,
RetailObjectQuantumClock? objectClock,
PlayerMovementConstructionOptions options)
: this(
physics,
objectClock,
options,
PlayerMovementControllerPublicationLifecycle.StandalonePublished)
{
}
private PlayerMovementController(
PhysicsEngine physics,
RetailObjectQuantumClock? objectClock,
PlayerMovementConstructionOptions options,
PlayerMovementControllerPublicationLifecycle publicationLifecycle)
{
_physics = physics;
_objectClock = objectClock ?? new RetailObjectQuantumClock();
_publicationLifecycle = publicationLifecycle;
_body = new PhysicsBody
{
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
// Default skills — tuned toward mid-retail feel. Real characters'
// skills come from PlayerDescription (0xF7B0/0x0013) — GameWindow
// Runtime supplies them through typed construction options and
// updates them via SetCharacterSkills when later authoritative
// values arrive.
// 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 PlayerDescription supplies both values, retain the typed
// construction baseline. RuntimeMovementSkillState updates this same
// PlayerWeenie after authoritative character data arrives.
_weenie = new PlayerWeenie(
runSkill: options.RunSkill,
jumpSkill: options.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).
_movementManager = new AcDream.Core.Physics.Motion.MovementManager(_motion);
_movementManager.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;
}
internal static PlayerMovementController CreatePublicationCandidate(
PhysicsEngine physics,
PlayerMovementConstructionOptions options) => new(
physics,
new RetailObjectQuantumClock(),
options,
PlayerMovementControllerPublicationLifecycle.CandidatePreparing);
internal PhysicsBody PhysicsBody
{
get
{
EnsureConfigurationMutable();
return _body;
}
}
internal bool OwnsPhysicsBody(PhysicsBody body) =>
ReferenceEquals(_body, body);
internal bool IsSealedPublicationCandidate => _publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.CandidateSealed;
internal bool IsRuntimeOwnedDormant => _publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant;
internal bool IsRuntimePublished => _publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.RuntimePublished;
/// <summary>
/// True when this controller may execute a live movement operation — the
/// same predicate <see cref="EnsurePublishedForRuntimeOperation"/> throws
/// on. Exposed so LIFECYCLE callers (window focus loss, session teardown)
/// can skip the operation instead of faulting: a focus change can arrive at
/// any moment, including before the controller is published during login
/// and after it is retired on logout, and neither is an error.
/// </summary>
public bool CanExecuteLiveMovement => _publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.StandalonePublished
or PlayerMovementControllerPublicationLifecycle.RuntimePublished;
private bool _dormantSetPositionGroundPhase;
internal void BeginDormantSetPositionGroundPhase()
{
if (!IsRuntimeOwnedDormant || _dormantSetPositionGroundPhase)
throw new InvalidOperationException(
"Dormant SetPosition ground phase requires one dormant Runtime owner.");
_dormantSetPositionGroundPhase = true;
}
internal void EndDormantSetPositionGroundPhase()
{
if (!_dormantSetPositionGroundPhase)
throw new InvalidOperationException(
"Dormant SetPosition ground phase is not active.");
_body.TransientState &= ~TransientStateFlags.Active;
_dormantSetPositionGroundPhase = false;
}
internal bool IsDormantSetPositionGroundPhaseActive =>
_dormantSetPositionGroundPhase;
internal void RefreshDormantRuntimePhysicsState(
PhysicsStateFlags state,
bool recalculateAcceleration)
{
if (!IsRuntimeOwnedDormant || _dormantSetPositionGroundPhase)
throw new InvalidOperationException(
"Only an idle dormant Runtime owner can refresh physics state.");
_body.State = state;
if (recalculateAcceleration)
_body.calc_acceleration();
}
internal void RefreshDormantRuntimeVector(
Vector3? velocity,
Vector3? omega)
{
if (!IsRuntimeOwnedDormant || _dormantSetPositionGroundPhase)
throw new InvalidOperationException(
"Only an idle dormant Runtime owner can refresh vector state.");
if (velocity is { } liveVelocity)
_body.set_velocity(liveVelocity);
if (omega is { } liveOmega)
_body.Omega = liveOmega;
_body.TransientState &= ~TransientStateFlags.Active;
}
internal void SealPublicationCandidate()
{
if (_publicationLifecycle
is not PlayerMovementControllerPublicationLifecycle
.CandidatePreparing)
{
throw new InvalidOperationException(
"Only a preparing Runtime movement candidate can be sealed.");
}
_publicationLifecycle = PlayerMovementControllerPublicationLifecycle
.CandidateSealed;
}
internal void CommitRuntimeOwnership(RetailObjectQuantumClock objectClock)
{
ArgumentNullException.ThrowIfNull(objectClock);
if (_publicationLifecycle
is not PlayerMovementControllerPublicationLifecycle.CandidateSealed)
{
throw new InvalidOperationException(
"Only a sealed Runtime movement candidate can be published.");
}
_objectClock = objectClock;
_publicationLifecycle = PlayerMovementControllerPublicationLifecycle
.RuntimeOwnedDormant;
}
internal void ActivateRuntimePublication()
{
if (_publicationLifecycle
is not PlayerMovementControllerPublicationLifecycle
.RuntimeOwnedDormant
|| _dormantSetPositionGroundPhase)
{
throw new InvalidOperationException(
"Only a dormant Runtime-owned movement controller can be activated.");
}
_publicationLifecycle = PlayerMovementControllerPublicationLifecycle
.RuntimePublished;
}
/// <summary>
/// Installs the already-committed Runtime SetPosition frame into the
/// controller's interpolation/cell sidecar without invoking the public
/// teleport path. The canonical body is written by Runtime first; this
/// method only makes the controller's private frame agree while it is
/// still dormant. It deliberately does not touch CellGraph, movement,
/// PositionManager, the object clock, or callbacks.
/// </summary>
internal void CommitRuntimeActivationFrame()
{
if (_publicationLifecycle
is not PlayerMovementControllerPublicationLifecycle
.RuntimeOwnedDormant
|| _dormantSetPositionGroundPhase)
{
throw new InvalidOperationException(
"Only a dormant Runtime-owned movement controller can accept its activation frame.");
}
_prevPhysicsPos = _body.Position;
_currPhysicsPos = _body.Position;
CellId = _body.CellPosition.ObjCellId;
}
internal void DiscardRuntimeCandidate()
{
if (_publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.CandidatePreparing
or PlayerMovementControllerPublicationLifecycle.CandidateSealed)
{
_publicationLifecycle = PlayerMovementControllerPublicationLifecycle
.Discarded;
}
}
internal void RetireRuntimePublication()
{
if (_publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant
or PlayerMovementControllerPublicationLifecycle.RuntimePublished)
{
_publicationLifecycle = PlayerMovementControllerPublicationLifecycle
.RuntimeRetired;
}
}
private void EnsureConfigurationMutable()
{
if (_publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.StandalonePublished
or PlayerMovementControllerPublicationLifecycle
.CandidatePreparing
or PlayerMovementControllerPublicationLifecycle.RuntimePublished
|| IsRuntimeOwnedDormant && _dormantSetPositionGroundPhase)
{
return;
}
throw new InvalidOperationException(
"A sealed, retired, or discarded Runtime movement controller cannot be mutated.");
}
/// <summary>
/// Campaign CH slice CH2: retail-exact <c>CommenceJump</c>/<c>DoJump</c>
/// refusal-text dispatch (research doc §4.2, verified directly against
/// the decomp — NOT the <c>HandleFailureEvent</c>/<c>WeenieErrorMessages</c>
/// table, which is a DIFFERENT switch that happens to share three of
/// these same string globals).
/// <c>ClientCombatSystem::CommenceJump @0x0056AF90</c> and
/// <c>ClientCombatSystem::DoJump @0x0056B110</c> each explicitly handle
/// only <c>0x24</c>/<c>0x48</c>/<c>0x49</c>; every other code —
/// including <c>0x47 GeneralMovementFailure</c> (fully constrained or
/// can't afford the jump's stamina cost, <see cref="MotionInterpreter.jump_is_allowed"/>)
/// and <c>0x08 NoPhysicsObject</c> — falls through their dispatch with
/// NO <c>AddTextToScroll</c> call at all. Confirmed via <c>DoJump</c>'s
/// compiled <c>switch(eax_7)</c> (raw 376288-376312): exactly 4 real
/// case targets (<c>0</c>, <c>0x24</c>, <c>0x48</c>, <c>0x49</c>), every
/// other index routed to the function's silent fall-through end. A
/// fully-constrained or out-of-stamina jump refusal is therefore
/// SILENT in retail — no on-screen text, the player just doesn't jump.
/// </summary>
private void ReportJumpRefusal(WeenieError result)
{
// Campaign CH user-gate round 2, item 1 (TEMPORARY): print
// UNCONDITIONALLY, before the OnInterfaceText null-check, so the
// probe distinguishes "ReportJumpRefusal was never called with a
// reportable result" from "it was called but OnInterfaceText was
// null" from "the callback fired but downstream dropped the line."
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeJumpEnabled)
{
Console.WriteLine(
$"[jump] ReportJumpRefusal result={result} "
+ $"hasCallback={OnInterfaceText is not null} "
+ $"onWalkable={_body.OnWalkable} "
+ $"prevJumpHeld={_prevJumpHeld}");
}
if (OnInterfaceText is null)
return;
string? text = result switch
{
WeenieError.NotGrounded => ClientTextRefusals.CantJumpInAir, // 0x24
WeenieError.YouCantJumpFromThisPosition => ClientTextRefusals.CantJumpPosition, // 0x48
WeenieError.CantJumpLoadedDown => ClientTextRefusals.CantJumpLoad, // 0x49
_ => null,
};
if (text is not null)
OnInterfaceText(text, RetailLogTextType.ClientLocal);
}
private void EnsurePublishedForRuntimeOperation()
{
if (_publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.StandalonePublished
or PlayerMovementControllerPublicationLifecycle.RuntimePublished)
{
return;
}
throw new InvalidOperationException(
"An unpublished or retired Runtime movement controller cannot execute live movement operations.");
}
/// <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()
{
EnsureConfigurationMutable();
if ((_body.State & PhysicsStateFlags.Static) != 0)
return;
if (IsRuntimeOwnedDormant && _dormantSetPositionGroundPhase)
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)
{
EnsurePublishedForRuntimeOperation();
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)
{
EnsurePublishedForRuntimeOperation();
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)
{
EnsurePublishedForRuntimeOperation();
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)
{
EnsurePublishedForRuntimeOperation();
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)
{
EnsurePublishedForRuntimeOperation();
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()
{
EnsurePublishedForRuntimeOperation();
// 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;
}
/// <summary>
/// Applies a command-originated retail posture through the same
/// CPhysicsObj movement boundary used by keyboard input. The next admitted
/// object turn emits the resulting raw movement state exactly once.
/// </summary>
public bool RequestPosture(uint motion)
{
EnsurePublishedForRuntimeOperation();
if (motion is not (
MotionCommand.Ready
or MotionCommand.Crouch
or MotionCommand.Sitting
or MotionCommand.Sleeping))
{
return false;
}
TakeControlFromServer();
var parameters =
new AcDream.Core.Physics.Motion.MovementParameters();
if (DoMotionAtPhysicsObjectBoundary(motion, parameters)
!= WeenieError.None)
{
return false;
}
_externalMovementEventPending = true;
return true;
}
public void SetCharacterSkills(int runSkill, int jumpSkill)
{
EnsureConfigurationMutable();
_weenie.SetSkills(runSkill, jumpSkill);
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): pushes the retail
/// <c>InqLoad</c>-equivalent burden ratio computed by Runtime (Strength
/// + augmentation property 0xE6 + EncumbranceVal property 5) into the
/// player's <see cref="PlayerWeenie"/> — wires the previously-dead
/// <c>PlayerWeenie.SetBurden</c> setter (TS-5 retired).
/// </summary>
public void SetCharacterBurden(float burden)
{
EnsureConfigurationMutable();
_weenie.SetBurden(burden);
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): pushes the current-stamina vital
/// reading. A negative value restores PlayerWeenie's "unknown, don't
/// gate" sentinel; any non-negative value including 0 gates
/// <c>InqRunRate</c>/<c>InqJumpVelocity</c>'s effective-skill-zeroing per
/// retail's <c>CACQualities::InqRunRate</c>/<c>InqJumpVelocity</c>.
/// </summary>
public void SetCharacterStamina(int currentStamina)
{
EnsureConfigurationMutable();
_weenie.SetStamina(currentStamina < 0 ? null : (uint)currentStamina);
}
/// <summary>
/// TS-23 §12b (Campaign P Slice P3, 2026-07-30): pushes the raw
/// <c>PlayerKillerStatus</c>/<c>LastPkAttackTimestamp</c> pair into
/// <see cref="PlayerWeenie.JumpStaminaCost"/>'s PK-timer bump. A
/// negative <paramref name="playerKillerStatus"/> restores "never
/// pushed" (matches <see cref="SetCharacterStamina"/>'s own sentinel
/// convention); <paramref name="lastPkAttackTimestamp"/> is <c>null</c>
/// when the property is absent.
/// </summary>
public void SetCharacterPkStatus(int playerKillerStatus, float? lastPkAttackTimestamp)
{
EnsureConfigurationMutable();
_weenie.SetPlayerKillerStatus(
playerKillerStatus < 0 ? null : playerKillerStatus,
lastPkAttackTimestamp);
}
/// <summary>
/// C3c-F1 (2026-08-02): the lifecycle-deciding half of the Runtime
/// movement-stats application seam
/// (<see cref="RuntimeLocalPlayerMovementState.ApplyCharacterMovementStats"/>).
/// The publication owner — not any App caller — decides whether a
/// server stat recompute may land:
/// <list type="bullet">
/// <item><see cref="PlayerMovementControllerPublicationLifecycle.StandalonePublished"/>,
/// <see cref="PlayerMovementControllerPublicationLifecycle.CandidatePreparing"/>, and
/// <see cref="PlayerMovementControllerPublicationLifecycle.RuntimePublished"/>
/// apply immediately — byte-identical to the deleted
/// <c>RuntimeMovementSkillProjection.ApplyTo</c> direct path.</item>
/// <item><see cref="PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant"/>
/// ALSO applies immediately: the dormant window (publication committed,
/// activation deferred on cell streaming —
/// <c>RuntimeLocalPlayerFirstEntryState.AdvanceCore</c>'s
/// AwaitingActivation loop) spans inbound pumps, and this exact instance
/// is the controller that <c>ActivateRuntimePublication</c> later makes
/// live, so the write must land here (same discipline as
/// <see cref="RefreshDormantRuntimePhysicsState"/> /
/// <see cref="RefreshDormantRuntimeVector"/>: accepted server facts
/// arriving mid-dormancy land on the dormant owner). These writes touch
/// only <see cref="PlayerWeenie"/> fields and the mover-flag latch —
/// no body/world/currency state the activation envelope validates.</item>
/// <item><see cref="PlayerMovementControllerPublicationLifecycle.CandidateSealed"/>,
/// <see cref="PlayerMovementControllerPublicationLifecycle.RuntimeRetired"/>, and
/// <see cref="PlayerMovementControllerPublicationLifecycle.Discarded"/>
/// report the typed displaced-write outcome (J3.6
/// displaced-callback-rejection): a stat write against a terminal
/// controller is meaningless by design — the next login re-derives from
/// PlayerDescription. A sealed candidate is additionally unreachable
/// through the seam in production: it is never installed into
/// <see cref="RuntimeLocalPlayerMovementState"/> (Prepare requires the
/// movement owner empty and Commit installs it already-dormant in the
/// same synchronous Advance step).</item>
/// </list>
/// </summary>
internal RuntimeMovementStatsApplication ApplyCharacterMovementStats(
in RuntimeMovementSkillSnapshot snapshot)
{
switch (_publicationLifecycle)
{
case PlayerMovementControllerPublicationLifecycle.StandalonePublished:
case PlayerMovementControllerPublicationLifecycle.CandidatePreparing:
case PlayerMovementControllerPublicationLifecycle.RuntimePublished:
ApplyCharacterMovementStatsCore(snapshot);
return RuntimeMovementStatsApplication.AppliedLive;
case PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant:
ApplyCharacterMovementStatsCore(snapshot);
return RuntimeMovementStatsApplication.AppliedDormant;
default:
return RuntimeMovementStatsApplication.DroppedDisplacedController;
}
}
/// <summary>
/// The exact application body of the deleted
/// <c>RuntimeMovementSkillProjection.ApplyTo</c> (same fields, same
/// order, same conversions) — moved behind the lifecycle switch so the
/// dormant window can share it without routing through the
/// <see cref="EnsureConfigurationMutable"/>-gated public setters.
/// Campaign P Slice P1 (2026-07-30): burden/stamina ride the SAME seam
/// run/jump skill already used — see the pseudocode doc §9. TS-23
/// (Campaign P Slice P3, 2026-07-30): the player's own
/// PK/PKLite/Impenetrable collision-exemption bits and the
/// PlayerKillerStatus/LastPkAttackTimestamp pair the jump-cost PK-timer
/// bump reads — see <c>EntityCollisionFlagsExt.ToMoverState</c> and
/// <c>PlayerWeenie.JumpStaminaCost</c>.
/// </summary>
private void ApplyCharacterMovementStatsCore(
in RuntimeMovementSkillSnapshot snapshot)
{
_weenie.SetSkills(snapshot.RunSkill, snapshot.JumpSkill);
_weenie.SetBurden(snapshot.Burden);
_weenie.SetStamina(
snapshot.CurrentStamina < 0 ? null : (uint)snapshot.CurrentStamina);
_ownPvpFlags = EntityCollisionFlagsExt
.FromPwdBitfield(snapshot.OwnPwdBitfield)
.ToMoverState();
_weenie.SetPlayerKillerStatus(
snapshot.PlayerKillerStatus < 0 ? null : snapshot.PlayerKillerStatus,
snapshot.LastPkAttackTimestamp);
}
/// <summary>
/// C3c-F1: the stamina-exhaustion EVENT dispatch
/// (retail <c>CommandInterpreter::HandleExhaustion</c> @ 0x006b3c70 →
/// <c>CPhysicsObj::report_exhaustion</c>), routed through the owner so
/// App never touches the gated <see cref="Motion"/> surface. Fires only
/// on a live controller: a dormant owner has no in-flight movement to
/// re-dispatch (retail's handler is a no-op for a player not in world;
/// activation dispatches movement fresh from the already-current
/// <see cref="PlayerWeenie"/> stamina gate), and a terminal owner is a
/// displaced callback.
/// </summary>
internal bool ReportExhaustionAtMovementBoundary()
{
if (_publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.StandalonePublished
or PlayerMovementControllerPublicationLifecycle.CandidatePreparing
or PlayerMovementControllerPublicationLifecycle.RuntimePublished)
{
_motion.ReportExhaustion();
return true;
}
return false;
}
/// <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
{
get
{
EnsureConfigurationMutable();
return _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()
{
EnsureConfigurationMutable();
return _movementManager.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)
{
EnsureConfigurationMutable();
_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)
{
EnsureConfigurationMutable();
_body.LastMoveWasAutonomous = true;
return Movement.PerformMovement(new MovementStruct
{
Type = MovementType.StopRawCommand,
Motion = motion,
Params = parameters,
});
}
/// <summary>R4-V5: CONTACT transient-state bit (retail
/// <c>transient_state &amp; 1</c>) — the <see cref="MoveTo"/> manager's
/// UseTime tick gate reads exactly this bit (decomp §6a @307781; a
/// strict subset of the funnel's Contact+OnWalkable gate).</summary>
internal bool BodyInContact => _body.InContact;
/// <summary>Retail <c>SendPositionEvent</c> admission gate: both Contact
/// and OnWalkable must be present on the local physics body.</summary>
internal bool CanSendPositionEvent => _body.InContact && _body.OnWalkable;
/// <summary>R4-V5: 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>
public void SetBodyOrientation(Quaternion orientation)
{
EnsureConfigurationMutable();
_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)
{
EnsureConfigurationMutable();
_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)
{
EnsureConfigurationMutable();
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)
{
EnsureConfigurationMutable();
_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)
{
EnsurePublishedForRuntimeOperation();
_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)
{
EnsurePublishedForRuntimeOperation();
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)
{
EnsurePublishedForRuntimeOperation();
_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)
{
EnsurePublishedForRuntimeOperation();
if (!_lastSentInitialized)
return true;
bool cellChanged = _lastSentPosition.ObjCellId != currentPosition.ObjCellId;
if ((_lastSentTime + HeartbeatInterval) >= nowSeconds)
{
return cellChanged
|| !ApproxPlaneEqual(_lastSentContactPlane, currentContactPlane);
}
return cellChanged
|| !ApproxFrameEqual(_lastSentPosition.Frame, currentPosition.Frame);
}
// L.2a slice 1 (2026-05-12): centralized CellId mutation so the
// [cell-transit] probe fires from a single chokepoint. Both the
// server-snap path (SetPosition) and the per-frame resolver path
// route through here. When PhysicsDiagnostics.ProbeCellEnabled is
// off this collapses to a single bool-compare + assignment — zero
// logging cost.
private void UpdateCellId(
uint newCellId,
string reason,
bool publishRenderRoot = true)
{
if (newCellId != CellId && PhysicsDiagnostics.ProbeCellEnabled)
{
var pos = _body.Position;
Console.WriteLine(System.FormattableString.Invariant(
$"[cell-transit] 0x{CellId:X8} -> 0x{newCellId:X8} pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) reason={reason}"));
}
CellId = newCellId;
// Render root: CellGraph.CurrCell IS "the player's cell" — it roots the indoor render
// (GameWindow.OnRender). Set it HERE, the single PLAYER-only chokepoint for CellId
// (teleport / server snap @ SetPosition + per-frame resolver), NOT in the per-entity
// PhysicsEngine.ResolveWithTransition. That ran for EVERY entity, so a Holtburg NPC
// jump-looping near the cottage doorway clobbered the render root every tick → the render
// rooted at the NPC's tiny connector cell → only its ~8-tri shell drew, rest = GL clear
// color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 via [flap-cam]/[shell]).
if (publishRenderRoot)
_physics.UpdatePlayerCurrCell(newCellId);
}
/// <summary>
/// C5a (2026-08-05): this seed exists ONLY to place a controller directly
/// in test fixtures. Production placement never calls it — the retail
/// server-snap / teleport / enter-world path commits through
/// <see cref="PreparePositionForCommit"/> followed by
/// <see cref="ArmConstraintLeashAtCommittedPlacement"/> (or, for the
/// already-live case, canonical <c>SetPositionCore</c> callers inside
/// this class). <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).
/// </summary>
internal void SeedPlacementForTest(Vector3 pos, uint cellId, Vector3 cellLocal)
{
EnsurePublishedForRuntimeOperation();
SetPositionCore(
pos,
cellId,
cellLocal,
publishSharedState: true);
}
/// <summary>
/// Builds a new local controller's canonical body pose without publishing
/// the renderer's global current cell or mutating the incarnation-stable
/// PositionManager. Player-mode entry commits those shared edges only
/// after camera, shadow, and host preparation succeeds.
/// </summary>
internal void PreparePositionForCommit(
Vector3 pos,
uint cellId,
Vector3 cellLocal)
{
EnsureConfigurationMutable();
SetPositionCore(
pos,
cellId,
cellLocal,
publishSharedState: false);
}
/// <summary>
/// C3c-R1: arms the login-entry constraint leash from the Runtime
/// publication chain. The flip deleted the only login-path caller of
/// <see cref="RearmConstraintLeashAtCurrentPosition"/> (the App-side
/// <c>CommitPreparedPosition</c> call in the old
/// player-mode-entry commit, removed C5a — production placement now
/// arms exclusively here and at <see cref="SetPositionCore"/>'s
/// teleport_hook teardown+rearm); the dormant activation's final commit
/// (<c>RuntimeSetPositionState.TryApplyDormantLocalActivationFinalCommit</c>)
/// is the accepted-position event that replaces it — retail arms at
/// every accepted-position event (<c>SmartBox::HandleReceivedPosition</c>
/// 0x00453FD0). The final commit has already activated this controller
/// (<c>ActivateRuntimePublication</c>), so the published guard doubles
/// as a stale-caller check. Like the pre-flip commit path, no
/// UnConstrain teardown is needed: nothing can have armed the leash on
/// a controller whose <see cref="PositionManager"/> was created by its
/// own publication candidate.
/// </summary>
internal void ArmConstraintLeashAtCommittedPlacement()
{
EnsurePublishedForRuntimeOperation();
RearmConstraintLeashAtCurrentPosition();
}
/// <summary>
/// #167 (Campaign P P5): retail <c>SmartBox::HandleReceivedPosition</c>
/// (0x00453fd0) "Player, teleport-newer" branch re-arms the leash
/// immediately after <c>TeleportPlayer</c>'s teardown, anchored to the
/// RECEIVED position (here, the body's just-snapped current position).
/// Shared by the teleport path (after UnConstrain), the deferred
/// player-mode-entry commit path (formerly the App-side
/// <c>CommitPreparedPosition</c> caller, removed C5a; now
/// <see cref="ArmConstraintLeashAtCommittedPlacement"/>'s Runtime-owned
/// caller), which never ran UnConstrain because nothing could have armed
/// the leash before the controller had a <see cref="PositionManager"/>,
/// and the C3c first-entry placement commit
/// (<see cref="ArmConstraintLeashAtCommittedPlacement"/>).
/// docs/research/2026-07-30-constraint-leash-constants.md §2/§3.2.
/// </summary>
private void RearmConstraintLeashAtCurrentPosition()
{
if (PositionManager is not { } positionManager)
return;
AcDream.Core.Physics.Position anchor = _body.CellPosition;
positionManager.ConstrainTo(
anchor,
AcDream.Core.Physics.Motion.ConstraintDistance.GetStartConstraintDistance(anchor.ObjCellId),
AcDream.Core.Physics.Motion.ConstraintDistance.GetMaxConstraintDistance(anchor.ObjCellId));
}
private void SetPositionCore(
Vector3 pos,
uint cellId,
Vector3 cellLocal,
bool publishSharedState)
{
_body.SnapToCell(cellId, pos, cellLocal);
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
UpdateCellId(
_body.CellPosition.ObjCellId,
"teleport",
publishSharedState);
// Treat as grounded after a server-side position snap.
_body.TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable
| TransientStateFlags.Active;
_body.Velocity = Vector3.Zero;
// #145 Slice 7: idle the motion interpreter on a server snap / teleport arrival.
// SetPosition zeros the body velocity, but the motion interpreter still holds the
// PRE-teleport ForwardCommand (e.g. RunForward), so the next Update() would
// reconstruct that run vector via get_state_velocity and the player would sprint
// off in the old direction the instant input resumes. Resetting the forward
// command to Ready makes the player arrive at rest.
// R3-W6: retail's teleport idle is a FULL stop (StopCompletely
// 0x00527e40: resets fwd/sidestep/turn COMMANDS, zeroes velocity,
// enqueues the A9 jump-snapshot node) — not a bare DoMotion(Ready).
StopCompletelyAtPhysicsObjectBoundary();
_activeInputTurnCommand = null;
_activeInputTurnSpeed = 0f;
_activeInputTurnFromMouse = false;
_activeInputSidestepCommand = null;
_activeInputSidestepUsesRunHold = false;
_mouseLookActive = false;
_mouseTurnSamplePending = false;
_mouseTurnAdjustment = 0f;
_mouseMovementEventCandidate = false;
_mouseMovementEventPending = false;
// R5-V3 (#171): retail teleport_hook (0x00514ed0) — PositionManager::
// UnStick (@0x00514eee) right after the moveto cancel: a teleport
// tears down any active stick. (StopInterpolating has no armed
// acdream counterpart — no local-player InterpolationManager.)
// #167 (Campaign P P5): teleport_hook's UnConstrain (@0x00514f02) runs
// right after UnStick — previously a no-op because nothing armed the
// leash. Now that inbound positions arm it (both remote UpdatePosition
// and this player teleport/blip path), the teardown must actually run
// so a teleport doesn't inherit a stale leash from wherever the player
// was constrained before. Retail's "Player, teleport-newer" branch
// then immediately RE-arms the leash anchored to the new (received)
// position (SmartBox::HandleReceivedPosition 0x00453fd0) — velocity is
// already zeroed above by StopCompletelyAtPhysicsObjectBoundary.
if (publishSharedState)
{
PositionManager?.UnStick();
PositionManager?.UnConstrain();
RearmConstraintLeashAtCurrentPosition();
}
// 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>
/// C4 route 2: the controller-local half of a ForcePosition commit whose
/// body write already happened inside Runtime's canonical
/// <c>RuntimeSetPositionState.CommitCanonical</c> (retail
/// <c>CPhysicsObj::SetPositionSimple</c> @0x005162B0 with flags
/// <c>0x1012</c>, called from <c>SmartBox::BlipPlayer</c> @0x00453940,
/// acclient_2013_pseudo_c.txt:284276/92528). Resets the render-lerp
/// anchors and republishes the render-root cell — the same two
/// controller-local jobs the deleted <c>BlipPosition</c> performed after
/// its own (now-Runtime-owned) body snap.
///
/// Deliberately does NOT call <see cref="RearmConstraintLeashAtCurrentPosition"/>:
/// retail's FORCE_POSITION branch of <c>SmartBox::HandleReceivedPosition</c>
/// (@0x00453FD0) returns at 0x0045409D, before every
/// <c>CPhysicsObj::ConstrainTo</c> call (0x00454272/0x0045418A/
/// 0x004541EC) — the deleted <c>BlipPosition</c>'s re-arm here was an
/// unbacked deviation (docs/research/2026-08-03-c4-route-2-implementation-plan.md
/// §1b); this route retires it. Active motion, velocity, contact state,
/// and PositionManager stick relationships are untouched, matching
/// retail's BlipPlayer path exactly (Runtime's canonical commit — not
/// this method — is what already wrote the body).
/// </summary>
internal void CommitCanonicalForcePositionFrame()
{
EnsurePublishedForRuntimeOperation();
_prevPhysicsPos = _body.Position;
_currPhysicsPos = _body.Position;
UpdateCellId(_body.CellPosition.ObjCellId, "force-position");
}
/// <summary>
/// C4 route 3: the controller-local half of a portal-teleport commit
/// whose body write, cell install, and orientation already happened
/// inside Runtime's canonical <c>RuntimeSetPositionState.CommitCanonical</c>
/// (retail <c>CPhysicsObj::SetPositionSimple</c> @0x005162B0 with flags
/// <c>0x1012</c>, called from <c>SmartBox::TeleportPlayer</c> @0x00453910,
/// acclient_2013_pseudo_c.txt:284276/92528). Unlike
/// <see cref="CommitCanonicalForcePositionFrame"/> (which the FORCE_POSITION
/// branch's early return at @0x0045409D exempts from every
/// <c>ConstrainTo</c>), the local TELEPORT branch of
/// <c>SmartBox::HandleReceivedPosition</c> (@0x0045415F) DOES re-arm the
/// leash (@0x0045418A, anchored at the received destination) and DOES
/// zero velocity (@0x004541B4) — the inversion is deliberate, not a
/// missed exemption; see docs/research/2026-08-04-c4-route-3-contract.md
/// §2 Inversion A.
///
/// Performs every <see cref="SetPositionCore"/> duty NOT already covered
/// by the canonical commit (P1's duty map): render-lerp anchor reset,
/// <c>UpdateCellId</c> publication, the retail teleport_hook tail
/// (UnStick @0x00514eee / UnConstrain @0x00514f02 / re-arm @0x0045418A),
/// the retail StopCompletely full stop (0x00527e40, zeroes velocity and
/// resets fwd/sidestep/turn commands so input resumes at rest), the
/// input-edge/mouse press-edge reset, and the physics-clock reset for a
/// fresh <c>update_object</c> boundary. TransientState (Contact/OnWalkable/
/// Sliding/WaterContact) is deliberately NOT re-seeded here — the canonical
/// commit's <c>PhysicsObjUpdate.CommitSetPositionContactTransition</c>
/// already derives those bits from the SLIDE placement's OWN resolved
/// contact result, which is more retail-faithful than the old
/// <see cref="SetPositionCore"/>'s unconditional
/// <c>Contact|OnWalkable|Active</c> overwrite (that overwrite could mark a
/// portal arrival grounded even when the destination placement actually
/// resolved airborne). <c>Active</c> is untouched because a live in-world
/// local player already carries it; the canonical commit only sets it on
/// entry from a celless residence, which a portal arrival never is.
/// </summary>
/// <summary>
/// A4 review fix (2026-08-05): the two inversions this method embodies
/// are named, retail-cited facts on the classifier's
/// <c>RuntimeAuthoritativePositionRoute</c> — <c>ZeroVelocity</c> and
/// <c>ConstrainPhase.AfterPositionOperation</c> — and this method must
/// actually READ them rather than assume the LocalPlayer-teleport
/// branch's values are the only ones that will ever reach it. Both
/// parameters are the route's own facts, passed by the one caller
/// (<c>RuntimeAcceptedPositionDriveController.ReconcileAndAcknowledgePortal</c>);
/// a future classifier edit that changes either value now changes this
/// method's behaviour instead of silently disagreeing with it.
/// <para>
/// Coordinator note (round-3 closeout, 2026-08-05): the two parameters
/// are read, not hardcoded — but they are NOT equally load-bearing.
/// <paramref name="zeroVelocity"/> is read and applied, then
/// <see cref="StopCompletelyAtPhysicsObjectBoundary"/> runs
/// UNCONDITIONALLY on the very next line and zeroes velocity again — so
/// a <c>zeroVelocity: false</c> sabotage changes nothing observable
/// here; the field is proven read but not proven DISCRIMINATING.
/// <paramref name="rearmConstraintLeash"/> (<c>ConstrainAfterRouting</c>)
/// has no such unconditional fallback and IS the load-bearing one —
/// it alone decides whether the leash re-arms. Do not read this doc
/// comment as proving both fields equally; only the leash flag is.
/// </para>
/// </summary>
/// <param name="runTeleportHookTail">
/// N4 review fix (2026-08-05): before this parameter, the caller gated
/// the ENTIRE method call on <c>route.RunsTeleportHook</c> — but retail's
/// <c>SetPositionInternal</c> @0x00515330 does the frame/cell/stop/input-
/// reset/clock work UNCONDITIONALLY; only retail's <c>teleport_hook</c>
/// @0x00514ED0 (UnStick/UnConstrain/re-arm, mapped below) is itself
/// conditional on the hook phase. Gating the whole call meant a future
/// <see cref="AcDream.Runtime.Physics.RuntimeAuthoritativePositionRoute.TeleportHookPhase"/>
/// of <c>None</c> would silently skip the render-root <c>UpdateCellId</c>
/// publish too — the doorway-FLAP class. Today the portal route always
/// sets a non-None phase, so this parameter is always <c>true</c> in
/// production and there is no live behavior change; it exists so a
/// future <c>None</c> phase changes only the hook tail, not the frame
/// commit.
/// </param>
internal void CommitCanonicalTeleportFrame(
bool zeroVelocity,
bool rearmConstraintLeash,
bool runTeleportHookTail = true)
{
EnsurePublishedForRuntimeOperation();
_prevPhysicsPos = _body.Position;
_currPhysicsPos = _body.Position;
UpdateCellId(_body.CellPosition.ObjCellId, "teleport");
// Retail set_velocity(player, 0, 1) @0x004541B4 — route.ZeroVelocity.
if (zeroVelocity)
_body.Velocity = Vector3.Zero;
// Retail teleport idle is a FULL stop (StopCompletely 0x00527e40):
// resets fwd/sidestep/turn COMMANDS and zeroes velocity again so the
// motion interpreter cannot reconstruct the pre-teleport run vector
// the instant input resumes.
StopCompletelyAtPhysicsObjectBoundary();
_activeInputTurnCommand = null;
_activeInputTurnSpeed = 0f;
_activeInputTurnFromMouse = false;
_activeInputSidestepCommand = null;
_activeInputSidestepUsesRunHold = false;
_mouseLookActive = false;
_mouseTurnSamplePending = false;
_mouseTurnAdjustment = 0f;
_mouseMovementEventCandidate = false;
_mouseMovementEventPending = false;
// Retail teleport_hook @0x00514ed0 tears down any active stick/leash
// unconditionally, then HandleReceivedPosition's TELEPORT branch
// immediately re-arms the leash anchored to the just-committed
// position ONLY when ConstrainPhase is AfterPositionOperation
// (Inversion A — the opposite of
// CommitCanonicalForcePositionFrame's no-re-arm rule, itself
// route.ConstrainPhase.None for FORCE_POSITION). N4 review fix: this
// is the ONLY part of this method retail actually conditions on the
// teleport-hook phase — everything above runs unconditionally.
if (runTeleportHookTail)
{
PositionManager?.UnStick();
PositionManager?.UnConstrain();
if (rearmConstraintLeash)
RearmConstraintLeashAtCurrentPosition();
}
// 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 SetPositionCore's walking-straight-out-of-a-
// teleport behavior 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();
}
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)
{
EnsurePublishedForRuntimeOperation();
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,
// TS-23: OwnPvpFlags is None for every non-PK character
// (the default), so this OR is a no-op for the common
// case and bit-identical to the pre-P3 hardcoded value.
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide | OwnPvpFlags,
movingEntityId: LocalEntityId,
// TS-46: the player's own Setup sphere list, scaled by
// ObjectScale. Empty falls back to the 0.48/1.835
// reconstruction above.
sphereList: SphereList,
sphereScale: ObjectScale);
_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)
{
EnsurePublishedForRuntimeOperation();
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 externallyRequestedMovementEvent =
_externalMovementEventPending;
_externalMovementEventPending = false;
bool motionEdgeFired = false;
bool movementEventRequested =
externallyRequestedMovementEvent;
{
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 && !_jumpCharging && !_prevJumpHeld)
{
// Press edge — ClientCombatSystem::CommenceJump @0x0056AF90.
// 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).
//
// 2026-08-14 user retail gate: charge_jump @0x005281c0 has NO
// grounded check — it refuses only 0x49 (CanJump encumbrance)
// and 0x48 (fallen/crouch-family commands). Pressing jump while
// AIRBORNE charges the bar normally; retail's 0x24 "You can't
// jump while in the air" comes exclusively from the RELEASE
// path's jump_is_allowed (DoJump @0x0056B110 → CMotionInterp::
// jump), so charging through the air and releasing after
// landing executes a normal jump. This supersedes the CH
// user-gate round 1 item A press-edge 0x24 report — CommenceJump
// DOES carry an in-air fallback text, but with a faithful
// charge_jump (0/0x48/0x49 only) that arm is unreachable.
//
// A REFUSED charge reports and never begins the bar
// (jump_pending stays 0 in retail — no powerbar, no charge).
WeenieError chargeResult = _motion.ChargeJump();
if (chargeResult == WeenieError.None)
{
_jumpCharging = true;
_jumpExtent = 0f;
}
else
{
ReportJumpRefusal(chargeResult);
}
}
if (input.Jump && _jumpCharging)
{
// Spacebar held — accumulate charge (grounded OR airborne).
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 — fire jump (DoJump @0x0056B110). An
// airborne release refuses 0x24 through jump_is_allowed below.
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);
}
else
{
// Campaign CH slice CH2: a refused fire used to reset the
// charge state and return silently — the power bar drained
// and nothing happened, with no explanation. Report it
// exactly as ClientCombatSystem::DoJump @0x0056B110 does.
ReportJumpRefusal(jumpResult);
}
_jumpCharging = false;
_jumpExtent = 0f;
}
// Campaign CH user-gate round 2, item 1 (TEMPORARY): per-tick trace
// bracketing every frame where jump is (or was) held, so the probe
// can see the OnWalkable transition around a live jump attempt
// without spamming every ordinary frame.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeJumpEnabled
&& (input.Jump || _prevJumpHeld))
{
Console.WriteLine(
$"[jump-tick] input.Jump={input.Jump} prevJumpHeld={_prevJumpHeld} "
+ $"onWalkable={_body.OnWalkable} jumpCharging={_jumpCharging} "
+ $"jumpExtent={_jumpExtent:F2}");
}
_prevJumpHeld = input.Jump;
// ── 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);
bool captureQuantum = PlayerPhysicsQuantumCapture.IsEnabled;
uint captureCellBefore = CellId;
PlayerPhysicsBodyTraceSnapshot captureQuantumStart = captureQuantum
? PlayerPhysicsQuantumCapture.Snapshot(_body)
: default;
// 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;
}
// #265/#166 (2026-07-30, docs/research/2026-07-30-265-capture-bisect.md
// §4): retail CPhysicsObj::UpdatePositionInternal (0x00512C30) composes
// BOTH channels every quantum -- the root-motion Frame just written into
// pmDelta.Origin above (commanded locomotion) AND the integrated physics
// Velocity (residual momentum: jump arcs, landing slides) -- via the SAME
// candidate position that PhysicsBody.UpdatePhysicsInternal's Euler step
// (calc_friction + v*dt, below) and the ResolveWithTransition sweep both
// see. This block used to hand-zero Velocity.X/Y to EXACTLY zero on every
// single grounded tick whenever animation root motion drives the walk (the
// production graphical local-player path since R6) -- regardless of why
// the body was OnWalkable. That discarded any horizontal momentum a fall
// or collision had just left on the body (retail settles it via
// calc_friction over subsequent ticks; PhysicsBody.GroundNormal is now
// synced to the real contact-plane normal by PhysicsEngine so calc_friction
// has real slope data to act on) before the integrator ever ran -- a mover
// that landed on a walkable roof/slope with residual horizontal velocity
// had that velocity vanish the very next tick and never moved again. Root
// motion still fully owns COMMANDED locomotion (walking/running
// displacement comes from pmDelta.Origin above, not from Velocity), so
// this does not reintroduce command- or packet-cadence-derived grounded
// translation -- it only stops DESTROYING whatever Velocity already holds.
// Ordinary walking is unaffected: Velocity is already ~0 while grounded
// with no fall/collision in flight (nothing else writes it), so removing
// this zero is a no-op on that path -- see
// GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests
// (PhysicsBodyTests.cs) and Update_AnimationRootMotion_WalkSpeedUnaffected
// ByResidualVelocityFix (PlayerMovementControllerTests.cs).
//
// The headless/test-controller fallback below (no animation runtime --
// get_state_velocity's doc comment) is unchanged: it still directly
// writes the commanded state velocity into the body every grounded tick,
// exactly as before -- that model has no separate root-motion channel to
// compose with, so overwriting IS its correct per-tick behavior.
if (_body.OnWalkable && !hasAnimationRootMotion)
{
float savedWorldVz = _body.Velocity.Z;
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);
// #167 (Campaign P P5): push the read side of TS-35's
// jump_is_allowed gate. MotionInterpreter only has a PhysicsBody
// (no host reference), so the per-tick pump — the single owner of
// this write, right beside the taper call it mirrors — is the seam
// that keeps the stub property current for the local player.
_body.IsFullyConstrained = PositionManager?.IsFullyConstrained() ?? false;
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();
PlayerPhysicsBodyTraceSnapshot capturePreIntegration = captureQuantum
? PlayerPhysicsQuantumCapture.Snapshot(_body)
: default;
_body.UpdatePhysicsInternal(tickDt);
PlayerPhysicsBodyTraceSnapshot capturePostIntegration = captureQuantum
? PlayerPhysicsQuantumCapture.Snapshot(_body)
: default;
// 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.
// TS-23 (2026-07-30): OwnPvpFlags carries the real
// PK/PKLite/Impenetrable bits, decoded from the player's own
// PublicWeenieDesc._bitfield (default None — a no-op OR for
// every non-PK character, bit-identical to the pre-P3 value;
// non-PK pair still walks through other non-PK players,
// retail's default for ACE's character creation defaults).
moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
| OwnPvpFlags,
// 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,
// TS-46 (2026-07-30): the player's own Setup sphere list
// (0x02000001: (0,0,0.475) r=.48 + (0,0,1.350) r=.48),
// scaled by ObjectScale. Empty (unset/no Setup resolved yet)
// falls back to the sphereRadius/sphereHeight reconstruction
// above.
sphereList: SphereList,
sphereScale: ObjectScale);
// 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;
// Landing-bounce family (#265, 2026-07-30,
// docs/research/2026-07-30-landing-bounce-family.md): retail
// SetPositionInternal (0x00515330) commits contact PURELY from the
// transition's contact plane — no velocity-sign gate, no velocity
// zeroing. The former AD-25 gate (Velocity.Z<=0) existed because
// the resolver glued ascending movers to the ground; the
// check_contact seed (PhysicsEngine, retail 0x0050f5b0) now stops
// that at the source — an ascending jump finds no contact plane and
// goes airborne naturally. The former Velocity.Z hand-zero
// explicitly defeated handle_all_collisions' landing reflect;
// deleting it restores retail's landing bounce
// (v += -(v·n)(elasticity+1)·n, DEFAULT_ELASTICITY 0.05): the flat
// landing pop, the downhill bounce chain, and the uphill
// into-slope velocity kill all come from that reflect.
//
// Retail reaches SetPositionInternal only when the transition
// succeeded (CPhysicsObj::transition 0x00512DC0 discards the
// CTransition on find_valid_position failure) — contact state stays
// untouched on failure frames. fsf-wedge frames are OK frames
// (ValidateTransition manufactures the UP contact), so the fsf>1
// bleed below remains reachable exactly as before.
// The WHOLE commit is additionally gated on candidateMoved: retail
// UpdateObjectInternal (pc:283657) only runs the transition +
// SetPositionInternal when the integrated candidate MOVED — a
// standing body's contact state is never re-derived (a zero-move
// resolve cannot "find" a contact plane because no sweep runs; the
// old code masked this by echoing the caller's isOnGround back
// through the seeded oi flags). This same gate is what keeps a
// post-bleed no-move frame from re-zeroing the rebuilding gravity
// velocity (#182).
bool landedThisQuantum = false;
if (resolveResult.Ok && candidateMoved)
{
if (resolveResult.InContact)
_body.TransientState |= TransientStateFlags.Contact;
else
_body.TransientState &= ~TransientStateFlags.Contact;
_body.calc_acceleration(); // pc:283442 (post-contact-bit)
if (resolveResult.InContact && resolveResult.OnWalkable)
{
bool wasAirborne = !_body.OnWalkable;
_body.TransientState |= TransientStateFlags.OnWalkable;
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
{
_body.TransientState &= ~TransientStateFlags.OnWalkable;
}
_body.calc_acceleration(); // pc:283475/283490 (set_on_walkable tail)
// handle_all_collisions (0x005154FE): reflect the into-surface
// velocity (fsf≤1 — v += -(v·n)(elasticity+1)·n, the landing
// bounce) or ZERO it entirely (fsf>1 — THE airborne-stuck
// fix). The Stationary* bit round-trip is owned by the Core
// resolve writeback.
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);
if (captureQuantum)
{
PlayerPhysicsQuantumCapture.Log(
tickDt,
captureCellBefore,
CellId,
input,
capturePreIntegration.Position - captureQuantumStart.Position,
candidateMoved,
captureQuantumStart,
capturePreIntegration,
capturePostIntegration,
new PlayerPhysicsResolveTraceSnapshot(
resolveResult.Position,
resolveResult.CellId,
resolveResult.Ok,
resolveResult.IsOnGround,
resolveResult.InContact,
resolveResult.OnWalkable,
resolveResult.CollisionNormalValid,
resolveResult.CollisionNormal),
PlayerPhysicsQuantumCapture.Snapshot(_body));
}
}
// ── 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;
}
else if (_motion.RawState.ForwardCommand is (
MotionCommand.Crouch
or MotionCommand.Sitting
or MotionCommand.Sleeping))
{
outForwardCmd = _motion.RawState.ForwardCommand;
outForwardSpeed = _motion.RawState.ForwardSpeed;
}
// 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
|| externallyRequestedMovementEvent;
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;
}
}