acdream/src/AcDream.App/Input/PlayerMovementController.cs
Erik dccd700991 feat(physics): R5-V5 — MovementManager facade owns each entity's interp+moveto pair
Structural capstone of the R5 movement-manager arc; zero behavior change.

Retail MovementManager (acclient.h /* 3463 */, 16 bytes / four pointers)
gives every CPhysicsObj ONE owner for its motion_interpreter +
moveto_manager. acdream carried them as loose per-entity objects wired by
hand at three sites. This slice:

- New src/AcDream.Core/Physics/Motion/MovementManager.cs — owns
  MotionInterpreter + lazy MoveToManager (MakeMoveToManager 0x00524000 via
  a MoveToFactory closure, the acdream stand-in for the physics_obj/
  weenie_obj backpointers) + the relays with retail call shapes:
  PerformMovement 0x005240d0 (types 1-5 -> minterp, 6-9 ->
  MakeMoveToManager + moveto, (type-1)>8 -> 0x47), UseTime 0x005242f0
  (moveto only), HitGround 0x00524300 (minterp FIRST then moveto),
  HandleExitWorld 0x00524350 (minterp only), CancelMoveTo 0x005241b0,
  HandleUpdateTarget 0x00524790, IsMovingTo 0x00524260.
- RemoteMotion.Movement + PlayerMovementController.Movement hold the ONE
  facade; Motion/MoveTo become child views so the comment-dense call sites
  read unchanged. The three wiring sites (EnsureRemoteMotionBindings,
  EnterPlayerModeNow, the chase harness — same commit per the mirror rule)
  construct through MoveToFactory + MakeMoveToManager(), preserving the
  pre-facade eager timing (side-effect-free ctor = unobservable either way).
- Relay call sites repointed: both remote landing HitGround pairs + the
  player landing pair, despawn HandleExitWorld, TickRemoteMoveTo + the
  player Update UseTime, RouteServerMoveTo (takes the facade; routes via
  the retail PerformMovement dispatch), InstallSpeculativeTurnToTarget,
  host HandleUpdateTarget/InterruptCurrentMovement closures (retail
  CPhysicsObj::HandleUpdateTarget @0x00512bf0 fan head + the TS-36
  interrupt chain, now the literal facade relays).
- NOT absorbed per the slice spec: unpack_movement stays App
  (RouteServerMoveTo + UM heads; Core.Net types stay out of Core.Physics);
  TS-42 per-tick order untouched (R6); #170/#171 gate-passed machinery
  untouched. PerformMovement's set_active(1) head not re-asserted (spawn
  asserts Active; status quo — no new register row).
- Register: TS-41/TS-42 source wording freshened to the facade shape;
  AD-36 retire note corrected (facade half closed; residue = entities
  that never get a RemoteMotion). No new rows.
- Conformance: 15 new MovementManagerTests pin the dispatch table, lazy
  create, relay targets/order, null tolerance. Suite 4052 green; the
  183-case/funnel/moveto/chase/sticky suites UNMODIFIED (harness
  construction mirrors production, test bodies untouched).

Decomp: docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:12:19 +02:00

1331 lines
68 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.Physics;
namespace AcDream.App.Input;
/// <summary>
/// Input state for a single frame of player movement.
/// </summary>
public readonly record struct MovementInput(
bool Forward = false,
bool Backward = false,
bool StrafeLeft = false,
bool StrafeRight = false,
bool TurnLeft = false,
bool TurnRight = false,
bool Run = false,
float MouseDeltaX = 0f,
bool Jump = false);
/// <summary>
/// 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.
/// <summary>
/// Portal-space state for the player movement controller.
/// PortalSpace freezes all movement input while the server is moving the
/// player through a portal — resumed once the destination UpdatePosition
/// arrives and the player is snapped to the new location.
/// While in PortalSpace, Update returns immediately with a zero-movement
/// result so no WASD input or physics is processed.
/// </summary>
public enum PlayerState { InWorld, PortalSpace }
/// <summary>
/// Per-frame player movement controller. Reads input, drives the
/// ported PhysicsBody + MotionInterpreter, tracks motion state for
/// animation + server messages.
///
/// Architecture:
/// - PhysicsBody owns integration: gravity, friction, sub-stepping,
/// velocity clamping — all from the decompiled retail client.
/// - MotionInterpreter owns the motion state machine: walk/run/jump
/// validation, state tracking, speed constants from the retail dat.
/// - PhysicsEngine.Resolve is still used each frame to snap the player
/// to terrain/cell floor Z and detect ground contact.
/// </summary>
public sealed class PlayerMovementController
{
private readonly PhysicsEngine _physics;
private readonly PhysicsBody _body;
private readonly MotionInterpreter _motion;
private readonly PlayerWeenie _weenie;
public float MouseTurnSensitivity { get; set; } = 0.003f;
/// <summary>
/// Maximum Z increase per movement step before the move is rejected.
/// Retail's <c>step_up_height</c> for human characters is ~0.4 m (hip-
/// level). Setting this too high lets the player teleport up small
/// buildings via the step-up scan finding any walkable polygon within
/// reach (Bug 3 in L.2.3 testing — walking into a steep slope mounted
/// the building's flat top instead of sliding off the slope).
/// Authoritative source is the player's <c>Setup.StepUpHeight</c> set
/// in GameWindow.cs at world-entry time.
/// </summary>
public float StepUpHeight { get; set; } = 0.4f;
/// <summary>
/// L.2.3a (2026-04-29): how far below the foot the step-down probe
/// reaches when transitioning between surfaces. Retail's
/// <c>step_down_height</c> for human characters is ~0.4 m. With the
/// previous 4 cm hardcoded value, walking off the top of a stair onto
/// the ground 25 cm below produced a one-frame contact-plane gap — the
/// animation system briefly flickered to falling.
/// </summary>
public float StepDownHeight { get; set; } = 0.4f;
/// <summary>
/// Current portal-space state. Set to PortalSpace when the server sends
/// PlayerTeleport (0xF751); set back to InWorld once the destination
/// UpdatePosition arrives and the player is snapped to the new cell.
/// While in PortalSpace, Update returns immediately with a zero-movement
/// result so no WASD input or physics is processed.
/// </summary>
public PlayerState State { get; set; } = PlayerState.InWorld;
public float Yaw { get; set; }
public Vector3 Position => _body.Position;
public Vector3 RenderPosition => ComputeRenderPosition();
public uint CellId { get; private set; }
/// <summary>
/// Local-player entity id used to skip self-collision in the
/// airborne sweep. GameWindow updates this whenever the local
/// `+Acdream` entity (re)spawns. Default 0 = no filter (matches
/// retail's CObjCell::find_obj_collisions self-skip when the
/// caller's OBJECTINFO::object pointer is null). Without this the
/// sweep collides with its own ShadowEntry registered at
/// GameWindow.cs:2545 — see #42.
/// </summary>
public uint LocalEntityId { get; set; }
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;
// K-fix6 (2026-04-26): retail's PowerBar charge constant for jump is
// not legible in the named decomp (the divisor was clobbered in
// GetPowerBarLevel's FPU stack reordering at FUN_0056ade0). 2.0/s
// (full charge in 0.5s) feels matches retail muscle memory better
// than the previous 1.0/s — a tap gives a noticeable hop, half-hold
// a meaningful jump, full-hold the maximum extent. The vertical
// velocity formula itself (height × 19.6 → vz) is unchanged and
// matches retail byte-for-byte; only the time-to-fill is faster.
private const float JumpChargeRate = 2.0f;
// Airborne → grounded transition detection. Flipped on every frame where
// the body transitions from airborne to on-walkable; used by the GameWindow
// to drive the landing animation cycle.
private bool _wasAirborneLastFrame;
// Previous frame's motion commands for change detection (wire cadence).
private uint? _prevForwardCmd;
private uint? _prevSidestepCmd;
private uint? _prevTurnCmd;
private float? _prevForwardSpeed;
private bool _prevRunHold;
// R3-W6: previous frame's HELD-KEY state — the edge detector feeding
// retail's DoMotion/StopMotion/set_hold_run calls (retail's
// CommandInterpreter altitude: motion dispatch happens on key EDGES,
// never level-triggered per-frame).
private bool _prevForwardHeld;
private bool _prevBackwardHeld;
private bool _prevStrafeLeftHeld;
private bool _prevStrafeRightHeld;
private bool _prevTurnLeftHeld;
private bool _prevTurnRightHeld;
private bool _prevRunHeld;
// Heartbeat timer.
// Cadence is 1.0 sec to match holtburger's
// AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the retail trace
// (2026-05-01 motion-trace findings.md): retail sends ~1 Hz at rest,
// not the 5 Hz our pre-fix code used. Sending at 5 Hz was harmless
// but wasteful and probably looked like jitter to observers.
/// <summary>
/// 2026-05-16 — retail-faithful AP cadence. Matches retail's
/// CommandInterpreter::ShouldSendPositionEvent (acclient_2013_pseudo_c.txt
/// at address 0x006b45e0) which gates on either (a) position-or-cell
/// change since the last send, or (b) at-rest 1 sec heartbeat elapsed.
/// `time_between_position_events` constant at 0x006b3efb = 1.0 sec.
///
/// Old model: a 1 Hz idle / 10 Hz active flat accumulator. That
/// missed retail's per-frame-while-moving behaviour and forced the
/// four B.6 workarounds (arrival margin, re-send on arrival, AP
/// flush, retry flag) to compensate for the lag in ACE's server-side
/// WithinUseRadius poll. Replaced by diff-driven cadence below.
/// </summary>
public const float HeartbeatInterval = 1.0f; // retail 0x006b3efb
private System.Numerics.Vector3 _lastSentPos;
private uint _lastSentCellId;
private System.Numerics.Plane _lastSentContactPlane;
private float _lastSentTime;
private bool _lastSentInitialized;
private float _simTimeSeconds;
public bool HeartbeatDue { get; private set; }
/// <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;
// L.5 retail physics-tick gate (2026-04-30).
//
// Retail's CPhysicsObj::update_object subdivides per-frame dt into
// MinQuantum (1/30s) sized integration steps, SKIPPING entirely when
// accumulated dt is below MinQuantum. The retail debugger trace
// confirmed this: UpdatePhysicsInternal fires only ~61% as often as
// update_object — i.e., retail's effective physics tick rate is 30Hz
// even when the renderer runs at 60+Hz.
//
// Without this gate our acdream integrates at the full render rate
// (60+Hz), which compresses bounce-energy / gravity-tangent
// accumulation into half the time. Per-frame V grows ~2x faster than
// retail's. On a steep-slope tangent that produces the wedge: V grows
// tangent + huge while position reverts each frame, body locks in
// place. Retail's slower integration cadence (and larger per-tick
// position deltas) lets the body geometrically escape the tangent.
//
// Source: retail debugger trace 2026-04-30
// update_object = 40,960 calls
// UpdatePhysicsInternal = 25,087 calls (61%)
// ratio implies 39% of frames return early via the MinQuantum gate.
//
// ACE: PhysicsObj.UpdateObject (Physics.cs).
// Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950).
private float _physicsAccum;
private Vector3 _prevPhysicsPos;
private Vector3 _currPhysicsPos;
// ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ──
// The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase,
// 30° walk-while-turning band, one-shot walk/run decision, invented
// arrival epsilon — registers AD-26/AD-8-local) is DELETED; server
// MoveTos for the local player now run through the SAME verbatim
// MoveToManager remotes use (R4-V4), bound below by GameWindow's
// EnterPlayerModeNow beside the R3-W6 DefaultSink bind.
/// <summary>
/// R5-V5: retail <c>CPhysicsObj::movement_manager</c> (acclient.h
/// <c>/* 3463 */</c>) — the ONE owner of this controller's
/// <see cref="Motion"/> + <see cref="MoveTo"/> pair. Constructed in the
/// ctor around the interp; the MoveToManager side arrives via
/// <c>MoveToFactory</c> + <c>MakeMoveToManager()</c> in
/// <c>GameWindow.EnterPlayerModeNow</c> (the same facade shape
/// <c>EnsureRemoteMotionBindings</c> gives remotes). <see cref="Update"/>
/// ticks <c>UseTime()</c> (0x005242f0) at the slot the deleted
/// <c>DriveServerAutoWalk</c> occupied and relays <c>HitGround()</c>
/// (0x00524300, minterp first then moveto).
/// </summary>
public AcDream.Core.Physics.Motion.MovementManager Movement { get; }
/// <summary>
/// R4-V5: the local player's verbatim retail <c>MoveToManager</c>
/// (decomp 0x00529010-0x0052a987), constructed + seam-bound by
/// <c>GameWindow.EnterPlayerModeNow</c> against this controller's
/// <see cref="Motion"/>/body/Yaw (the same wiring shape
/// <c>EnsureRemoteMotionBindings</c> uses for remotes). GameWindow
/// routes inbound mt 6-9 movement events through
/// <see cref="AcDream.Core.Physics.Motion.MovementManager.PerformMovement"/>.
/// User input cancels a moveto through the retail chain: key edge →
/// DoMotion (ctor-default params, CancelMoveTo bit set) →
/// <see cref="MotionInterpreter.InterruptCurrentMovement"/> →
/// <c>CancelMoveTo(ActionCancelled)</c> (register TS-36 retired).
/// R5-V5: a view of <see cref="Movement"/>'s moveto child; the setter is
/// sugar over the facade's factory path (kept for the
/// PlayerMoveToCutoverTests rig and any pre-facade bind shape).
/// </summary>
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo
{
get => Movement.MoveTo;
set
{
var mtm = value ?? throw new ArgumentNullException(nameof(value));
Movement.MoveToFactory = () => mtm;
Movement.MakeMoveToManager();
}
}
/// <summary>
/// R5-V3 (#171): the player's <c>PositionManager</c> facade (retail
/// <c>CPhysicsObj::position_manager</c> — owned by the player's
/// <c>EntityPhysicsHost</c>, handed here by <c>EnterPlayerModeNow</c>).
/// <see cref="Update"/> drives it at the two retail per-tick points:
/// <c>AdjustOffset</c> inside the physics-tick block (retail
/// <c>UpdatePositionInternal</c> @0x00512d0e, BEFORE
/// <c>UpdatePhysicsInternal</c> so the sticky steer is part of the swept
/// motion) and <c>UseTime</c> after the completed-motions sweep (retail
/// <c>UpdateObjectInternal</c> tail @0x005159b3 — the sticky 1 s lease
/// watchdog). <see cref="SetPosition(Vector3, uint, Vector3)"/> tears any
/// stick down (retail <c>teleport_hook</c> @0x00514eee).
/// </summary>
public AcDream.Core.Physics.Motion.PositionManager? PositionManager { get; set; }
public PlayerMovementController(PhysicsEngine physics)
{
_physics = physics;
_body = new PhysicsBody
{
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
// Default skills — tuned toward mid-retail feel. Real characters'
// skills come from PlayerDescription (0xF7B0/0x0013) — GameWindow
// pushes them via SetCharacterSkills once the controller exists
// (K-fix7; PD arrives at login before auto-entry). These env-var
// defaults only cover tests / pre-PD frames:
// ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL
// K-fix6 (2026-04-26): bumped default jump skill from 200 → 300.
// Retail formula: height = (skill/(skill+1300))*22.2 + 0.05 (extent=1):
// skill=200 → 3.01m max (felt too low — user complaint)
// skill=300 → 4.21m max (closer to a typical retail mid-tier
// character's "I can clear that fence" hop)
// Until #7 ships and PlayerDescription gives us the server's real
// skill, this default is the right "feels like retail" baseline.
int runSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_RUN_SKILL"), out var rs) ? rs : 200;
int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300;
_weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill);
_motion = new MotionInterpreter(_body, _weenie);
// R5-V5: the MovementManager facade owns the interp from birth
// (retail CPhysicsObj::movement_manager); the moveto child binds
// later via MoveToFactory (EnterPlayerModeNow / the test rigs).
Movement = new AcDream.Core.Physics.Motion.MovementManager(_motion);
// R3-W4 (A3): the local player's movement is input-driven —
// movement_is_autonomous true so apply_current_movement's dual
// dispatch routes apply_raw_movement (IsThePlayer && autonomous).
// R3-W6 refines this per-motion (server-controlled MoveTo clears it).
_body.LastMoveWasAutonomous = true;
}
public void SetCharacterSkills(int runSkill, int jumpSkill)
{
_weenie.SetSkills(runSkill, jumpSkill);
}
/// <summary>
/// R3-W2 (r3-port-plan.md §4): the player's <see cref="MotionInterpreter"/>
/// — GameWindow binds the player sequencer's MotionDone seam to it so the
/// pending_motions queue pops in step with animation completion, same
/// path remotes use. R3-W6 widens this into the full local-player
/// unification.
/// </summary>
internal MotionInterpreter Motion => _motion;
/// <summary>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>R4-V5: body orientation for the <see cref="MoveTo"/>
/// manager's position seam (re-derived from <see cref="Yaw"/> every
/// Update — heading reads/writes go through Yaw, not this).</summary>
internal Quaternion BodyOrientation => _body.Orientation;
/// <summary>R4-V5 wedge fix — the P1 unpack store
/// (<c>CPhysics::SetObjectMovement</c> @00509730:
/// <c>last_move_was_autonomous = arg7</c>, written on the unpack path
/// for every applied movement event). GameWindow's local-player 0xF74C
/// branch stores the wire autonomous byte here (always false — the P1
/// gate drops autonomous echoes); the speculative use install stores
/// false too (it models the wire mt-6 ACE sends moments later). Routes
/// the per-tick pump's A3 dual dispatch to the INTERPRETED branch
/// during a server moveto so apply_raw can't clobber the manager's
/// dispatched motions with the idle raw state.</summary>
internal void SetLastMoveWasAutonomous(bool autonomous)
=> _body.LastMoveWasAutonomous = autonomous;
/// <summary>
/// Wire the player's AnimationSequencer current cycle velocity into
/// <see cref="MotionInterpreter.GetCycleVelocity"/>. When attached,
/// <c>get_state_velocity</c> uses <c>MotionData.Velocity * speedMod</c>
/// as the primary forward-axis drive, keeping the body's world velocity
/// locked to the animation's baked-in root-motion velocity.
///
/// <para>
/// Without this accessor, the decompiled constant path
/// (<c>RunAnimSpeed * ForwardSpeed</c>) is used — matches retail only
/// when the character's MotionTable happens to bake Velocity=4.0 on
/// RunForward, which is true for Humanoid but not for arbitrary
/// creatures. See <see cref="MotionInterpreter.GetCycleVelocity"/>
/// for the full rationale.
/// </para>
///
/// <para>
/// Called once from <c>GameWindow.CreateAnimatedEntity</c> after the
/// player's <c>AnimatedEntity.Sequencer</c> is constructed.
/// </para>
/// </summary>
public void AttachCycleVelocityAccessor(Func<Vector3> accessor)
{
if (accessor is null) throw new ArgumentNullException(nameof(accessor));
_motion.GetCycleVelocity = accessor;
}
/// <summary>
/// 2026-05-16. Called by the network outbound layer after every
/// AutonomousPosition or MoveToState that carries the player's
/// position. Resets the diff-driven heartbeat clock so the next
/// `HeartbeatDue` evaluation requires either a fresh state change
/// (cell, contact-plane, or frame) OR another full HeartbeatInterval.
/// Mirrors retail's SendPositionEvent at
/// <c>acclient_2013_pseudo_c.txt:700345-700348</c> which updates
/// `last_sent_position`, `last_sent_position_time`, AND
/// `last_sent_contact_plane` after every send.
/// </summary>
public void NotePositionSent(System.Numerics.Vector3 worldPos,
uint cellId,
System.Numerics.Plane contactPlane,
float nowSeconds)
{
_lastSentPos = worldPos;
_lastSentCellId = cellId;
_lastSentContactPlane = contactPlane;
_lastSentTime = nowSeconds;
_lastSentInitialized = true;
}
// L.2a slice 1 (2026-05-12): centralized CellId mutation so the
// [cell-transit] probe fires from a single chokepoint. Both the
// server-snap path (SetPosition) and the per-frame resolver path
// route through here. When PhysicsDiagnostics.ProbeCellEnabled is
// off this collapses to a single bool-compare + assignment — zero
// logging cost.
private void UpdateCellId(uint newCellId, string reason)
{
if (newCellId != CellId && PhysicsDiagnostics.ProbeCellEnabled)
{
var pos = _body.Position;
Console.WriteLine(System.FormattableString.Invariant(
$"[cell-transit] 0x{CellId:X8} -> 0x{newCellId:X8} pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) reason={reason}"));
}
CellId = newCellId;
// Render root: CellGraph.CurrCell IS "the player's cell" — it roots the indoor render
// (GameWindow.OnRender). Set it HERE, the single PLAYER-only chokepoint for CellId
// (teleport / server snap @ SetPosition + per-frame resolver), NOT in the per-entity
// PhysicsEngine.ResolveWithTransition. That ran for EVERY entity, so a Holtburg NPC
// jump-looping near the cottage doorway clobbered the render root every tick → the render
// rooted at the NPC's tiny connector cell → only its ~8-tri shell drew, rest = GL clear
// color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 via [flap-cam]/[shell]).
_physics.UpdatePlayerCurrCell(newCellId);
}
public void SetPosition(Vector3 pos, uint cellId)
// #145: tests + legacy callers run in the world==block-local frame (no
// streaming center), so the cell-local seed IS the world position. This
// makes the carried anchor (body.Position CellPosition.Origin) == (0,0,0),
// identical to the legacy Zero terrain-origin fallback → behaviour unchanged.
=> SetPosition(pos, cellId, pos);
/// <summary>
/// Server-snap / teleport placement. <paramref name="cellLocal"/> is the
/// LANDBLOCK-relative position (the wire's local, or world landblock origin)
/// which seeds the body's cell-relative <c>CellPosition</c> WITHOUT any streaming
/// center (#145). A teleport is a large jump, so this snaps the cell frame
/// directly via <c>SnapToCell</c> rather than delta-syncing through the setter.
/// </summary>
public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
{
_body.SnapToCell(cellId, pos, cellLocal);
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
UpdateCellId(cellId, "teleport");
// Treat as grounded after a server-side position snap.
_body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
_body.Velocity = Vector3.Zero;
// #145 Slice 7: idle the motion interpreter on a server snap / teleport arrival.
// SetPosition zeros the body velocity, but the motion interpreter still holds the
// PRE-teleport ForwardCommand (e.g. RunForward), so the next Update() would
// reconstruct that run vector via get_state_velocity and the player would sprint
// off in the old direction the instant input resumes. Resetting the forward
// command to Ready makes the player arrive at rest.
// R3-W6: retail's teleport idle is a FULL stop (StopCompletely
// 0x00527e40: resets fwd/sidestep/turn COMMANDS, zeroes velocity,
// enqueues the A9 jump-snapshot node) — not a bare DoMotion(Ready).
_motion.StopCompletely();
// R5-V3 (#171): retail teleport_hook (0x00514ed0) — PositionManager::
// UnStick (@0x00514eee) right after the moveto cancel: a teleport
// tears down any active stick. (StopInterpolating/UnConstrain have no
// armed acdream counterparts — no local-player InterpolationManager,
// constraint leash unarmed per #167.)
PositionManager?.UnStick();
// Reset the edge tracker: the stop wiped the motion state, so keys
// still physically held must re-fire as press edges on the next
// Update (matches the pre-W6 level-triggered behavior of walking
// straight out of a teleport while W stays held).
_prevForwardHeld = false;
_prevBackwardHeld = false;
_prevStrafeLeftHeld = false;
_prevStrafeRightHeld = false;
_prevTurnLeftHeld = false;
_prevTurnRightHeld = false;
_prevRunHeld = false;
// Reset physics clock so any subsequent update_object calls start fresh.
_body.LastUpdateTime = 0.0;
_physicsAccum = 0f;
}
private Vector3 ComputeRenderPosition()
{
float alpha = Math.Clamp(_physicsAccum / PhysicsBody.MinQuantum, 0f, 1f);
return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha);
}
public MovementResult Update(float dt, MovementInput input)
{
_simTimeSeconds += dt;
// R4-V5: tick the verbatim MoveToManager at the slot the deleted
// DriveServerAutoWalk occupied — after inbound wire routing, before
// the input-driven motion sections. UseTime runs the retail per-tick
// moveto drivers (HandleMoveToPosition aux-turn steering + arrival +
// fail-distance / HandleTurnToHeading); the motions it dispatches
// land in InterpretedState through _DoMotion → DoInterpretedMotion,
// and sections 1/2 below turn that state into Yaw rotation + body
// velocity — the same per-frame apply user input gets. No
// synthesized input exists, so the outbound-packet pipeline sees no
// user motion and never builds a MoveToState mid-moveto (the #75
// invariant, now by construction). Provisional tick placement until
// R6 ports retail's UpdateObjectInternal ordering (r4-port-plan.md
// §3 placement decision). R5-V5: the facade relay
// (MovementManager::UseTime 0x005242f0 — moveto side only).
Movement.UseTime();
// R4-V5 wedge fix (2026-07-03 live door bug), retail's per-tick
// completion slot: CPartArray::HandleMovement — a tailcall chain to
// MotionTableManager::CheckForCompletedMotions (0x00517d60 →
// 0x0051bfd0) — runs every tick right AFTER MovementManager::UseTime
// in UpdateObjectInternal (@005159a4). It flushes pending_animations
// entries whose animations already ran out so their AnimationDone →
// MotionDone pops land each tick even when no op fired that frame.
// (apply_current_movement is NOT part of the per-tick order — its
// retail callers are all event-driven: hold-key toggles, HitGround/
// LeaveGround, ReportExhaustion.) Full tick ordering remains R6
// scope; the companion fix is StopCompletely's previously-missing
// StopCompletely_Internal animation dispatch (see
// MotionInterpreter.StopCompletely), whose completable entry this
// slot drains.
_motion.CheckForCompletedMotions?.Invoke();
// Portal-space guard: while teleporting, no input is processed and
// no physics is resolved. Return a zero-movement result so the caller
// can detect the frozen state (MotionStateChanged = false, no commands).
if (State == PlayerState.PortalSpace)
{
return new MovementResult(
Position: Position,
RenderPosition: RenderPosition,
CellId: CellId,
IsOnGround: _body.OnWalkable,
MotionStateChanged: false,
ForwardCommand: null,
SidestepCommand: null,
TurnCommand: null,
ForwardSpeed: null,
SidestepSpeed: null,
TurnSpeed: null);
}
// ── R3-W6: EDGE-DRIVEN retail input (replaces the D6.2 per-frame
// RawMotionState rebuild — the level-triggered substitute for
// retail's edge-triggered CommandInterpreter). Each key EDGE fires
// DoMotion/StopMotion (0x00528d20/0x00528530) which mutate the
// interpreter's OWN RawState via ApplyMotion/RemoveMotion and
// dispatch through the funnel + DefaultSink — the SAME pipeline
// remotes use. The Shift edge is retail's set_hold_run
// (0x00528b70, caller 0x006b33ca shape: interrupt=true). RAW speeds
// stay 1.0 (apply_run_to_command applies the run rate — pre-scaling
// would double-scale; TS-22 unchanged). R4-V5: the ctor-default
// params carry retail's 0x1EE0F bitfield whose CancelMoveTo bit
// (0x8000) is SET — any key edge mid-moveto fires
// InterruptCurrentMovement → MoveTo.CancelMoveTo(ActionCancelled),
// the retail user-input cancel chain (TS-36 retired; set_hold_run's
// interrupt:true is the same chain for the Shift edge).
bool motionEdgeFired = false;
{
var p = new AcDream.Core.Physics.Motion.MovementParameters();
// Shift/run edge FIRST — retail's set_hold_run re-applies
// movement, so the hold-key state is current before any
// same-frame directional dispatch.
if (input.Run != _prevRunHeld)
{
_motion.set_hold_run(input.Run, interrupt: true);
motionEdgeFired = true;
}
// Forward channel (W / S share one raw channel — retail
// RawMotionState.ApplyMotion's forward-class switch). W wins on
// a same-frame double-press; releasing one key while the
// opposite is still held re-issues the survivor (equivalent to
// the old level-triggered build, which always reflected the
// currently-held set).
if (input.Forward && !_prevForwardHeld)
{ _motion.DoMotion(MotionCommand.WalkForward, p); motionEdgeFired = true; }
else if (input.Backward && !_prevBackwardHeld && !input.Forward)
{ _motion.DoMotion(MotionCommand.WalkBackward, p); motionEdgeFired = true; }
if (!input.Forward && _prevForwardHeld)
{
if (input.Backward)
_motion.DoMotion(MotionCommand.WalkBackward, p);
else
_motion.StopMotion(MotionCommand.WalkForward, p);
motionEdgeFired = true;
}
else if (!input.Backward && _prevBackwardHeld && !input.Forward)
{ _motion.StopMotion(MotionCommand.WalkBackward, p); motionEdgeFired = true; }
// Sidestep channel.
if (input.StrafeRight && !_prevStrafeRightHeld)
{ _motion.DoMotion(MotionCommand.SideStepRight, p); motionEdgeFired = true; }
else if (input.StrafeLeft && !_prevStrafeLeftHeld)
{ _motion.DoMotion(MotionCommand.SideStepLeft, p); motionEdgeFired = true; }
else if (!input.StrafeRight && !input.StrafeLeft
&& (_prevStrafeRightHeld || _prevStrafeLeftHeld))
{ _motion.StopMotion(MotionCommand.SideStepRight, p); motionEdgeFired = true; }
// Turn channel.
if (input.TurnRight && !_prevTurnRightHeld)
{ _motion.DoMotion(MotionCommand.TurnRight, p); motionEdgeFired = true; }
else if (input.TurnLeft && !_prevTurnLeftHeld)
{ _motion.DoMotion(MotionCommand.TurnLeft, p); motionEdgeFired = true; }
else if (!input.TurnRight && !input.TurnLeft
&& (_prevTurnRightHeld || _prevTurnLeftHeld))
{ _motion.StopMotion(MotionCommand.TurnRight, p); motionEdgeFired = true; }
// Retail stores last_move_was_autonomous = 1 at the CPhysicsObj
// INPUT boundary — CPhysicsObj::DoMotion @00510030 /
// CPhysicsObj::StopMotion @005100e0 (per call, before routing
// to MovementManager) and CommandInterpreter::
// TakeControlFromServer @006b32f4. The MoveToManager's
// _DoMotion goes through CMotionInterp internals and NEVER
// touches the flag; the wire unpack path stores the wire byte
// (P1, 00509730). This edge block IS acdream's input boundary,
// so an edge firing is exactly retail's store site. The flag
// routes the per-tick pump's A3 dual dispatch (raw for
// input-driven motion, interpreted for server/manager-driven).
if (motionEdgeFired)
_body.LastMoveWasAutonomous = true;
}
_prevForwardHeld = input.Forward;
_prevBackwardHeld = input.Backward;
_prevStrafeLeftHeld = input.StrafeLeft;
_prevStrafeRightHeld = input.StrafeRight;
_prevTurnLeftHeld = input.TurnLeft;
_prevTurnRightHeld = input.TurnRight;
_prevRunHeld = input.Run;
// ── 1. Apply turning from keyboard + mouse ────────────────────────────
// 2026-05-16 — retail-faithful turn rate.
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
// - CMotionInterp::apply_run_to_command 0x00527be0
// multiplies turn_speed by run_turn_factor (1.5) under
// HoldKey.Run on TurnRight/TurnLeft commands.
// - Base rate ±π/2 rad/s comes from add_motion 0x005224b0
// with HasOmega-cleared MotionData fallback.
// Effective: walking ≈ 90°/s, running ≈ 135°/s.
// Previously: WalkAnimSpeed*0.5 ≈ 89.4°/s — coincidentally
// close to retail walking but no run differentiation.
// D6.2: local keyboard turn rate now comes from the interpreted turn
// state (adjust_motion normalized TurnLeft→TurnRight with sign +
// apply_run_to_command ×RunTurnFactor when running). omega.Z =
// BaseTurnRateRadPerSec × turn_speed — the same π/2 formula the remote
// path uses (GameWindow ObservedOmega seed). Numerically identical to the
// former TurnRateFor(input.Run); AP-9 (π/2 base rate) is unchanged.
// R4-V5: runs during a moveto too — the manager's aux-turn steering
// and TurnToHeading nodes dispatch TurnRight/TurnLeft through
// _DoMotion into the SAME interpreted turn state, so this one
// integrator rotates the player for both input and moveto turns.
// Mouse turn stays a direct Yaw delta (deliberately generates no
// turn command — avoids MoveToState spam from mouse jitter).
if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
{
Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver
* _motion.InterpretedState.TurnSpeed * dt;
}
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
// Wrap yaw to [-PI, PI] so it doesn't grow unbounded.
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
// Sync the body's orientation quaternion with our Yaw (rotation about Z).
// Convention: Yaw=0 faces +X. Local body +Y is "forward", so we rotate
// by (Yaw - PI/2) about Z to map local +Y → world (cos Yaw, sin Yaw, 0).
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
// ── 2. Set velocity via MotionInterpreter state machine ───────────────
// R4-V5: runs during a moveto too — the manager's BeginMoveForward
// dispatched WalkForward/RunForward through _DoMotion into the SAME
// interpreted state, so this one per-frame apply turns it into body
// velocity exactly like input-driven motion (the #75 "two writers"
// hazard is gone: there is only this writer now).
// D6.2: the forward/sidestep command determination + DoMotion +
// DoInterpretedMotion moved into the apply_raw_movement call above, so
// the interpreted state (and thus get_state_velocity) is already
// populated + normalized for all directions.
// Only replace velocity with motion interpreter output when grounded.
// While airborne, the physics body's integrated velocity (from LeaveGround)
// persists — gravity pulls Z down, horizontal momentum is preserved.
// Retail AC works this way: you maintain momentum in the air.
if (_body.OnWalkable)
{
float savedWorldVz = _body.Velocity.Z;
// D6.2: velocity for ALL directions comes from the normalized
// interpreted state — backward (WalkForward ×-0.65) and strafe-left
// (SideStepRight ×-1×1.248) are handled inside get_state_velocity now
// that adjust_motion ran above. The hand-mirrored formulas are gone
// (register TS-22 retired).
var stateVel = _motion.get_state_velocity();
// R4-V5 wedge fix: PRESERVE the flag — retail's
// last_move_was_autonomous is stored at EVENT boundaries only
// (input DoMotion/StopMotion edges above, the P1 wire-unpack
// store, LeaveGround's own autonomous=1 write), never re-stamped
// by the per-tick velocity write. V5's first cut stamped it per
// frame here, which mis-routed the pump's A3 dual dispatch on
// event-transition frames (apply_raw clobbering a just-armed
// moveto's dispatched motion with the raw Ready state).
_body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz),
autonomous: _body.LastMoveWasAutonomous);
}
// ── 3. Jump (charged) ─────────────────────────────────────────────────
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
// Release to execute: jump(extent) validates + sets JumpExtent,
// then LeaveGround() applies the scaled velocity via GetLeaveGroundVelocity.
float? outJumpExtent = null;
Vector3? outJumpVelocity = null;
if (input.Jump && _body.OnWalkable)
{
// Spacebar held and on the ground — accumulate charge.
if (!_jumpCharging)
{
_jumpCharging = true;
_jumpExtent = 0f;
// R3-W6 (map R1): retail's charge_jump fires at charge
// START (SmartBox/input boundary 0x0056afac) — the ONLY
// place StandingLongJump arms (grounded + Ready + no
// sidestep/turn). Never called by production code before
// this line despite the W3 port.
_motion.ChargeJump();
}
_jumpExtent = MathF.Min(_jumpExtent + dt * JumpChargeRate, 1.0f);
}
else if (_jumpCharging)
{
// Spacebar released (or left ground during charge) — fire jump.
var jumpResult = _motion.jump(_jumpExtent);
if (jumpResult == WeenieError.None)
{
// R3-W4 (J7): the manual LeaveGround call is DELETED —
// jump() clears OnWalkable, and the SAME frame's
// grounded→airborne edge (section 5's transition detection)
// fires _motion.LeaveGround() exactly where retail's
// transition sweep does. Capture jump_v_z NOW: the edge's
// LeaveGround resets JumpExtent to 0 later this frame.
float jumpVz = _motion.GetJumpVZ();
outJumpExtent = _jumpExtent;
// D6.2: get_state_velocity() is now correct for all directions
// (apply_raw_movement normalized backward/strafe above), so the
// jump-launch velocity is get_state_velocity() + the vertical jump
// component. Retires the duplicated hand-mirrored formulas that
// existed only until adjust_motion was ported (register TS-22).
var jumpVel = _motion.get_state_velocity();
outJumpVelocity = new Vector3(jumpVel.X, jumpVel.Y, jumpVz);
// Local-prediction fix: LeaveGround above wrote (0, 0, jumpZ)
// to the body for backward/strafe-left (same get_state_velocity
// zero-for-non-canonical-motion bug as on the wire side).
// Push the corrected body-local velocity back so the local
// client renders the jump in the same world direction the
// server is broadcasting to observers. Same vector we just
// sent in JumpAction — local + remote stay in sync.
_body.set_local_velocity(outJumpVelocity.Value, autonomous: true);
}
_jumpCharging = false;
_jumpExtent = 0f;
}
// ── 4. Integrate physics (gravity, friction, sub-stepping) ────────────
//
// L.5 retail-physics-tick gate (2026-04-30): retail's CPhysicsObj::
// update_object skips integration when accumulated dt is below
// MinQuantum (1/30 s). Effective physics rate is 30 Hz even at 60+ Hz
// render. We accumulate per-frame dt and only integrate (with the
// accumulated dt) when the threshold is reached. See _physicsAccum
// declaration for the full retail trace evidence.
var preIntegratePos = _body.Position;
bool physicsTickRan = false;
Vector3 oldTickEndPos = _currPhysicsPos;
_physicsAccum += dt;
if (_physicsAccum > PhysicsBody.HugeQuantum)
{
// Stale frame (debugger break, GC pause). Discard accumulated dt.
_physicsAccum = 0f;
_prevPhysicsPos = _body.Position;
_currPhysicsPos = _body.Position;
}
else if (_physicsAccum >= PhysicsBody.MinQuantum)
{
// Integrate accumulated dt, clamped to MaxQuantum so a long
// pause doesn't produce one giant integration step.
float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum);
// R5-V3 (#171): retail UpdatePositionInternal (0x00512c30) —
// PositionManager::adjust_offset (@0x00512d0e) composes the
// sticky steer into this quantum's motion BEFORE
// UpdatePhysicsInternal, so the transition sweep below resolves
// it like any other movement (preIntegratePos was captured
// above). The delta's Origin is mover-LOCAL (rotate out by the
// body orientation); the heading is RELATIVE and writes Yaw —
// the authoritative facing the body quaternion is re-derived
// from every frame (a quaternion-only write would be clobbered).
// No-op while nothing is stuck (untouched delta frame).
if (PositionManager is { } ppm)
{
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
ppm.AdjustOffset(pmDelta, tickDt);
if (pmDelta.Origin != Vector3.Zero)
_body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation);
if (!pmDelta.Orientation.IsIdentity)
Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(
AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw)
+ pmDelta.GetHeading());
}
_body.calc_acceleration();
_body.UpdatePhysicsInternal(tickDt);
_physicsAccum -= tickDt;
physicsTickRan = true;
}
// Else: dt below MinQuantum threshold — skip integration. Position
// and velocity remain unchanged; Resolve below runs as a zero-distance
// sphere sweep (no collision possible) and the rest of the frame
// (motion commands, animation, return) runs normally.
var postIntegratePos = _body.Position;
// ── 5. Collision resolution via CTransition sphere-sweep ─────────────
// The Transition system subdivides the movement from pre→post into
// sphere-radius steps, testing terrain collision at each step.
// Falls back to simple Z-snap if transition fails.
var resolveResult = _physics.ResolveWithTransition(
preIntegratePos, postIntegratePos, CellId,
sphereRadius: 0.48f, // human player radius from Setup
sphereHeight: 1.2f, // human player height from Setup
stepUpHeight: StepUpHeight,
stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight
isOnGround: _body.OnWalkable,
body: _body, // persist ContactPlane across frames for slope tracking
// L.2c 2026-04-30: retail PhysicsGlobals.DefaultState includes
// EdgeSlide, and PhysicsObj.get_object_info copies that bit into
// OBJECTINFO. Keep it explicit here so edge/cliff handling runs
// under the same flag profile as retail player movement.
//
// Commit C 2026-04-29 — local player is always IsPlayer.
// The PK/PKLite/Impenetrable bits come from PlayerDescription's
// PlayerKillerStatus property; not yet parsed (non-PK pair → walks
// through other non-PK players, which is retail's default for
// ACE's character creation defaults too).
moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
// Fix #42: skip self in FindObjCollisions. Wired by GameWindow
// when the local player entity spawns (or stays 0 in tests, in
// which case there's no registered ShadowEntry to collide with
// anyway).
movingEntityId: LocalEntityId);
// L.4-diag (2026-04-30): trace position transitions so we can see
// whether the body is actually moving frame-to-frame on the steep
// roof, or whether it's frozen at the impact point.
if (AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled
&& resolveResult.CollisionNormalValid)
{
Console.WriteLine(
$"[steep-roof] FRAME pre=({preIntegratePos.X:F2},{preIntegratePos.Y:F2},{preIntegratePos.Z:F2}) " +
$"post=({postIntegratePos.X:F2},{postIntegratePos.Y:F2},{postIntegratePos.Z:F2}) " +
$"resolved=({resolveResult.Position.X:F2},{resolveResult.Position.Y:F2},{resolveResult.Position.Z:F2}) " +
$"isOnGround={resolveResult.IsOnGround}");
}
// Apply resolved position.
_body.Position = resolveResult.Position;
if (physicsTickRan)
{
_prevPhysicsPos = oldTickEndPos;
_currPhysicsPos = _body.Position;
// R5-V3 (#171): retail UpdateObjectInternal TAIL — PositionManager::
// UseTime (@0x005159b3) runs AFTER the quantum's integration
// (whose head hosts adjust_offset) and only on frames where a
// physics quantum executed (retail's update_object MinQuantum
// gate skips the whole UpdateObjectInternal). Diff-review find:
// a head-of-frame placement tore an expiring stick down BEFORE
// the crossing quantum's final steer/turn — retail applies that
// last adjust_offset, THEN the tail watchdog tears down.
PositionManager?.UseTime();
}
// L.3a (2026-04-30): retail wall-bounce / velocity reflection.
//
// Retail's CPhysicsObj::handle_all_collisions runs after every
// SetPositionInternal. It reads the wall normal that the
// transition's slide computed and reflects the body's velocity:
//
// v_new = v - (1 + elasticity) * dot(v, n) * n
//
// This is what gives retail its "bouncy" feel — fast head-on
// jumps push the player back from the wall, glancing angles
// produce a small deflection. acdream's transition resolver
// SLID position correctly but never updated velocity, so the
// player kept driving into walls until the controller's input
// changed direction. Felt sticky / fragile.
//
// Suppression rule (apply_bounce): grounded movement on a wall
// SHOULDN'T bounce — sliding along a corridor is expected. Only
// airborne wall hits reflect. Mirrors retail's `var_10_1` guard
// and ACE PhysicsObj.cs:2656-2660 `apply_bounce`.
//
// Inelastic flag (spell projectiles, missiles) zeros velocity
// entirely instead of reflecting. The player never has it set.
//
// Sources:
// acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions)
// acclient.h:2834 (INELASTIC_PS = 0x20000)
// ACE PhysicsObj.cs:2656-2721 (line-for-line port)
// PhysicsGlobals.DefaultElasticity = 0.05f, MaxElasticity = 0.1f
if (resolveResult.CollisionNormalValid)
{
bool prevOnWalkable = _body.OnWalkable;
bool nowOnWalkable = resolveResult.IsOnGround;
// apply_bounce: bounce ONLY when the body stays airborne both
// before and after this step. That is: jumping into a wall
// mid-flight, hitting a ceiling, etc. Specifically NOT:
//
// - prev grounded + now grounded → wall-slide along corridor
// (bounce would feel sticky on every wall touch).
// - prev airborne + now grounded → terrain landing
// (terrain normal is mostly +Z; reflecting downward velocity
// would push the body upward and prevent the landing snap
// from firing — player perpetually micro-bouncing on the
// floor instead of resting).
// - prev grounded + now airborne → walked off cliff
// (gravity should take over, not lateral bounce).
//
// Sledding mode reverts to retail's broader rule (bounce
// unless both grounded), since sledding intentionally bounces
// off ramps.
//
// This is more conservative than retail's strict
// `!(prev && now && !sledding)` rule — retail bounces on
// landing too, but at elasticity 0.05 the visual effect is
// imperceptible there. acdream's per-frame architecture
// amplifies the artifact (the post-reflection upward Z
// defeats the controller's `Velocity.Z <= 0` landing-snap
// gate), so we suppress it on landing to avoid the
// micro-bounce death spiral.
bool applyBounce = _body.State.HasFlag(PhysicsStateFlags.Sledding)
? !(prevOnWalkable && nowOnWalkable)
: (!prevOnWalkable && !nowOnWalkable);
// L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug.
bool diagSteep = AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled;
if (diagSteep && resolveResult.CollisionNormalValid)
{
var n0 = resolveResult.CollisionNormal;
var v0 = _body.Velocity;
Console.WriteLine(
$"[steep-roof] BOUNCE-CHECK applyBounce={applyBounce} " +
$"prevWalk={prevOnWalkable} nowWalk={nowOnWalkable} " +
$"N=({n0.X:F2},{n0.Y:F2},{n0.Z:F2}) FloorZ={PhysicsGlobals.FloorZ:F2} " +
$"V=({v0.X:F2},{v0.Y:F2},{v0.Z:F2}) " +
$"dot={Vector3.Dot(v0, n0):F3} " +
$"isOnGround={resolveResult.IsOnGround}");
}
if (applyBounce)
{
if (_body.State.HasFlag(PhysicsStateFlags.Inelastic))
{
// Full stop on impact. Spell projectiles / missiles.
_body.Velocity = Vector3.Zero;
}
else
{
var v = _body.Velocity;
var n = resolveResult.CollisionNormal;
float dotVN = Vector3.Dot(v, n);
if (dotVN < 0f)
{
// Reflect the into-wall component back out.
// Player elasticity is 0.05 → 105% of perpendicular
// velocity reflects (subtle bounce).
float k = -(dotVN * (_body.Elasticity + 1f));
_body.Velocity = v + n * k;
if (diagSteep)
{
var v1 = _body.Velocity;
Console.WriteLine(
$"[steep-roof] BOUNCE-APPLIED V_after=({v1.X:F2},{v1.Y:F2},{v1.Z:F2}) k={k:F3}");
}
}
}
}
}
bool justLanded = false;
if (resolveResult.IsOnGround)
{
if (_body.Velocity.Z <= 0f)
{
// Grounded — snap to resolved position and land.
bool wasAirborne = !_body.OnWalkable;
_body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
_body.calc_acceleration();
if (_body.Velocity.Z < 0f)
_body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f);
if (wasAirborne)
{
// R4-V5 → R5-V5: retail order — minterp first, then
// moveto (MovementManager::HitGround 0x00524300, decomp
// §2d — now the literal facade relay); re-arms a moveto
// suspended by the airborne UseTime contact gate.
// LeaveGround has NO moveto side (COMDAT no-op, §2e) —
// do not add one.
Movement.HitGround();
justLanded = true;
}
}
else
{
// Moving upward (jump) — stay airborne even though terrain is below.
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
_body.calc_acceleration();
}
}
else
{
// No ground found — airborne.
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
_body.calc_acceleration();
}
// R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's
// LeaveGround (0x00528b00) — jump launches (jump() cleared
// OnWalkable earlier this frame) AND walk-off-a-ledge both route
// here, replacing the old manual jump-block call (and giving
// ledge departures the momentum fallback + link strip they never
// had). Edge = grounded last frame, airborne now; the landing
// branch above already fires HitGround on the opposite edge.
if (!_body.OnWalkable && !_wasAirborneLastFrame)
_motion.LeaveGround();
_wasAirborneLastFrame = !_body.OnWalkable;
UpdateCellId(resolveResult.CellId, "resolver");
// ── 6. Determine outbound motion commands ─────────────────────────────
uint? outForwardCmd = null;
float? outForwardSpeed = null;
uint? outSidestepCmd = null;
float? outSidestepSpeed = null;
uint? outTurnCmd = null;
float? outTurnSpeed = null;
// Retail-faithful wire commands — the wire carries the RAW motion state:
// - Forward (walk): WalkForward @ 1.0
// - Forward (run): WalkForward @ 1.0 + HoldKey.Run
// - Backward: WalkBackward @ 1.0
// D6.2b (echo-test 2026-07-01): ACE RECOMPUTES the broadcast run speed
// from the character's run skill and auto-upgrades WalkForward+HoldKey.Run
// → RunForward for observers. Sending the raw forward_speed=1.0 (omitted by
// default-difference packing) still broadcasts RunForward @ runRate — a
// retail observer saw +Acdream run at full pace.
// R3-W6: the LOCAL animation no longer needs a separate
// LocalAnimationCommand — the walk→run promotion happens inside the
// ported machinery (apply_raw_movement → apply_run_to_command
// promotes WalkForward+HoldKey.Run → RunForward @ my_run_rate on the
// interpreted side, which the DefaultSink dispatch plays).
// NOTE (R7 scope): the wire values below stay derived from input —
// the L.2b-verified byte stream is untouched; sourcing them from
// _motion.RawState needs the cdb CommandInterpreter-boundary capture
// the W6 map's §2b TODO flags, and R7 owns outbound anyway.
if (input.Forward)
{
outForwardCmd = MotionCommand.WalkForward;
outForwardSpeed = 1.0f; // RAW — ACE recomputes the broadcast speed
}
else if (input.Backward)
{
outForwardCmd = MotionCommand.WalkBackward;
outForwardSpeed = 1.0f;
}
// Strafe: retail uses speed=1.0 for SideStep (see holtburger
// common.rs::locomotion_command_for_state). 0.5 was our earlier guess
// and made strafing feel lethargic; the retail feel is full-speed
// sidestep matching the walk forward pace.
if (input.StrafeRight)
{
outSidestepCmd = MotionCommand.SideStepRight;
outSidestepSpeed = 1.0f;
}
else if (input.StrafeLeft)
{
outSidestepCmd = MotionCommand.SideStepLeft;
outSidestepSpeed = 1.0f;
}
// Turn commands from KEYBOARD only (A/D). Mouse turning is applied
// directly to Yaw above and doesn't generate a turn command — if it
// did, mouse jitter would flip turnCmd between TurnRight/TurnLeft
// every frame, causing stateChanged=True on every frame and flooding
// the server with MoveToState spam.
if (input.TurnRight)
{
outTurnCmd = MotionCommand.TurnRight;
outTurnSpeed = 1.0f;
}
else if (input.TurnLeft)
{
outTurnCmd = MotionCommand.TurnLeft;
outTurnSpeed = 1.0f;
}
// ── 7. Detect motion state change ─────────────────────────────────────
// ForwardCommand can stay WalkForward while only the run-hold bit changes
// (walk W held, then Shift → run). Since D6.2b the wire forward_speed is
// always the RAW 1.0 (ACE recomputes the broadcast run speed), so the
// walk↔run toggle is detected via the HoldKey (runHold) and the
// LocalAnimationCommand change (Walk↔Run cycle), NOT via forward_speed. A
// fresh MoveToState on that toggle lets ACE's BroadcastMovement re-pick
// WalkForward vs RunForward (via HoldKey) and recompute the run speed for
// observers. The forward_speed comparison below is retained (harmless — it
// never fires now that forward_speed is constant) for defensiveness.
bool runHold = input.Run;
// R3-W6: the localAnimCmd leg is deleted with the synthesis layer;
// motionEdgeFired (any DoMotion/StopMotion/set_hold_run edge this
// frame) is OR-ed in — by construction an edge IS a state change
// (retail dispatches only on edges), keeping the wire cadence
// identical to the old output-comparison.
bool changed = outForwardCmd != _prevForwardCmd
|| outSidestepCmd != _prevSidestepCmd
|| outTurnCmd != _prevTurnCmd
|| !FloatsEqual(outForwardSpeed, _prevForwardSpeed)
|| runHold != _prevRunHold
|| motionEdgeFired;
_prevForwardCmd = outForwardCmd;
_prevSidestepCmd = outSidestepCmd;
_prevTurnCmd = outTurnCmd;
_prevForwardSpeed = outForwardSpeed;
_prevRunHold = runHold;
static bool FloatsEqual(float? a, float? b)
{
if (a.HasValue != b.HasValue) return false;
if (!a.HasValue || !b.HasValue) return true;
return System.Math.Abs(a.Value - b.Value) < 1e-4f;
}
// ── 8. Heartbeat timer (always while in-world, not just while moving) ─
// 2026-05-16 (closes #74) — retail-faithful AP cadence per
// CommandInterpreter::ShouldSendPositionEvent at
// acclient_2013_pseudo_c.txt:700233-700285. Two-branch:
//
// Branch 1 — interval NOT yet elapsed (< 1 sec since last
// send): send only if cell changed OR contact-plane changed
// (mid-walk events that matter — stair / hill / cell cross).
//
// Branch 2 — interval HAS elapsed (>= 1 sec): send only if
// cell OR position frame changed. Truly idle = no send
// (retail's `last_sent.frame == player.frame` check at
// acclient_2013_pseudo_c.txt:700248-700265).
//
// SendPositionEvent (line 700327) gates the actual send on
// (state & 1) != 0 && (state & 2) != 0 — Contact AND
// OnWalkable both set. We mirror that gate so airborne and
// wall-contact-without-walkable suppress AP entirely;
// MoveToState carries jump/fall snapshots while airborne.
//
// Effective rates:
// Truly idle (grounded, no movement) : 0 Hz
// Smooth movement (no cell/plane changes) : ~1 Hz (interval)
// Cell crossings + stair/hill steps : per-event
// Airborne : 0 Hz
//
// Bootstrap: when NotePositionSent has never been called
// (_lastSentInitialized=false), every state-changed branch is
// forced true so the first AP gets a chance to fire.
bool intervalElapsed = !_lastSentInitialized
|| (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval;
bool cellChanged = !_lastSentInitialized
|| _lastSentCellId != CellId;
bool planeChanged = !_lastSentInitialized
|| !ApproxPlaneEqual(_lastSentContactPlane, _body.ContactPlane);
bool frameChanged = !_lastSentInitialized
|| !ApproxPositionEqual(_lastSentPos, _body.Position);
bool sendThisFrame = intervalElapsed
? (cellChanged || frameChanged)
: (cellChanged || planeChanged);
// Grounded-on-walkable gate per acclient_2013_pseudo_c.txt:700327
// (`(state & 1) != 0 && (state & 2) != 0`). Both flags must be
// set simultaneously, NOT a bitwise-OR mask test.
bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
HeartbeatDue = groundedOnWalkable && sendThisFrame;
// R3-W6: the K-fix5 LocalAnimationSpeed synthesis is DELETED — the
// run pacing now comes from the ported machinery itself
// (apply_run_to_command scales the interpreted speed by my_run_rate;
// the DefaultSink dispatch plays the cycle at that speed — the same
// source remotes use).
bool anyDirectional = input.Forward || input.Backward
|| input.StrafeLeft || input.StrafeRight;
// R4-V5: the #69 auto-walk turn-cycle edge synthesizer is DELETED —
// the MoveToManager's own aux-turn steering and TurnToHeading nodes
// dispatch TurnRight/TurnLeft through _DoMotion (the retail
// mechanism), so turn cycles during a moveto come from the same
// pipeline as everything else.
return new MovementResult(
Position: Position,
RenderPosition: RenderPosition,
CellId: CellId,
IsOnGround: _body.OnWalkable,
MotionStateChanged: changed,
ForwardCommand: outForwardCmd,
SidestepCommand: outSidestepCmd,
TurnCommand: outTurnCmd,
ForwardSpeed: outForwardSpeed,
SidestepSpeed: outSidestepSpeed,
TurnSpeed: outTurnSpeed,
// Run hold-key applies to ANY active directional axis, not just
// forward (per holtburger's build_motion_state_raw_motion_state:
// "uses the same value for every active per-axis hold key"). The
// pre-fix condition `input.Run && input.Forward` made strafe-run
// and backward-run incorrectly broadcast as walk to observers,
// who then animated walk + dead-reckoned at walk speed while the
// server position moved at run speed — visible as observer lag.
IsRunning: input.Run && anyDirectional,
JustLanded: justLanded,
JumpExtent: outJumpExtent,
JumpVelocity: outJumpVelocity);
}
/// <summary>
/// 2026-05-16. Position-equality test for diff-driven AP cadence.
/// Retail uses Frame::is_equal at acclient_2013_pseudo_c.txt:700263
/// which is essentially exact float comparison after a memcmp of
/// the frame struct. For floating-point safety we use a tiny epsilon
/// — sub-millimeter — that's well below any movement we'd want to
/// suppress sending for.
/// </summary>
private static bool ApproxPositionEqual(
System.Numerics.Vector3 a, System.Numerics.Vector3 b)
{
const float Epsilon = 0.001f; // 1 mm
return MathF.Abs(a.X - b.X) < Epsilon
&& MathF.Abs(a.Y - b.Y) < Epsilon
&& MathF.Abs(a.Z - b.Z) < Epsilon;
}
/// <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 NormalEpsilon = 1e-4f;
const float DistanceEpsilon = 0.001f;
return MathF.Abs(a.Normal.X - b.Normal.X) < NormalEpsilon
&& MathF.Abs(a.Normal.Y - b.Normal.Y) < NormalEpsilon
&& MathF.Abs(a.Normal.Z - b.Normal.Z) < NormalEpsilon
&& MathF.Abs(a.D - b.D) < DistanceEpsilon;
}
}