diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs
new file mode 100644
index 00000000..72c0ef55
--- /dev/null
+++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs
@@ -0,0 +1,817 @@
+using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
+using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity;
+
+namespace AcDream.App.Physics;
+
+///
+/// #184 Slice 2a — the per-remote dead-reckoning physics tick, extracted
+/// verbatim from GameWindow.TickAnimations (Code Structure Rule 1: no
+/// new feature bodies in the >10k-line GameWindow). One instance is
+/// owned by GameWindow and called once per animated remote entity per
+/// frame, from inside the same guard the body used to live under
+/// (ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid
+/// && rm.LastServerPosTime > 0).
+///
+/// Behaviour is byte-for-byte the pre-extraction body: the player/NPC
+/// FORK is PRESERVED here unchanged — Path A (grounded PLAYER remotes,
+/// IsPlayerGuid(serverGuid) && !rm.Airborne) advances by the interp
+/// catch-up and deliberately OMITS the sweep; Path B (NPCs + airborne player
+/// remotes) runs ResolveWithTransition + shadow-follows-resolved. Slice
+/// 2b collapses the fork inside this class. See
+/// docs/research/2026-07-07-184-slice2-unify-extract-handoff.md.
+///
+/// Shared helpers that GameWindow also calls elsewhere are injected:
+/// GetSetupCylinder (a general Setup-dimension helper with ~9 callers,
+/// incl. the local player's own cylinder — kept on GameWindow) and
+/// ApplyServerControlledVelocityCycle (anim-cycle selection, also called
+/// from the UP handler) arrive as delegates. SyncRemoteShadowToBody
+/// (remote-physics-specific) moved here and is called back from the UP-branch
+/// tail; ApplyPositionManagerDelta / TickRemoteMoveTo had no other
+/// callers and moved here outright.
+///
+internal sealed class RemotePhysicsUpdater
+{
+ // Moved from GameWindow (#184 Slice 2a — the DR tick was its only caller).
+ private const double ServerControlledVelocityStaleSeconds = 0.60;
+
+ private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine;
+ private readonly System.Func _getSetupCylinder;
+ private readonly System.Action _applyServerControlledVelocityCycle;
+
+ internal RemotePhysicsUpdater(
+ AcDream.Core.Physics.PhysicsEngine physicsEngine,
+ System.Func getSetupCylinder,
+ System.Action applyServerControlledVelocityCycle)
+ {
+ _physicsEngine = physicsEngine;
+ _getSetupCylinder = getSetupCylinder;
+ _applyServerControlledVelocityCycle = applyServerControlledVelocityCycle;
+ }
+
+ // Duplicated one-liner (GameWindow keeps its own copy — many callers there).
+ private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
+
+ ///
+ /// #184 Slice 2a — the per-remote DR tick (retail UpdateObjectInternal
+ /// shape), verbatim from the former GameWindow.TickAnimations guard
+ /// body. serverGuid + the entity id derive from
+ /// .Entity; /
+ /// are passed per-call (they change on streaming recentre — never snapshot
+ /// them in the constructor).
+ ///
+ public void Tick(RemoteMotion rm, AnimatedEntity ae, float dt, int liveCenterX, int liveCenterY)
+ {
+ uint serverGuid = ae.Entity.ServerGuid;
+ // R5-V2: retail UpdateObjectInternal ticks TargetManager::
+ // HandleTargetting UNCONDITIONALLY per entity, BEFORE the
+ // movement managers' UseTime. This is where this entity, as a
+ // watched target, pushes its position to its voyeurs (any entity
+ // moving-to it), and where its own target-info staleness times
+ // out. Runs for every remote regardless of the grounded/airborne
+ // branch below (which drive MoveToManager.UseTime via
+ // TickRemoteMoveTo). No-op for entities with no target + no voyeurs.
+ rm.Host?.HandleTargetting();
+
+ if (IsPlayerGuid(serverGuid) && !rm.Airborne)
+ {
+ // ── L.3 M2/M3 (2026-05-05): queue + anim chase for grounded player remotes ──
+ //
+ // Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md +
+ // 04-interp-manager.md +
+ // 05-position-manager-and-partarray.md):
+ //
+ // - For a grounded REMOTE player, m_velocityVector stays at 0.
+ // - apply_current_movement is NEVER called per tick on remotes
+ // (it's the local-player-only velocity feed).
+ // - UpdatePhysicsInternal's translation step is gated on
+ // velocity² > 0, so it's a no-op when body.Velocity = 0.
+ // - ResolveWithTransition is NOT called — the server already
+ // collision-resolved the broadcast position.
+ // - Per-tick body translation per retail UpdatePositionInternal:
+ // 1. CPartArray::Update writes anim root motion (body-local
+ // seqVel × dt) into the local frame.
+ // 2. PositionManager::adjust_offset OVERWRITES the local
+ // frame's origin with the queue catch-up vector when
+ // the queue is active and the head is not yet reached
+ // — REPLACE, not additive.
+ // 3. Frame::combine composes the local frame with the
+ // body's world pose.
+ // Net: catch-up replaces anim during the chase phase, anim
+ // stands when the queue is empty / head reached. PositionManager.
+ // ComputeOffset implements this exact REPLACE dichotomy.
+ //
+ // Airborne player remotes (rm.Airborne) and NPCs fall through to
+ // the legacy path below — unchanged from main per the M2 plan.
+ System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity
+ ?? System.Numerics.Vector3.Zero;
+ System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
+ ?? System.Numerics.Vector3.Zero;
+
+ // Step 1: transient flags (Contact + OnWalkable for grounded;
+ // Active always so UpdatePhysicsInternal doesn't early-return).
+ if (!rm.Airborne)
+ {
+ rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
+ | AcDream.Core.Physics.TransientStateFlags.OnWalkable
+ | AcDream.Core.Physics.TransientStateFlags.Active;
+
+ // For grounded remotes the body should not be carrying
+ // velocity — retail's m_velocityVector for a remote is
+ // 0 unless the server explicitly pushed one. Clear any
+ // stale velocity from a prior airborne arc so
+ // UpdatePhysicsInternal doesn't double-apply it on top
+ // of the seqVel-driven ComputeOffset translation below.
+ rm.Body.Velocity = System.Numerics.Vector3.Zero;
+ }
+ else
+ {
+ rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
+ }
+
+ // R4-V5 glide fix (2026-07-03 user report: a retail
+ // player using an object GLIDES to it — position moves
+ // via the UP queue but no walk/run legs): remote
+ // PLAYERS' MoveToManagers were armed by mt-6 but never
+ // TICKED — the V4 UseTime slot lived only in the
+ // NPC/legacy branch below, gated !IsPlayerGuid
+ // (inherited from the deleted RemoteMoveToDriver's
+ // NPC-only scope). Retail ticks every entity's
+ // MovementManager (UpdateObjectInternal has no entity-
+ // class fork). The manager's dispatches produce the
+ // locomotion cycle through the funnel sink (the LEGS);
+ // position stays queue-chased per the L.3 M2 spec, so
+ // the two compose exactly like an NPC's tick.
+ TickRemoteMoveTo(rm);
+
+ // Step 2 (M3): queue + anim translation via PositionManager.
+ // ComputeOffset returns:
+ // - Vector3.Zero when queue is empty AND seqVel is zero
+ // (idle remote between UPs after head reached) — body
+ // stays still.
+ // - Direction × min(catchUpSpeed × dt, dist) when the
+ // queue is active and head is not reached — body chases
+ // the head waypoint at up to 2× motion-table max speed
+ // (REPLACES anim for this frame).
+ // - Anim root motion (seqVel × dt rotated into world) when
+ // the queue is empty OR head is within DesiredDistance —
+ // body advances with the locomotion cycle's baked
+ // velocity, keeping legs and body pace synchronized.
+ // - Blip-to-tail (tail − body) when fail_count > 3.
+ float maxSpeed = rm.Motion.GetMaxSpeed();
+ // Slope-staircase fix (2026-05-05): sample terrain normal
+ // at the body's current XY so PositionManager can project
+ // the seqVel-only fallback onto the local slope. Without
+ // this, the queue-empty interval between UPs left Z flat
+ // (anim cycles bake Z=0 body-local) — visible ~5 Hz
+ // staircase when a remote runs up/down hills. The
+ // projection is a no-op on flat ground.
+ System.Numerics.Vector3? terrainNormal = _physicsEngine.SampleTerrainNormal(
+ rm.Body.Position.X, rm.Body.Position.Y);
+
+ System.Numerics.Vector3 bodyPosBefore = rm.Body.Position;
+ System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
+ dt: (double)dt,
+ currentBodyPosition: rm.Body.Position,
+ seqVel: seqVel,
+ ori: rm.Body.Orientation,
+ interp: rm.Interp,
+ maxSpeed: maxSpeed,
+ terrainNormal: terrainNormal);
+ // R5-V3 (#171): retail chains Interpolation → Sticky over
+ // ONE shared delta frame (PositionManager::adjust_offset
+ // 0x00555190). The combiner's catch-up IS acdream's
+ // interpolation stage, so its offset SEEDS the frame
+ // (converted to mover-local) and StickyManager::
+ // adjust_offset OVERWRITES it when armed+initialized
+ // (0x00555430 assigns m_fOrigin rather than accumulating).
+ // With no stick armed the frame comes back untouched and
+ // this reduces to the pre-V3 `Position += offset`.
+ if (rm.Host is { } plHost)
+ {
+ var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
+ {
+ Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
+ rm.Body.Orientation, offset),
+ };
+ plHost.PositionManager.AdjustOffset(pmDelta, dt);
+ ApplyPositionManagerDelta(rm.Body, pmDelta);
+ }
+ else
+ {
+ rm.Body.Position += offset;
+ }
+
+ // Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1.
+ // Prints per-tick body Z trajectory + queue state + projected
+ // offset.Z so we can grep before/after the fix and confirm Z
+ // changes continuously between UPs on slopes (no flat
+ // intervals followed by snaps).
+ if (System.Environment.GetEnvironmentVariable("ACDREAM_SLOPE_DIAG") == "1")
+ {
+ bool queueActive = rm.Interp.IsActive;
+ float nz = terrainNormal?.Z ?? 1.0f;
+ System.Console.WriteLine(
+ $"[SLOPE] guid={serverGuid:X8} bodyZ={bodyPosBefore.Z:F3}->{rm.Body.Position.Z:F3} "
+ + $"offset=({offset.X:F3},{offset.Y:F3},{offset.Z:F3}) "
+ + $"queue={queueActive} cpN.Z={nz:F3}");
+ }
+
+ // Step 2.5: angular velocity → body orientation. Prefer
+ // ObservedOmega (set explicitly in OnLiveMotionUpdated from
+ // the wire's TurnCommand + signed TurnSpeed) over the
+ // sequencer's synthesized omega: when the player runs in
+ // a circle ACE broadcasts ForwardCommand=RunForward AND
+ // TurnCommand=TurnLeft on the same UpdateMotion. The
+ // sequencer's animCycle picker chooses RunForward (legs
+ // running), whose synthesized CurrentOmega is zero. Body
+ // would not rotate between UPs and body.Velocity stays in
+ // an out-of-date world direction, producing the
+ // user-reported "rectangle when running circles" effect.
+ // ObservedOmega has the correct turn rate even when the
+ // visible cycle is RunForward.
+ System.Numerics.Vector3 omegaToApply =
+ rm.ObservedOmega.LengthSquared() > 1e-9f
+ ? rm.ObservedOmega
+ : seqOmega;
+ if (omegaToApply.LengthSquared() > 1e-9f)
+ {
+ float angleDelta = omegaToApply.Length() * (float)dt;
+ System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
+ var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
+ rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
+ System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
+
+ // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): print seqOmega direction
+ // once per remote per ~1 second so we can confirm whether the omega
+ // sign actually being applied matches the retail-observed turn
+ // direction. Z>0 = CCW (TurnLeft); Z<0 = CW (TurnRight).
+ if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
+ {
+ double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
+ if (nowSec - rm.LastOmegaDiagLogTime > 0.5)
+ {
+ uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
+ System.Console.WriteLine(
+ $"[OMEGA_DIAG] guid={serverGuid:X8} motion=0x{seqMotion:X8} "
+ + $"omegaApplied.Z={omegaToApply.Z:F3} "
+ + $"(seq.Z={seqOmega.Z:F3} obs.Z={rm.ObservedOmega.Z:F3}) "
+ + $"(Z>0=CCW=TurnLeft, Z<0=CW=TurnRight)");
+ rm.LastOmegaDiagLogTime = nowSec;
+ }
+ }
+ }
+
+ // Step 3: calc_acceleration sets body.Acceleration from the Gravity flag
+ // (mirrors retail CPhysicsObj::calc_acceleration @ FUN_00511420, called
+ // per-frame in update_object). Without this, body.Acceleration stays stale
+ // or zero → gravity never decays jump velocity → endless rise on jumps.
+ rm.Body.calc_acceleration();
+
+ // Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²).
+ rm.Body.UpdatePhysicsInternal(dt);
+
+ // Step 4b INTENTIONALLY OMITTED in M2:
+ // ResolveWithTransition is NOT called — the server has
+ // already collision-resolved the broadcast position, and
+ // running our sweep on tiny per-frame queue catch-up deltas
+ // amplifies micro-bounces into visible position blips
+ // (issue #40 staircase + flat-ground blips). Per retail
+ // spec the per-tick body advance for a remote is purely
+ // the queue catch-up; collision is the sender's problem.
+ //
+ // Step 5 (landing fallback) is unreachable in this branch —
+ // we're gated on !rm.Airborne. Airborne player remotes fall
+ // through to the legacy path below where K-fix15 still fires.
+
+ // Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1).
+ // Track the maximum sequencer velocity magnitude seen since
+ // the last UpdatePosition arrival (carried on the
+ // RemoteMotion struct), then on each UP arrival the
+ // OnLivePositionUpdated path prints the comparison against
+ // the server's actual broadcast pace
+ // ((LastServerPos - PrevServerPos) / Δt). This guarantees
+ // both sides are sampled during the same window and the
+ // ratio reflects the real overshoot.
+ if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
+ {
+ // body.Velocity is now the source of bulk translation
+ // (set above by apply_current_movement). Track its
+ // magnitude so VEL_DIAG can compare against the actual
+ // server broadcast pace.
+ float seqSpeedNow = rm.Body.Velocity.Length();
+ if (seqSpeedNow > rm.MaxSeqSpeedSinceLastUP)
+ rm.MaxSeqSpeedSinceLastUP = seqSpeedNow;
+ }
+
+ ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
+ if (rm.CellId != 0)
+ ae.Entity.ParentCellId = rm.CellId;
+ ae.Entity.Rotation = rm.Body.Orientation;
+ }
+ else
+ {
+ // ── LEGACY PATH (UNCHANGED — kept until Task 8 cleanup) ──
+ //
+ // Stop detection is handled explicitly on packet receipt:
+ // - UpdateMotion with ForwardCommand flag CLEARED → Ready.
+ // - UpdatePosition with HasVelocity flag CLEARED → StopCompletely.
+ // Both map to retail's "flag-absent = Invalid = reset to
+ // default" semantics (FUN_0051F260 bulk-copy). No timer-based
+ // inference needed — the server sends the right signal every
+ // time a remote stops.
+
+ // Retail per-tick motion pipeline applied to every remote.
+ // Mirrors retail FUN_00515020 update_object → FUN_00513730
+ // UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal:
+ //
+ // 1. apply_current_movement (FUN_00529210) — recomputes
+ // body.Velocity from InterpretedState via get_state_velocity.
+ // 2. Pull omega from the sequencer (baked MotionData.Omega
+ // for TurnRight / TurnLeft cycles, scaled by speedMod).
+ // 3. body.update_object(now) — Euler-integrates
+ // position += Velocity × dt + 0.5 × Accel × dt² AND
+ // orientation += omega × dt.
+ //
+ // On UpdatePosition receipt we hard-snap body.Position and
+ // body.Orientation — if integration matched server physics,
+ // each snap is small/invisible.
+ double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
+
+ // Step 1: re-apply current motion commands → body.Velocity.
+ // Forces OnWalkable + Contact so the gate in apply_current_movement
+ // always succeeds (remotes are server-authoritative; we don't
+ // simulate airborne physics for them).
+ //
+ // K-fix9 (2026-04-26): SKIP this when the remote is airborne.
+ // Otherwise the force-OnWalkable + apply_current_movement
+ // path stomps the +Z velocity we set in OnLiveVectorUpdated,
+ // and gravity never gets to integrate the arc. The airborne
+ // body keeps the launch velocity from the VectorUpdate;
+ // UpdatePhysicsInternal below applies gravity each tick;
+ // the next UpdatePosition snaps to the new ground location
+ // and re-grounds.
+ if (!rm.Airborne)
+ {
+ rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
+ | AcDream.Core.Physics.TransientStateFlags.OnWalkable
+ | AcDream.Core.Physics.TransientStateFlags.Active;
+
+ // #184 (2026-07-07): a grounded remote carries NO translation
+ // velocity. Its per-tick movement is the interp CATCH-UP toward
+ // the MoveOrTeleport-queued server waypoint (computed at the
+ // sticky-compose site below), which the KEPT ResolveWithTransition
+ // sweep de-overlaps against neighbours — and the resolved position
+ // is written back into the SHADOW (below) so the de-overlap
+ // persists and neighbours collide against the resolved body, not
+ // the raw server pos. This REPLACES the old synth-velocity model
+ // (get_state_velocity / SERVERVEL Body.Velocity = ServerVelocity):
+ // retail's UpdateObjectInternal (0x005156b0) has NO synth-velocity
+ // leg — a remote translates by adjust_offset and the UP is a gentle
+ // target. Matches Path A's grounded model (:10113); clearing
+ // velocity mirrors Path A (:10125).
+ rm.Body.Velocity = System.Numerics.Vector3.Zero;
+
+ // Stale server-velocity → stop the locomotion CYCLE (the legs).
+ // ANIM ONLY — translation is the catch-up. Kept verbatim (same
+ // !moveToArmed && !stickyArmed gate) from the old SERVERVEL branch
+ // so a scripted-path NPC that stops server-side drops out of its
+ // walk/run cycle; ApplyServerControlledVelocityCycle selects the
+ // anim from ServerVelocity, independent of Body.Velocity.
+ bool moveToArmed = rm.MoveTo is
+ { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid };
+ bool stickyArmed =
+ (rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
+ if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity
+ && !moveToArmed && !stickyArmed)
+ {
+ double velocityAge = nowSec - rm.LastServerPosTime;
+ if (velocityAge > ServerControlledVelocityStaleSeconds)
+ {
+ rm.ServerVelocity = System.Numerics.Vector3.Zero;
+ rm.HasServerVelocity = false;
+ _applyServerControlledVelocityCycle(
+ serverGuid,
+ ae,
+ rm,
+ System.Numerics.Vector3.Zero);
+ }
+ }
+
+ // R4-V4: tick the MoveToManager UNCONDITIONALLY (retail
+ // MovementManager::UseTime per tick, UpdateObjectInternal call
+ // @0x00515998) — UseTime runs HandleMoveToPosition /
+ // HandleTurnToHeading (steering + arrival + fail-distance),
+ // dispatching its per-node locomotion (turn / RunForward) through
+ // the sink (the LEGS). Position comes from the catch-up; legs from
+ // this per-node dispatch + the funnel. The #170-deleted per-frame
+ // apply_current_movement is NOT reintroduced.
+ TickRemoteMoveTo(rm);
+ }
+ else
+ {
+ // Airborne — keep Active flag (so UpdatePhysicsInternal
+ // doesn't early-return) but DON'T set Contact / OnWalkable.
+ rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
+ }
+
+ // Step 2: integrate rotation manually per tick. We can't
+ // rely on PhysicsBody.update_object here — its MinQuantum
+ // gate (1/30 s) causes it to SKIP integration when our
+ // 60fps render dt (~0.016s) is below the quantum, meaning
+ // rotation never advances. Measured snap per UP was ~129°
+ // = the full expected 1s × 2.24 rad/s, confirming zero
+ // between-tick rotation.
+ //
+ // Manual integration matches retail's FUN_005256b0
+ // apply_physics (Orientation *= quat(ω × dt)). Use
+ // ObservedOmega derived from server UP rotation deltas so
+ // the rate exactly matches server physics — hard-snap on
+ // next UP becomes invisible by construction.
+ rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object
+ if (rm.ObservedOmega.LengthSquared() > 1e-8f)
+ {
+ float omegaMag = rm.ObservedOmega.Length();
+ var axis = rm.ObservedOmega / omegaMag;
+ float angle = omegaMag * dt;
+ var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
+ rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
+ System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
+ }
+
+ // Step 3: integrate physics — retail FUN_005111D0
+ // UpdatePhysicsInternal. Pure Euler:
+ // position += velocity × dt + 0.5 × accel × dt²
+ //
+ // Call UpdatePhysicsInternal DIRECTLY rather than via
+ // PhysicsBody.update_object (FUN_00515020). update_object gates
+ // on MinQuantum = 1/30s: at our 60fps render tick (~16ms),
+ // deltaTime < MinQuantum → early return AND LastUpdateTime is
+ // NOT advanced. Net effect: position never integrates between
+ // UpdatePositions and the only Body.Position changes come
+ // from the UP hard-snap, producing a visible teleport-stride
+ // on slopes (the "staircase" the user reported).
+ //
+ // PlayerMovementController.cs:358 calls UpdatePhysicsInternal
+ // directly for the same reason. Remote motion mirrors that.
+ // Omega is already integrated manually above, so we zero it
+ // here to prevent UpdatePhysicsInternal's own omega pass from
+ // double-integrating.
+ var preIntegratePos = rm.Body.Position;
+ // R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation →
+ // Sticky over ONE shared delta frame (PositionManager::adjust_offset
+ // 0x00555190), composed BEFORE UpdatePhysicsInternal + the transition
+ // sweep so collision resolves whichever movement won (preIntegratePos
+ // captured first — the sweep covers it).
+ // • GROUNDED: the interp CATCH-UP SEEDS the frame (world→local) —
+ // the movement source is the adjust_offset walk toward the
+ // MoveOrTeleport-queued server waypoint, exactly like Path A
+ // (:10173). StickyManager::adjust_offset then OVERWRITES the
+ // Origin when armed (0x00555430 ASSIGNS m_fOrigin — the REPLACE
+ // dichotomy), so a stuck monster still steers via #171.
+ // • AIRBORNE: seed an EMPTY frame (no catch-up — the arc integrates
+ // from velocity + gravity, unchanged).
+ // Body.Velocity is 0 when grounded (set above), so UpdatePhysicsInternal
+ // adds no translation on top of the catch-up — no double-move.
+ if (rm.Host is { } npcHost)
+ {
+ AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta;
+ if (!rm.Airborne)
+ {
+ System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
+ ?? System.Numerics.Vector3.Zero;
+ float maxSpeedNpc = rm.Motion.GetMaxSpeed();
+ System.Numerics.Vector3? terrainNormalNpc =
+ _physicsEngine.SampleTerrainNormal(
+ rm.Body.Position.X, rm.Body.Position.Y);
+ System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
+ dt: (double)dt,
+ currentBodyPosition: rm.Body.Position,
+ seqVel: seqVelNpc,
+ ori: rm.Body.Orientation,
+ interp: rm.Interp,
+ maxSpeed: maxSpeedNpc,
+ terrainNormal: terrainNormalNpc);
+ pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
+ {
+ Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
+ rm.Body.Orientation, offsetNpc),
+ };
+ }
+ else
+ {
+ pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
+ }
+ npcHost.PositionManager.AdjustOffset(pmDelta, dt);
+ ApplyPositionManagerDelta(rm.Body, pmDelta);
+ }
+ else if (!rm.Airborne)
+ {
+ // No PositionManager host yet (pre-binding): apply the catch-up
+ // directly, matching Path A's fallback (:10202).
+ System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
+ ?? System.Numerics.Vector3.Zero;
+ float maxSpeedNpc = rm.Motion.GetMaxSpeed();
+ System.Numerics.Vector3? terrainNormalNpc =
+ _physicsEngine.SampleTerrainNormal(
+ rm.Body.Position.X, rm.Body.Position.Y);
+ rm.Body.Position += rm.Position.ComputeOffset(
+ dt: (double)dt,
+ currentBodyPosition: rm.Body.Position,
+ seqVel: seqVelNpc,
+ ori: rm.Body.Orientation,
+ interp: rm.Interp,
+ maxSpeed: maxSpeedNpc,
+ terrainNormal: terrainNormalNpc);
+ }
+ rm.Body.calc_acceleration();
+ rm.Body.UpdatePhysicsInternal(dt);
+ var postIntegratePos = rm.Body.Position;
+
+ // Step 4: collision sweep — retail FUN_00514B90 →
+ // FUN_005148A0 → Transition::FindTransitionalPosition.
+ // Projects the sphere from preIntegratePos to postIntegratePos
+ // through the BSP + terrain, resolving:
+ // - terrain Z snap along the slope (fixes the "staircase" where
+ // horizontal Euler motion up a slope sinks into rising ground
+ // until the next UP pops it up)
+ // - indoor BSP walls (via the 6-path dispatcher in BSPQuery)
+ // - object collisions via ShadowObjectRegistry
+ // - step-up / step-down against walkable ledges
+ // ResolveWithTransition is the same call PlayerMovementController
+ // uses for the local player; remotes now get the full retail
+ // treatment between UpdatePositions instead of pure kinematics.
+ //
+ // Skipped when rm.CellId == 0 (no UP landed yet — can't build
+ // a SpherePath without a starting cell). One-frame grace until
+ // the first UP arrives; harmless because the entity is
+ // server-freshly-spawned at a valid Z anyway.
+ if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0)
+ {
+ // #184 Slice 3 (2026-07-07): Setup-DERIVED mover sphere so
+ // creatures de-overlap at their TRUE radii (a big monster
+ // spreads wider, a small one tighter), not the hardcoded
+ // human 0.48/1.835. GetSetupCylinder returns (setup.Radius,
+ // setup.Height) × ObjScale — the creature's own dat Setup
+ // scaled by its wire ObjScale, the same source the local
+ // player + moveto/sticky use, and consistent with the
+ // spawn-time shadow registration's entScale. Retail seeds
+ // the transition from the object's own Setup sphere list ×
+ // m_scale (CPhysicsObj::transition 0x00512dc0 → init_sphere;
+ // ObjScale from set_description 0x00514f40). This narrows
+ // TS-46 (remotes no longer use human dims); the two-scalar
+ // API is still a lossy stand-in for retail's full (≤2)
+ // sphere list, and stepUp/stepDown stay 0.4 (retail derives
+ // those from the Setup too — an adjacent divergence left as-is).
+ // Fallback to the human capsule for a shapeless / unresolvable
+ // Setup (GetSetupCylinder returns (0,0)); a zero radius would
+ // degenerate the sweep.
+ var (deR, deH) = _getSetupCylinder(serverGuid, ae.Entity);
+ if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
+ var resolveResult = _physicsEngine.ResolveWithTransition(
+ preIntegratePos, postIntegratePos, rm.CellId,
+ sphereRadius: deR,
+ sphereHeight: deH,
+ stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
+ stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
+ // K-fix9 (2026-04-26): mirror the K-fix7 gate —
+ // airborne remotes must NOT pre-seed the
+ // ContactPlane, otherwise AdjustOffset's snap-to-plane
+ // branch zeroes the +Z offset every step (same bug
+ // we hit on the local jump).
+ isOnGround: !rm.Airborne,
+ body: rm.Body, // persist ContactPlane across frames for slope tracking
+ // Retail default physics state includes EdgeSlide.
+ // Remote dead-reckoning should exercise the same
+ // edge/cliff branch as local movement.
+ moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
+ // Fix #42 (2026-05-05): skip the moving remote's
+ // own ShadowEntry. _animatedEntities is keyed by
+ // entity.Id so kv.Key matches the EntityId the
+ // ShadowObjectRegistry has for this remote.
+ // Without this, the airborne sweep collides with
+ // the remote's own cylinder and produces ~1m of
+ // horizontal drift on the first jump frame
+ // (validated by [SWEEP-OBJ] traces).
+ movingEntityId: ae.Entity.Id);
+
+ rm.Body.Position = resolveResult.Position;
+ if (resolveResult.CellId != 0)
+ rm.CellId = resolveResult.CellId;
+
+ // #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
+ // de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail
+ // re-registers a moved object's shadow every transition step
+ // (SetPositionInternal → remove/add_shadows_to_cells, Ghidra
+ // 0x00515330) so its m_position — the RESOLVED position — is what
+ // OTHER creatures collide against. acdream's shadow otherwise only
+ // syncs to the RAW server pos on UpdatePosition, so neighbours would
+ // de-overlap against each other's OVERLAPPING shadows and any spread
+ // would be discarded on the next UP (never accumulating), AND the
+ // player would collide with a shadow offset from where the monster
+ // renders (the reverted attempt's "stuck on an invisible monster").
+ // Syncing the shadow to the resolved body every tick makes the
+ // de-overlap PERSIST and keeps collision == render. Re-flood is cheap
+ // MOVEMENT-GATED (#184 review): re-flood only when the resolved
+ // body moved > ~1 cm since the last shadow registration. This is
+ // SAFE now that the per-UP :5669 sync is players-only — the NPC
+ // shadow's only writers are this loop + the UP-branch tail, both to
+ // the resolved body, so a net-stationary (de-overlapped, sweep-
+ // blocked) creature keeps its correct shadow and never re-floods,
+ // while a moving/de-overlapping crowd (which moves every tick) still
+ // syncs every tick. Bounds the per-tick RegisterMultiPart flood cost
+ // to actually-moving remotes — the perf risk the review flagged for
+ // a packed town. (In-place shadow-move + cell-relink-on-change is a
+ // further optimization if profiling still shows churn.)
+ if (System.Numerics.Vector3.DistanceSquared(
+ rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f)
+ {
+ SyncRemoteShadowToBody(ae.Entity.Id, rm, liveCenterX, liveCenterY);
+ }
+
+ // #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
+ // (pc:282699-282715) runs after EVERY SetPositionInternal —
+ // remote objects included; a VectorUpdate-launched jump arc
+ // is ordinary object physics in retail. acdream ported the
+ // velocity reflection for the LOCAL player only (L.3a,
+ // PlayerMovementController ~:940), so a remote jumping into
+ // a dungeon ceiling had its POSITION pinned by the sweep
+ // while its +Z velocity kept integrating — the char hovered
+ // at the roof until gravity burned the arc off, landing
+ // late (user report, 0x0007 dungeon). Mirror the local
+ // site exactly:
+ // v_new = v − (1 + elasticity)·dot(v, n)·n
+ // with the AD-25 suppression (bounce only when airborne
+ // before AND after — corridor slides and landings don't
+ // reflect; the landing snap below keeps its
+ // `Velocity.Z <= 0` gate intact). Inelastic movers
+ // (missiles, later) zero out instead.
+ if (resolveResult.CollisionNormalValid)
+ {
+ bool prevOnWalkable = rm.Body.OnWalkable;
+ bool nowOnWalkable = resolveResult.IsOnGround;
+ bool applyBounce = rm.Body.State.HasFlag(
+ AcDream.Core.Physics.PhysicsStateFlags.Sledding)
+ ? !(prevOnWalkable && nowOnWalkable)
+ : (!prevOnWalkable && !nowOnWalkable);
+ if (applyBounce)
+ {
+ if (rm.Body.State.HasFlag(
+ AcDream.Core.Physics.PhysicsStateFlags.Inelastic))
+ {
+ rm.Body.Velocity = System.Numerics.Vector3.Zero;
+ }
+ else
+ {
+ var vRem = rm.Body.Velocity;
+ var nRem = resolveResult.CollisionNormal;
+ float dotVN = System.Numerics.Vector3.Dot(vRem, nRem);
+ if (dotVN < 0f)
+ {
+ rm.Body.Velocity =
+ vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f)));
+ if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
+ Console.WriteLine(
+ $"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}");
+ }
+ }
+ }
+ }
+
+ // K-fix15 (2026-04-26): post-resolve landing
+ // detection for airborne remotes. Mirrors
+ // PlayerMovementController's local-player landing
+ // path: when the resolver says we're on ground AND
+ // velocity is no longer pointing up, transition
+ // back to grounded — clear Airborne, restore
+ // Contact + OnWalkable, remove Gravity, zero any
+ // residual downward velocity, and trigger
+ // HitGround so the sequencer can swap from
+ // Falling → idle/locomotion. Without this, an
+ // airborne remote falls through the floor (gravity
+ // keeps building Velocity.Z negative until the
+ // sphere-sweep clamps each frame, but Airborne
+ // stays true forever).
+ if (rm.Airborne
+ && resolveResult.IsOnGround
+ && rm.Body.Velocity.Z <= 0f)
+ {
+ rm.Airborne = false;
+ // #184 (2026-07-07): clear the interp queue on landing (mirrors
+ // the player-remote landing). Airborne UPs hard-snap and never
+ // Enqueue, so any pre-jump waypoints are stale; without this the
+ // first grounded catch-up after touchdown chases them backward.
+ rm.Interp.Clear();
+ rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
+ | AcDream.Core.Physics.TransientStateFlags.OnWalkable;
+ rm.Body.Velocity = new System.Numerics.Vector3(
+ rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f);
+ // #161: HitGround MUST run with the Gravity state
+ // bit still set — CMotionInterp::HitGround
+ // (0x00528ac0) gates on state&0x400 (retail never
+ // clears GRAVITY on landing; it's a persistent
+ // object property). Clearing it first made this
+ // re-apply a silent no-op, which is why the
+ // falling pose never exited. The re-apply
+ // dispatches the PRESERVED pre-fall forward
+ // command through the funnel → the motion table
+ // plays the Falling→X landing link. (The old
+ // K-fix17 forced SetCycle is deleted: it read the
+ // then-clobbered InterpretedState.ForwardCommand
+ // — 0x40000015 — and re-set the very Falling
+ // cycle it meant to clear.)
+ // R4-V5 (closes the V4 wiring-contract gap the
+ // adversarial review caught): retail order —
+ // minterp first, then moveto (MovementManager::
+ // HitGround 0x00524300, §2d — the R5-V5 facade
+ // relay). Re-arms a moveto suspended by the
+ // airborne UseTime contact gate; without it a
+ // chasing NPC that lands stalls until ACE's
+ // ~1 Hz re-emit.
+ rm.Movement.HitGround();
+ // DR bookkeeping only (partner of the jump-start
+ // `State |= Gravity`): stops the per-tick gravity
+ // integration for the grounded body.
+ rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
+
+ if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
+ Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
+ }
+ }
+
+ ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
+ if (rm.CellId != 0)
+ ae.Entity.ParentCellId = rm.CellId;
+ ae.Entity.Rotation = rm.Body.Orientation;
+ }
+
+ // R5-V3 (#171): retail UpdateObjectInternal tail —
+ // PositionManager::UseTime (0x005156b0, call @0x005159b3,
+ // right after CPartArray::HandleMovement, UNCONDITIONAL for
+ // every entity in both grounded and airborne branches): the
+ // sticky 1 s lease watchdog (StickyManager::UseTime
+ // 0x00555610 — a stick not re-issued by a fresh server arm
+ // within 1 s tears itself down). No-op while nothing is stuck.
+ rm.Host?.PositionManager.UseTime();
+ }
+
+ ///
+ /// R4-V5 / R5-V2: the per-tick
+ /// drive (retail MovementManager::UseTime 0x005242f0 — the moveto
+ /// side's steering, arrival, fail-distance; R5-V5 facade relay). Moved from
+ /// GameWindow (#184 Slice 2a); the DR tick is its only caller.
+ ///
+ private static void TickRemoteMoveTo(RemoteMotion rm)
+ {
+ rm.Movement.UseTime();
+ }
+
+ ///
+ /// R5-V3 (#171): apply a
+ /// written by PositionManager.AdjustOffset onto a body — acdream's
+ /// stand-in for retail's Frame::combine in
+ /// CPhysicsObj::UpdatePositionInternal (0x00512c30, combine
+ /// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
+ /// globaltolocalvec output — 0x00555430), so combining = rotating it
+ /// out by the body orientation. An untouched (identity) rotation means "no
+ /// turn"; the P5 pin (identity quaternion = heading 0) makes compass addition
+ /// the exact frame-combine here. Moved from GameWindow (#184 Slice 2a); the
+ /// DR tick is its only caller.
+ ///
+ private static void ApplyPositionManagerDelta(
+ AcDream.Core.Physics.PhysicsBody body,
+ AcDream.Core.Physics.Motion.MotionDeltaFrame delta)
+ {
+ if (delta.Origin != System.Numerics.Vector3.Zero)
+ body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation);
+ if (!delta.Orientation.IsIdentity)
+ body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading(
+ body.Orientation,
+ AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation)
+ + delta.GetHeading());
+ }
+
+ ///
+ /// #184 — shadow-follows-resolved. Re-register a remote creature's collision
+ /// SHADOW at its RESOLVED body position, so OTHER creatures (and the player)
+ /// de-overlap / collide against where the monster actually IS (== where it
+ /// renders), not the raw overlapping server position. Retail re-registers a
+ /// moved object's shadow every accepted transition step (SetPositionInternal
+ /// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is
+ /// passed in (/)
+ /// rather than snapshotted, since it moves on recentre. Updates
+ /// so callers can movement-gate.
+ /// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC
+ /// UP-branch tail.
+ ///
+ public void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm, int liveCenterX, int liveCenterY)
+ {
+ int shLbX = (int)((rm.CellId >> 24) & 0xFFu);
+ int shLbY = (int)((rm.CellId >> 16) & 0xFFu);
+ float shOffX = (shLbX - liveCenterX) * 192f;
+ float shOffY = (shLbY - liveCenterY) * 192f;
+ _physicsEngine.ShadowObjects.UpdatePosition(
+ entityId, rm.Body.Position, rm.Body.Orientation,
+ shOffX, shOffY, rm.CellId, seedCellId: rm.CellId);
+ rm.LastShadowSyncPos = rm.Body.Position;
+ }
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 6d6f2737..f06d9d91 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -151,6 +151,12 @@ public sealed class GameWindow : IDisposable
// ConcurrentDictionary inside makes cross-thread access safe.
private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache = new();
+ // #184 Slice 2a: the per-remote dead-reckoning tick, extracted out of the
+ // >10k-line TickAnimations (Code Structure Rule 1). Assigned in the ctor
+ // once the injected shared helpers exist as method groups (GetSetupCylinder,
+ // ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
+ private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
+
// Step 4: portal-based interior cell visibility.
private readonly CellVisibility _cellVisibility = new();
@@ -270,7 +276,10 @@ public sealed class GameWindow : IDisposable
///
private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new();
- private sealed class AnimatedEntity
+ // #184 Slice 2a: internal (was private) so the extracted
+ // RemotePhysicsUpdater in AcDream.App.Physics can take it by type. Matches
+ // RemoteMotion's existing internal visibility.
+ internal sealed class AnimatedEntity
{
public required AcDream.Core.World.WorldEntity Entity;
public required DatReaderWriter.DBObjs.Setup Setup;
@@ -1030,7 +1039,8 @@ public sealed class GameWindow : IDisposable
private readonly Dictionary _physicsHosts = new();
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
- private const double ServerControlledVelocityStaleSeconds = 0.60;
+ // ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184
+ // Slice 2a — the DR tick's stale-velocity anim-stop was its only user).
private int _liveSpawnReceived; // diagnostics
private int _liveSpawnHydrated;
private int _liveDropReasonNoPos;
@@ -1053,6 +1063,13 @@ public sealed class GameWindow : IDisposable
_uiRegistry = uiRegistry;
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
+ // #184 Slice 2a: the extracted per-remote DR tick. Shares GameWindow's
+ // PhysicsEngine; the two helpers it also needs but that have callers
+ // outside the DR loop (GetSetupCylinder — the player's own cylinder +
+ // moveto/sticky radii; ApplyServerControlledVelocityCycle — the UP
+ // handler) stay on GameWindow and are injected as delegates.
+ _remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater(
+ _physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle);
}
///
@@ -4591,56 +4608,10 @@ public sealed class GameWindow : IDisposable
return (setup.Radius * scale, setup.Height * scale);
}
- ///
- /// R5-V3 (#171): apply a
- /// written by PositionManager.AdjustOffset onto a body — acdream's
- /// stand-in for retail's Frame::combine in
- /// CPhysicsObj::UpdatePositionInternal (0x00512c30, combine
- /// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
- /// globaltolocalvec output — 0x00555430), so combining = rotating it
- /// out by the body orientation. The rotation carries a RELATIVE heading;
- /// an untouched (identity) rotation means "no turn" — retail distinguishes
- /// an unwritten offset rotation from set_heading(0) by the identity
- /// VALUE, not the angle, and the P5 pin (identity quaternion = heading 0)
- /// makes compass addition the exact frame-combine here.
- ///
- private static void ApplyPositionManagerDelta(
- AcDream.Core.Physics.PhysicsBody body,
- AcDream.Core.Physics.Motion.MotionDeltaFrame delta)
- {
- if (delta.Origin != System.Numerics.Vector3.Zero)
- body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation);
- if (!delta.Orientation.IsIdentity)
- body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading(
- body.Orientation,
- AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation)
- + delta.GetHeading());
- }
-
- ///
- /// #184 — shadow-follows-resolved. Re-register a remote creature's collision
- /// SHADOW at its RESOLVED body position, so OTHER creatures (and the player)
- /// de-overlap / collide against where the monster actually IS (== where it
- /// renders), not the raw overlapping server position. Retail re-registers a
- /// moved object's shadow every accepted transition step (SetPositionInternal
- /// → remove/add_shadows_to_cells, Ghidra 0x00515330); this is the acdream
- /// equivalent driven from the remote DR loop. worldOffset is recomputed from
- /// .CellId + the streaming centre, exactly as the UP
- /// handler derives origin (:5618) — and is DEAD anyway when seedCellId
- /// is supplied (it only feeds the short-circuited DeriveOutdoorSeed). Updates
- /// so callers can movement-gate.
- ///
- private void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm)
- {
- int shLbX = (int)((rm.CellId >> 24) & 0xFFu);
- int shLbY = (int)((rm.CellId >> 16) & 0xFFu);
- float shOffX = (shLbX - _liveCenterX) * 192f;
- float shOffY = (shLbY - _liveCenterY) * 192f;
- _physicsEngine.ShadowObjects.UpdatePosition(
- entityId, rm.Body.Position, rm.Body.Orientation,
- shOffX, shOffY, rm.CellId, seedCellId: rm.CellId);
- rm.LastShadowSyncPos = rm.Body.Position;
- }
+ // #184 Slice 2a: ApplyPositionManagerDelta + SyncRemoteShadowToBody moved to
+ // AcDream.App.Physics.RemotePhysicsUpdater. ApplyPositionManagerDelta had no
+ // caller outside the DR tick; SyncRemoteShadowToBody is now called back via
+ // _remotePhysicsUpdater from the NPC UP-branch tail (below).
///
/// R5-V4: retail CPhysicsObj::stick_to_object (0x005127e0) — the
@@ -4848,23 +4819,10 @@ public sealed class GameWindow : IDisposable
return false;
}
- ///
- /// R4-V5 / R5-V2: the per-tick
- /// drive (retail MovementManager::UseTime 0x005242f0 — the moveto
- /// side's steering, arrival, fail-distance; R5-V5 facade relay). The P4
- /// TargetTracker POLL is gone (R5-V2): target-position delivery now flows
- /// through the
- /// voyeur system — the target's own HandleTargetting pushes updates
- /// into this entity's HandleUpdateTarget, which is called
- /// unconditionally per remote in the per-tick loop BEFORE this drive
- /// (retail UpdateObjectInternal order). Safe for any remote: a
- /// manager with no armed moveto no-ops, airborne bodies held by
- /// UseTime's contact gate.
- ///
- private void TickRemoteMoveTo(RemoteMotion rm)
- {
- rm.Movement.UseTime();
- }
+ // #184 Slice 2a: TickRemoteMoveTo (retail MovementManager::UseTime
+ // 0x005242f0 — moveto steering / arrival / fail-distance; the P4 poll is
+ // gone, TargetManager voyeur delivers positions) moved to
+ // RemotePhysicsUpdater. The DR tick was its only caller.
///
/// Phase 6.6: the server says an entity's motion has changed. Look up
@@ -6153,7 +6111,8 @@ public sealed class GameWindow : IDisposable
// the per-tick loop skips). The per-tick loop keeps it current between
// UPs, movement-gated. rmState.CellId is the server cell adopted above.
if (rmState.CellId != 0)
- SyncRemoteShadowToBody(entity.Id, rmState);
+ _remotePhysicsUpdater.SyncRemoteShadowToBody(
+ entity.Id, rmState, _liveCenterX, _liveCenterY);
entity.SetPosition(rmState.Body.Position);
entity.Rotation = rmState.Body.Orientation;
@@ -10181,696 +10140,7 @@ public sealed class GameWindow : IDisposable
// TickAnimations advance.
&& rm.LastServerPosTime > 0)
{
- // R5-V2: retail UpdateObjectInternal ticks TargetManager::
- // HandleTargetting UNCONDITIONALLY per entity, BEFORE the
- // movement managers' UseTime. This is where this entity, as a
- // watched target, pushes its position to its voyeurs (any entity
- // moving-to it), and where its own target-info staleness times
- // out. Runs for every remote regardless of the grounded/airborne
- // branch below (which drive MoveToManager.UseTime via
- // TickRemoteMoveTo). No-op for entities with no target + no voyeurs.
- rm.Host?.HandleTargetting();
-
- if (IsPlayerGuid(serverGuid) && !rm.Airborne)
- {
- // ── L.3 M2/M3 (2026-05-05): queue + anim chase for grounded player remotes ──
- //
- // Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md +
- // 04-interp-manager.md +
- // 05-position-manager-and-partarray.md):
- //
- // - For a grounded REMOTE player, m_velocityVector stays at 0.
- // - apply_current_movement is NEVER called per tick on remotes
- // (it's the local-player-only velocity feed).
- // - UpdatePhysicsInternal's translation step is gated on
- // velocity² > 0, so it's a no-op when body.Velocity = 0.
- // - ResolveWithTransition is NOT called — the server already
- // collision-resolved the broadcast position.
- // - Per-tick body translation per retail UpdatePositionInternal:
- // 1. CPartArray::Update writes anim root motion (body-local
- // seqVel × dt) into the local frame.
- // 2. PositionManager::adjust_offset OVERWRITES the local
- // frame's origin with the queue catch-up vector when
- // the queue is active and the head is not yet reached
- // — REPLACE, not additive.
- // 3. Frame::combine composes the local frame with the
- // body's world pose.
- // Net: catch-up replaces anim during the chase phase, anim
- // stands when the queue is empty / head reached. PositionManager.
- // ComputeOffset implements this exact REPLACE dichotomy.
- //
- // Airborne player remotes (rm.Airborne) and NPCs fall through to
- // the legacy path below — unchanged from main per the M2 plan.
- System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity
- ?? System.Numerics.Vector3.Zero;
- System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
- ?? System.Numerics.Vector3.Zero;
-
- // Step 1: transient flags (Contact + OnWalkable for grounded;
- // Active always so UpdatePhysicsInternal doesn't early-return).
- if (!rm.Airborne)
- {
- rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
- | AcDream.Core.Physics.TransientStateFlags.OnWalkable
- | AcDream.Core.Physics.TransientStateFlags.Active;
-
- // For grounded remotes the body should not be carrying
- // velocity — retail's m_velocityVector for a remote is
- // 0 unless the server explicitly pushed one. Clear any
- // stale velocity from a prior airborne arc so
- // UpdatePhysicsInternal doesn't double-apply it on top
- // of the seqVel-driven ComputeOffset translation below.
- rm.Body.Velocity = System.Numerics.Vector3.Zero;
- }
- else
- {
- rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
- }
-
- // R4-V5 glide fix (2026-07-03 user report: a retail
- // player using an object GLIDES to it — position moves
- // via the UP queue but no walk/run legs): remote
- // PLAYERS' MoveToManagers were armed by mt-6 but never
- // TICKED — the V4 UseTime slot lived only in the
- // NPC/legacy branch below, gated !IsPlayerGuid
- // (inherited from the deleted RemoteMoveToDriver's
- // NPC-only scope). Retail ticks every entity's
- // MovementManager (UpdateObjectInternal has no entity-
- // class fork). The manager's dispatches produce the
- // locomotion cycle through the funnel sink (the LEGS);
- // position stays queue-chased per the L.3 M2 spec, so
- // the two compose exactly like an NPC's tick.
- TickRemoteMoveTo(rm);
-
- // Step 2 (M3): queue + anim translation via PositionManager.
- // ComputeOffset returns:
- // - Vector3.Zero when queue is empty AND seqVel is zero
- // (idle remote between UPs after head reached) — body
- // stays still.
- // - Direction × min(catchUpSpeed × dt, dist) when the
- // queue is active and head is not reached — body chases
- // the head waypoint at up to 2× motion-table max speed
- // (REPLACES anim for this frame).
- // - Anim root motion (seqVel × dt rotated into world) when
- // the queue is empty OR head is within DesiredDistance —
- // body advances with the locomotion cycle's baked
- // velocity, keeping legs and body pace synchronized.
- // - Blip-to-tail (tail − body) when fail_count > 3.
- float maxSpeed = rm.Motion.GetMaxSpeed();
- // Slope-staircase fix (2026-05-05): sample terrain normal
- // at the body's current XY so PositionManager can project
- // the seqVel-only fallback onto the local slope. Without
- // this, the queue-empty interval between UPs left Z flat
- // (anim cycles bake Z=0 body-local) — visible ~5 Hz
- // staircase when a remote runs up/down hills. The
- // projection is a no-op on flat ground.
- System.Numerics.Vector3? terrainNormal = _physicsEngine.SampleTerrainNormal(
- rm.Body.Position.X, rm.Body.Position.Y);
-
- System.Numerics.Vector3 bodyPosBefore = rm.Body.Position;
- System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
- dt: (double)dt,
- currentBodyPosition: rm.Body.Position,
- seqVel: seqVel,
- ori: rm.Body.Orientation,
- interp: rm.Interp,
- maxSpeed: maxSpeed,
- terrainNormal: terrainNormal);
- // R5-V3 (#171): retail chains Interpolation → Sticky over
- // ONE shared delta frame (PositionManager::adjust_offset
- // 0x00555190). The combiner's catch-up IS acdream's
- // interpolation stage, so its offset SEEDS the frame
- // (converted to mover-local) and StickyManager::
- // adjust_offset OVERWRITES it when armed+initialized
- // (0x00555430 assigns m_fOrigin rather than accumulating).
- // With no stick armed the frame comes back untouched and
- // this reduces to the pre-V3 `Position += offset`.
- if (rm.Host is { } plHost)
- {
- var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
- {
- Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
- rm.Body.Orientation, offset),
- };
- plHost.PositionManager.AdjustOffset(pmDelta, dt);
- ApplyPositionManagerDelta(rm.Body, pmDelta);
- }
- else
- {
- rm.Body.Position += offset;
- }
-
- // Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1.
- // Prints per-tick body Z trajectory + queue state + projected
- // offset.Z so we can grep before/after the fix and confirm Z
- // changes continuously between UPs on slopes (no flat
- // intervals followed by snaps).
- if (System.Environment.GetEnvironmentVariable("ACDREAM_SLOPE_DIAG") == "1")
- {
- bool queueActive = rm.Interp.IsActive;
- float nz = terrainNormal?.Z ?? 1.0f;
- System.Console.WriteLine(
- $"[SLOPE] guid={serverGuid:X8} bodyZ={bodyPosBefore.Z:F3}->{rm.Body.Position.Z:F3} "
- + $"offset=({offset.X:F3},{offset.Y:F3},{offset.Z:F3}) "
- + $"queue={queueActive} cpN.Z={nz:F3}");
- }
-
- // Step 2.5: angular velocity → body orientation. Prefer
- // ObservedOmega (set explicitly in OnLiveMotionUpdated from
- // the wire's TurnCommand + signed TurnSpeed) over the
- // sequencer's synthesized omega: when the player runs in
- // a circle ACE broadcasts ForwardCommand=RunForward AND
- // TurnCommand=TurnLeft on the same UpdateMotion. The
- // sequencer's animCycle picker chooses RunForward (legs
- // running), whose synthesized CurrentOmega is zero. Body
- // would not rotate between UPs and body.Velocity stays in
- // an out-of-date world direction, producing the
- // user-reported "rectangle when running circles" effect.
- // ObservedOmega has the correct turn rate even when the
- // visible cycle is RunForward.
- System.Numerics.Vector3 omegaToApply =
- rm.ObservedOmega.LengthSquared() > 1e-9f
- ? rm.ObservedOmega
- : seqOmega;
- if (omegaToApply.LengthSquared() > 1e-9f)
- {
- float angleDelta = omegaToApply.Length() * (float)dt;
- System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
- var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
- rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
- System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
-
- // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): print seqOmega direction
- // once per remote per ~1 second so we can confirm whether the omega
- // sign actually being applied matches the retail-observed turn
- // direction. Z>0 = CCW (TurnLeft); Z<0 = CW (TurnRight).
- if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
- {
- double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
- if (nowSec - rm.LastOmegaDiagLogTime > 0.5)
- {
- uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
- System.Console.WriteLine(
- $"[OMEGA_DIAG] guid={serverGuid:X8} motion=0x{seqMotion:X8} "
- + $"omegaApplied.Z={omegaToApply.Z:F3} "
- + $"(seq.Z={seqOmega.Z:F3} obs.Z={rm.ObservedOmega.Z:F3}) "
- + $"(Z>0=CCW=TurnLeft, Z<0=CW=TurnRight)");
- rm.LastOmegaDiagLogTime = nowSec;
- }
- }
- }
-
- // Step 3: calc_acceleration sets body.Acceleration from the Gravity flag
- // (mirrors retail CPhysicsObj::calc_acceleration @ FUN_00511420, called
- // per-frame in update_object). Without this, body.Acceleration stays stale
- // or zero → gravity never decays jump velocity → endless rise on jumps.
- rm.Body.calc_acceleration();
-
- // Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²).
- rm.Body.UpdatePhysicsInternal(dt);
-
- // Step 4b INTENTIONALLY OMITTED in M2:
- // ResolveWithTransition is NOT called — the server has
- // already collision-resolved the broadcast position, and
- // running our sweep on tiny per-frame queue catch-up deltas
- // amplifies micro-bounces into visible position blips
- // (issue #40 staircase + flat-ground blips). Per retail
- // spec the per-tick body advance for a remote is purely
- // the queue catch-up; collision is the sender's problem.
- //
- // Step 5 (landing fallback) is unreachable in this branch —
- // we're gated on !rm.Airborne. Airborne player remotes fall
- // through to the legacy path below where K-fix15 still fires.
-
- // Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1).
- // Track the maximum sequencer velocity magnitude seen since
- // the last UpdatePosition arrival (carried on the
- // RemoteMotion struct), then on each UP arrival the
- // OnLivePositionUpdated path prints the comparison against
- // the server's actual broadcast pace
- // ((LastServerPos - PrevServerPos) / Δt). This guarantees
- // both sides are sampled during the same window and the
- // ratio reflects the real overshoot.
- if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
- {
- // body.Velocity is now the source of bulk translation
- // (set above by apply_current_movement). Track its
- // magnitude so VEL_DIAG can compare against the actual
- // server broadcast pace.
- float seqSpeedNow = rm.Body.Velocity.Length();
- if (seqSpeedNow > rm.MaxSeqSpeedSinceLastUP)
- rm.MaxSeqSpeedSinceLastUP = seqSpeedNow;
- }
-
- ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
- if (rm.CellId != 0)
- ae.Entity.ParentCellId = rm.CellId;
- ae.Entity.Rotation = rm.Body.Orientation;
- }
- else
- {
- // ── LEGACY PATH (UNCHANGED — kept until Task 8 cleanup) ──
- //
- // Stop detection is handled explicitly on packet receipt:
- // - UpdateMotion with ForwardCommand flag CLEARED → Ready.
- // - UpdatePosition with HasVelocity flag CLEARED → StopCompletely.
- // Both map to retail's "flag-absent = Invalid = reset to
- // default" semantics (FUN_0051F260 bulk-copy). No timer-based
- // inference needed — the server sends the right signal every
- // time a remote stops.
-
- // Retail per-tick motion pipeline applied to every remote.
- // Mirrors retail FUN_00515020 update_object → FUN_00513730
- // UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal:
- //
- // 1. apply_current_movement (FUN_00529210) — recomputes
- // body.Velocity from InterpretedState via get_state_velocity.
- // 2. Pull omega from the sequencer (baked MotionData.Omega
- // for TurnRight / TurnLeft cycles, scaled by speedMod).
- // 3. body.update_object(now) — Euler-integrates
- // position += Velocity × dt + 0.5 × Accel × dt² AND
- // orientation += omega × dt.
- //
- // On UpdatePosition receipt we hard-snap body.Position and
- // body.Orientation — if integration matched server physics,
- // each snap is small/invisible.
- double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
-
- // Step 1: re-apply current motion commands → body.Velocity.
- // Forces OnWalkable + Contact so the gate in apply_current_movement
- // always succeeds (remotes are server-authoritative; we don't
- // simulate airborne physics for them).
- //
- // K-fix9 (2026-04-26): SKIP this when the remote is airborne.
- // Otherwise the force-OnWalkable + apply_current_movement
- // path stomps the +Z velocity we set in OnLiveVectorUpdated,
- // and gravity never gets to integrate the arc. The airborne
- // body keeps the launch velocity from the VectorUpdate;
- // UpdatePhysicsInternal below applies gravity each tick;
- // the next UpdatePosition snaps to the new ground location
- // and re-grounds.
- if (!rm.Airborne)
- {
- rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
- | AcDream.Core.Physics.TransientStateFlags.OnWalkable
- | AcDream.Core.Physics.TransientStateFlags.Active;
-
- // #184 (2026-07-07): a grounded remote carries NO translation
- // velocity. Its per-tick movement is the interp CATCH-UP toward
- // the MoveOrTeleport-queued server waypoint (computed at the
- // sticky-compose site below), which the KEPT ResolveWithTransition
- // sweep de-overlaps against neighbours — and the resolved position
- // is written back into the SHADOW (below) so the de-overlap
- // persists and neighbours collide against the resolved body, not
- // the raw server pos. This REPLACES the old synth-velocity model
- // (get_state_velocity / SERVERVEL Body.Velocity = ServerVelocity):
- // retail's UpdateObjectInternal (0x005156b0) has NO synth-velocity
- // leg — a remote translates by adjust_offset and the UP is a gentle
- // target. Matches Path A's grounded model (:10113); clearing
- // velocity mirrors Path A (:10125).
- rm.Body.Velocity = System.Numerics.Vector3.Zero;
-
- // Stale server-velocity → stop the locomotion CYCLE (the legs).
- // ANIM ONLY — translation is the catch-up. Kept verbatim (same
- // !moveToArmed && !stickyArmed gate) from the old SERVERVEL branch
- // so a scripted-path NPC that stops server-side drops out of its
- // walk/run cycle; ApplyServerControlledVelocityCycle selects the
- // anim from ServerVelocity, independent of Body.Velocity.
- bool moveToArmed = rm.MoveTo is
- { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid };
- bool stickyArmed =
- (rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
- if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity
- && !moveToArmed && !stickyArmed)
- {
- double velocityAge = nowSec - rm.LastServerPosTime;
- if (velocityAge > ServerControlledVelocityStaleSeconds)
- {
- rm.ServerVelocity = System.Numerics.Vector3.Zero;
- rm.HasServerVelocity = false;
- ApplyServerControlledVelocityCycle(
- serverGuid,
- ae,
- rm,
- System.Numerics.Vector3.Zero);
- }
- }
-
- // R4-V4: tick the MoveToManager UNCONDITIONALLY (retail
- // MovementManager::UseTime per tick, UpdateObjectInternal call
- // @0x00515998) — UseTime runs HandleMoveToPosition /
- // HandleTurnToHeading (steering + arrival + fail-distance),
- // dispatching its per-node locomotion (turn / RunForward) through
- // the sink (the LEGS). Position comes from the catch-up; legs from
- // this per-node dispatch + the funnel. The #170-deleted per-frame
- // apply_current_movement is NOT reintroduced.
- TickRemoteMoveTo(rm);
- }
- else
- {
- // Airborne — keep Active flag (so UpdatePhysicsInternal
- // doesn't early-return) but DON'T set Contact / OnWalkable.
- rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
- }
-
- // Step 2: integrate rotation manually per tick. We can't
- // rely on PhysicsBody.update_object here — its MinQuantum
- // gate (1/30 s) causes it to SKIP integration when our
- // 60fps render dt (~0.016s) is below the quantum, meaning
- // rotation never advances. Measured snap per UP was ~129°
- // = the full expected 1s × 2.24 rad/s, confirming zero
- // between-tick rotation.
- //
- // Manual integration matches retail's FUN_005256b0
- // apply_physics (Orientation *= quat(ω × dt)). Use
- // ObservedOmega derived from server UP rotation deltas so
- // the rate exactly matches server physics — hard-snap on
- // next UP becomes invisible by construction.
- rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object
- if (rm.ObservedOmega.LengthSquared() > 1e-8f)
- {
- float omegaMag = rm.ObservedOmega.Length();
- var axis = rm.ObservedOmega / omegaMag;
- float angle = omegaMag * dt;
- var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
- rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
- System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
- }
-
- // Step 3: integrate physics — retail FUN_005111D0
- // UpdatePhysicsInternal. Pure Euler:
- // position += velocity × dt + 0.5 × accel × dt²
- //
- // Call UpdatePhysicsInternal DIRECTLY rather than via
- // PhysicsBody.update_object (FUN_00515020). update_object gates
- // on MinQuantum = 1/30s: at our 60fps render tick (~16ms),
- // deltaTime < MinQuantum → early return AND LastUpdateTime is
- // NOT advanced. Net effect: position never integrates between
- // UpdatePositions and the only Body.Position changes come
- // from the UP hard-snap, producing a visible teleport-stride
- // on slopes (the "staircase" the user reported).
- //
- // PlayerMovementController.cs:358 calls UpdatePhysicsInternal
- // directly for the same reason. Remote motion mirrors that.
- // Omega is already integrated manually above, so we zero it
- // here to prevent UpdatePhysicsInternal's own omega pass from
- // double-integrating.
- var preIntegratePos = rm.Body.Position;
- // R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation →
- // Sticky over ONE shared delta frame (PositionManager::adjust_offset
- // 0x00555190), composed BEFORE UpdatePhysicsInternal + the transition
- // sweep so collision resolves whichever movement won (preIntegratePos
- // captured first — the sweep covers it).
- // • GROUNDED: the interp CATCH-UP SEEDS the frame (world→local) —
- // the movement source is the adjust_offset walk toward the
- // MoveOrTeleport-queued server waypoint, exactly like Path A
- // (:10173). StickyManager::adjust_offset then OVERWRITES the
- // Origin when armed (0x00555430 ASSIGNS m_fOrigin — the REPLACE
- // dichotomy), so a stuck monster still steers via #171.
- // • AIRBORNE: seed an EMPTY frame (no catch-up — the arc integrates
- // from velocity + gravity, unchanged).
- // Body.Velocity is 0 when grounded (set above), so UpdatePhysicsInternal
- // adds no translation on top of the catch-up — no double-move.
- if (rm.Host is { } npcHost)
- {
- AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta;
- if (!rm.Airborne)
- {
- System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
- ?? System.Numerics.Vector3.Zero;
- float maxSpeedNpc = rm.Motion.GetMaxSpeed();
- System.Numerics.Vector3? terrainNormalNpc =
- _physicsEngine.SampleTerrainNormal(
- rm.Body.Position.X, rm.Body.Position.Y);
- System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
- dt: (double)dt,
- currentBodyPosition: rm.Body.Position,
- seqVel: seqVelNpc,
- ori: rm.Body.Orientation,
- interp: rm.Interp,
- maxSpeed: maxSpeedNpc,
- terrainNormal: terrainNormalNpc);
- pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
- {
- Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
- rm.Body.Orientation, offsetNpc),
- };
- }
- else
- {
- pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
- }
- npcHost.PositionManager.AdjustOffset(pmDelta, dt);
- ApplyPositionManagerDelta(rm.Body, pmDelta);
- }
- else if (!rm.Airborne)
- {
- // No PositionManager host yet (pre-binding): apply the catch-up
- // directly, matching Path A's fallback (:10202).
- System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
- ?? System.Numerics.Vector3.Zero;
- float maxSpeedNpc = rm.Motion.GetMaxSpeed();
- System.Numerics.Vector3? terrainNormalNpc =
- _physicsEngine.SampleTerrainNormal(
- rm.Body.Position.X, rm.Body.Position.Y);
- rm.Body.Position += rm.Position.ComputeOffset(
- dt: (double)dt,
- currentBodyPosition: rm.Body.Position,
- seqVel: seqVelNpc,
- ori: rm.Body.Orientation,
- interp: rm.Interp,
- maxSpeed: maxSpeedNpc,
- terrainNormal: terrainNormalNpc);
- }
- rm.Body.calc_acceleration();
- rm.Body.UpdatePhysicsInternal(dt);
- var postIntegratePos = rm.Body.Position;
-
- // Step 4: collision sweep — retail FUN_00514B90 →
- // FUN_005148A0 → Transition::FindTransitionalPosition.
- // Projects the sphere from preIntegratePos to postIntegratePos
- // through the BSP + terrain, resolving:
- // - terrain Z snap along the slope (fixes the "staircase" where
- // horizontal Euler motion up a slope sinks into rising ground
- // until the next UP pops it up)
- // - indoor BSP walls (via the 6-path dispatcher in BSPQuery)
- // - object collisions via ShadowObjectRegistry
- // - step-up / step-down against walkable ledges
- // ResolveWithTransition is the same call PlayerMovementController
- // uses for the local player; remotes now get the full retail
- // treatment between UpdatePositions instead of pure kinematics.
- //
- // Skipped when rm.CellId == 0 (no UP landed yet — can't build
- // a SpherePath without a starting cell). One-frame grace until
- // the first UP arrives; harmless because the entity is
- // server-freshly-spawned at a valid Z anyway.
- if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0)
- {
- // #184 Slice 3 (2026-07-07): Setup-DERIVED mover sphere so
- // creatures de-overlap at their TRUE radii (a big monster
- // spreads wider, a small one tighter), not the hardcoded
- // human 0.48/1.835. GetSetupCylinder returns (setup.Radius,
- // setup.Height) × ObjScale — the creature's own dat Setup
- // scaled by its wire ObjScale, the same source the local
- // player + moveto/sticky use, and consistent with the
- // spawn-time shadow registration's entScale. Retail seeds
- // the transition from the object's own Setup sphere list ×
- // m_scale (CPhysicsObj::transition 0x00512dc0 → init_sphere;
- // ObjScale from set_description 0x00514f40). This narrows
- // TS-46 (remotes no longer use human dims); the two-scalar
- // API is still a lossy stand-in for retail's full (≤2)
- // sphere list, and stepUp/stepDown stay 0.4 (retail derives
- // those from the Setup too — an adjacent divergence left as-is).
- // Fallback to the human capsule for a shapeless / unresolvable
- // Setup (GetSetupCylinder returns (0,0)); a zero radius would
- // degenerate the sweep.
- var (deR, deH) = GetSetupCylinder(serverGuid, ae.Entity);
- if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
- var resolveResult = _physicsEngine.ResolveWithTransition(
- preIntegratePos, postIntegratePos, rm.CellId,
- sphereRadius: deR,
- sphereHeight: deH,
- stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
- stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
- // K-fix9 (2026-04-26): mirror the K-fix7 gate —
- // airborne remotes must NOT pre-seed the
- // ContactPlane, otherwise AdjustOffset's snap-to-plane
- // branch zeroes the +Z offset every step (same bug
- // we hit on the local jump).
- isOnGround: !rm.Airborne,
- body: rm.Body, // persist ContactPlane across frames for slope tracking
- // Retail default physics state includes EdgeSlide.
- // Remote dead-reckoning should exercise the same
- // edge/cliff branch as local movement.
- moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
- // Fix #42 (2026-05-05): skip the moving remote's
- // own ShadowEntry. _animatedEntities is keyed by
- // entity.Id so kv.Key matches the EntityId the
- // ShadowObjectRegistry has for this remote.
- // Without this, the airborne sweep collides with
- // the remote's own cylinder and produces ~1m of
- // horizontal drift on the first jump frame
- // (validated by [SWEEP-OBJ] traces).
- movingEntityId: kv.Key);
-
- rm.Body.Position = resolveResult.Position;
- if (resolveResult.CellId != 0)
- rm.CellId = resolveResult.CellId;
-
- // #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
- // de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail
- // re-registers a moved object's shadow every transition step
- // (SetPositionInternal → remove/add_shadows_to_cells, Ghidra
- // 0x00515330) so its m_position — the RESOLVED position — is what
- // OTHER creatures collide against. acdream's shadow otherwise only
- // syncs to the RAW server pos on UpdatePosition, so neighbours would
- // de-overlap against each other's OVERLAPPING shadows and any spread
- // would be discarded on the next UP (never accumulating), AND the
- // player would collide with a shadow offset from where the monster
- // renders (the reverted attempt's "stuck on an invisible monster").
- // Syncing the shadow to the resolved body every tick makes the
- // de-overlap PERSIST and keeps collision == render. Re-flood is cheap
- // MOVEMENT-GATED (#184 review): re-flood only when the resolved
- // body moved > ~1 cm since the last shadow registration. This is
- // SAFE now that the per-UP :5669 sync is players-only — the NPC
- // shadow's only writers are this loop + the UP-branch tail, both to
- // the resolved body, so a net-stationary (de-overlapped, sweep-
- // blocked) creature keeps its correct shadow and never re-floods,
- // while a moving/de-overlapping crowd (which moves every tick) still
- // syncs every tick. Bounds the per-tick RegisterMultiPart flood cost
- // to actually-moving remotes — the perf risk the review flagged for
- // a packed town. (In-place shadow-move + cell-relink-on-change is a
- // further optimization if profiling still shows churn.)
- if (System.Numerics.Vector3.DistanceSquared(
- rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f)
- {
- SyncRemoteShadowToBody(kv.Key, rm);
- }
-
- // #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
- // (pc:282699-282715) runs after EVERY SetPositionInternal —
- // remote objects included; a VectorUpdate-launched jump arc
- // is ordinary object physics in retail. acdream ported the
- // velocity reflection for the LOCAL player only (L.3a,
- // PlayerMovementController ~:940), so a remote jumping into
- // a dungeon ceiling had its POSITION pinned by the sweep
- // while its +Z velocity kept integrating — the char hovered
- // at the roof until gravity burned the arc off, landing
- // late (user report, 0x0007 dungeon). Mirror the local
- // site exactly:
- // v_new = v − (1 + elasticity)·dot(v, n)·n
- // with the AD-25 suppression (bounce only when airborne
- // before AND after — corridor slides and landings don't
- // reflect; the landing snap below keeps its
- // `Velocity.Z <= 0` gate intact). Inelastic movers
- // (missiles, later) zero out instead.
- if (resolveResult.CollisionNormalValid)
- {
- bool prevOnWalkable = rm.Body.OnWalkable;
- bool nowOnWalkable = resolveResult.IsOnGround;
- bool applyBounce = rm.Body.State.HasFlag(
- AcDream.Core.Physics.PhysicsStateFlags.Sledding)
- ? !(prevOnWalkable && nowOnWalkable)
- : (!prevOnWalkable && !nowOnWalkable);
- if (applyBounce)
- {
- if (rm.Body.State.HasFlag(
- AcDream.Core.Physics.PhysicsStateFlags.Inelastic))
- {
- rm.Body.Velocity = System.Numerics.Vector3.Zero;
- }
- else
- {
- var vRem = rm.Body.Velocity;
- var nRem = resolveResult.CollisionNormal;
- float dotVN = System.Numerics.Vector3.Dot(vRem, nRem);
- if (dotVN < 0f)
- {
- rm.Body.Velocity =
- vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f)));
- if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
- Console.WriteLine(
- $"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}");
- }
- }
- }
- }
-
- // K-fix15 (2026-04-26): post-resolve landing
- // detection for airborne remotes. Mirrors
- // PlayerMovementController's local-player landing
- // path: when the resolver says we're on ground AND
- // velocity is no longer pointing up, transition
- // back to grounded — clear Airborne, restore
- // Contact + OnWalkable, remove Gravity, zero any
- // residual downward velocity, and trigger
- // HitGround so the sequencer can swap from
- // Falling → idle/locomotion. Without this, an
- // airborne remote falls through the floor (gravity
- // keeps building Velocity.Z negative until the
- // sphere-sweep clamps each frame, but Airborne
- // stays true forever).
- if (rm.Airborne
- && resolveResult.IsOnGround
- && rm.Body.Velocity.Z <= 0f)
- {
- rm.Airborne = false;
- // #184 (2026-07-07): clear the interp queue on landing (mirrors
- // the player-remote landing). Airborne UPs hard-snap and never
- // Enqueue, so any pre-jump waypoints are stale; without this the
- // first grounded catch-up after touchdown chases them backward.
- rm.Interp.Clear();
- rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
- | AcDream.Core.Physics.TransientStateFlags.OnWalkable;
- rm.Body.Velocity = new System.Numerics.Vector3(
- rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f);
- // #161: HitGround MUST run with the Gravity state
- // bit still set — CMotionInterp::HitGround
- // (0x00528ac0) gates on state&0x400 (retail never
- // clears GRAVITY on landing; it's a persistent
- // object property). Clearing it first made this
- // re-apply a silent no-op, which is why the
- // falling pose never exited. The re-apply
- // dispatches the PRESERVED pre-fall forward
- // command through the funnel → the motion table
- // plays the Falling→X landing link. (The old
- // K-fix17 forced SetCycle is deleted: it read the
- // then-clobbered InterpretedState.ForwardCommand
- // — 0x40000015 — and re-set the very Falling
- // cycle it meant to clear.)
- // R4-V5 (closes the V4 wiring-contract gap the
- // adversarial review caught): retail order —
- // minterp first, then moveto (MovementManager::
- // HitGround 0x00524300, §2d — the R5-V5 facade
- // relay). Re-arms a moveto suspended by the
- // airborne UseTime contact gate; without it a
- // chasing NPC that lands stalls until ACE's
- // ~1 Hz re-emit.
- rm.Movement.HitGround();
- // DR bookkeeping only (partner of the jump-start
- // `State |= Gravity`): stops the per-tick gravity
- // integration for the grounded body.
- rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
-
- if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
- Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
- }
- }
-
- ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
- if (rm.CellId != 0)
- ae.Entity.ParentCellId = rm.CellId;
- ae.Entity.Rotation = rm.Body.Orientation;
- }
-
- // R5-V3 (#171): retail UpdateObjectInternal tail —
- // PositionManager::UseTime (0x005156b0, call @0x005159b3,
- // right after CPartArray::HandleMovement, UNCONDITIONAL for
- // every entity in both grounded and airborne branches): the
- // sticky 1 s lease watchdog (StickyManager::UseTime
- // 0x00555610 — a stick not re-issued by a fresh server arm
- // within 1 s tears itself down). No-op while nothing is stuck.
- rm.Host?.PositionManager.UseTime();
+ _remotePhysicsUpdater.Tick(rm, ae, dt, _liveCenterX, _liveCenterY);
}
// ── Get per-part (origin, orientation) from either sequencer or legacy ──