The c2dc1a88 wedge fix paired every StopCompletely A9 node with a
completable motion-table type-5 entry via DefaultSink?.StopCompletely()
- but EnterPlayerModeNow called the initial SetPosition (whose teleport
idle is StopCompletely, R3-W6) BEFORE the sequencer/DefaultSink bind
block, so the login A9 node dispatched against a NULL sink and stayed
an orphan. Head-pop-any semantics mean a queue with one orphan never
reaches empty again (later completions just relabel the backlog), so
MotionsPending stayed true at every UseTime and the MoveToManager's
retail wait-for-anims gate (BeginTurnToHeading) never opened: every
server MoveTo armed (movingTo=True, Initialized=True, tracker fed,
node plan built) and the body never moved.
Pinned live by the [autowalk-gate] probe: type=MoveToObject init=True
contact=True motionsPending=True pm=[0x41000003] nodes=[TurnToHeading,
MoveToPosition] curCmd=0 - identical every half-second, forever.
Fix: the sequencer/sink bind block in EnterPlayerModeNow moves ABOVE
the initial physics Resolve + SetPosition (no data dependency - the
block only needs playerEntity/_animatedEntities). Teleport-arrival
SetPositions were already safe (sink long bound). Test rigs
(PlayerMoveToCutoverTests.MakeRig, W6EdgeDrivenMovementTests) mirror
the fixed order, and a new LoginQueue_DrainsToEmpty_UnderProductionFeed
test pins the invariant the probe caught: after login, pending_motions
must reach EMPTY under the production completion feed.
The [autowalk-gate]/[autowalk-feed] diagnostics stay in for the live
verify pass (TEMPORARY-tagged; strip when #5 closes). Full suite
green: 3,958.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1267 lines
65 KiB
C#
1267 lines
65 KiB
C#
using System;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
|
||
namespace AcDream.App.Input;
|
||
|
||
/// <summary>
|
||
/// Input state for a single frame of player movement.
|
||
/// </summary>
|
||
public readonly record struct MovementInput(
|
||
bool Forward = false,
|
||
bool Backward = false,
|
||
bool StrafeLeft = false,
|
||
bool StrafeRight = false,
|
||
bool TurnLeft = false,
|
||
bool TurnRight = false,
|
||
bool Run = false,
|
||
float MouseDeltaX = 0f,
|
||
bool Jump = false);
|
||
|
||
/// <summary>
|
||
/// 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;
|
||
// TEMPORARY diagnostic clock (2026-07-03 moveto-stall investigation).
|
||
private float _lastAutowalkGateLogTime = float.MinValue;
|
||
|
||
// 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>
|
||
/// 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 to
|
||
/// <see cref="AcDream.Core.Physics.Motion.MoveToManager.PerformMovement"/>;
|
||
/// <see cref="Update"/> ticks <c>UseTime()</c> at the slot the deleted
|
||
/// <c>DriveServerAutoWalk</c> occupied and relays <c>HitGround()</c>
|
||
/// (retail order: minterp first, then moveto — MovementManager::HitGround
|
||
/// 0x00524300). 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).
|
||
/// </summary>
|
||
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo { 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);
|
||
// 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 & 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();
|
||
// 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).
|
||
MoveTo?.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();
|
||
|
||
// TEMPORARY diagnostic (2026-07-03 moveto-stall investigation,
|
||
// strip when closed): every gate input the armed moveto's per-tick
|
||
// drive depends on, ~2 Hz while armed, ProbeAutoWalk-gated.
|
||
if (PhysicsDiagnostics.ProbeAutoWalkEnabled
|
||
&& MoveTo is { } mtDiag && mtDiag.IsMovingTo()
|
||
&& _simTimeSeconds - _lastAutowalkGateLogTime > 0.5f)
|
||
{
|
||
_lastAutowalkGateLogTime = _simTimeSeconds;
|
||
var pmParts = new System.Collections.Generic.List<string>();
|
||
foreach (var n in _motion.PendingMotions) pmParts.Add($"0x{n.Motion:X8}");
|
||
var nodeParts = new System.Collections.Generic.List<string>();
|
||
foreach (var n in mtDiag.PendingActions) nodeParts.Add(n.Type.ToString());
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[autowalk-gate] type={mtDiag.MovementTypeState} init={mtDiag.Initialized} tlid=0x{mtDiag.TopLevelObjectId:X8} contact={_body.InContact} motionsPending={_motion.MotionsPending()} pm=[{string.Join(",", pmParts)}] nodes=[{string.Join(",", nodeParts)}] curCmd=0x{mtDiag.CurrentCommand:X8} auxCmd=0x{mtDiag.AuxCommand:X8} fwd=0x{_motion.InterpretedState.ForwardCommand:X8} pos=({Position.X:F1},{Position.Y:F1}) yaw={Yaw:F2}"));
|
||
}
|
||
|
||
// 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);
|
||
_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;
|
||
}
|
||
|
||
// 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)
|
||
{
|
||
_motion.HitGround();
|
||
// R4-V5: retail order — minterp first, then moveto
|
||
// (MovementManager::HitGround 0x00524300, decomp §2d);
|
||
// re-arms a moveto suspended by the airborne UseTime
|
||
// contact gate. LeaveGround has NO moveto side (COMDAT
|
||
// no-op, §2e) — do not add one.
|
||
MoveTo?.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;
|
||
}
|
||
}
|