refactor(#184): Slice 2a — extract RemotePhysicsUpdater (byte-exact, fork preserved)

Extract the ~690-line per-remote dead-reckoning tick out of the >10k-line
GameWindow.TickAnimations into a testable AcDream.App.Physics.RemotePhysicsUpdater
(Code Structure Rule 1). The guard in TickAnimations now calls
_remotePhysicsUpdater.Tick(rm, ae, dt, _liveCenterX, _liveCenterY); the
animation/render half stays in GameWindow.

Pure, behaviour-neutral refactor — the Path A (grounded player remotes skip the
sweep) / Path B (NPCs + airborne players run it) FORK is preserved verbatim inside
the new class. Slice 2b collapses it.

Mechanics:
- Moved into RemotePhysicsUpdater: the Tick body (verbatim), SyncRemoteShadowToBody
  (now called back from the NPC UP-branch tail), ApplyPositionManagerDelta,
  TickRemoteMoveTo, ServerControlledVelocityStaleSeconds, and the diagnostics.
- Injected as delegates (kept on GameWindow — they have callers outside the DR
  loop): GetSetupCylinder (player cylinder + moveto/sticky radii) and
  ApplyServerControlledVelocityCycle (also called from the UP handler).
- AnimatedEntity: private -> internal (matches RemoteMotion) so the extracted
  class can take it by type.

Verified byte-exact: the extracted Tick body reverse-transforms (re-indent +8, undo
the 4 delegate/id substitutions) to diff-identical against the original block.
Behaviour-neutral: Core 2621 / App 741 green (unchanged), 0 warnings. No visual gate
(2a is structure only).

Handoff: docs/research/2026-07-07-184-slice2-unify-extract-handoff.md section 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-07 23:52:16 +02:00
parent 4b07b0f541
commit e1ac56cce9
2 changed files with 847 additions and 760 deletions

View file

@ -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
/// </summary>
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<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost> _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);
}
/// <summary>
@ -4591,56 +4608,10 @@ public sealed class GameWindow : IDisposable
return (setup.Radius * scale, setup.Height * scale);
}
/// <summary>
/// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/>
/// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's
/// stand-in for retail's <c>Frame::combine</c> in
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
/// <c>globaltolocalvec</c> 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 <c>set_heading(0)</c> by the identity
/// VALUE, not the angle, and the P5 pin (identity quaternion = heading 0)
/// makes compass addition the exact frame-combine here.
/// </summary>
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());
}
/// <summary>
/// #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
/// <paramref name="rm"/>.CellId + the streaming centre, exactly as the UP
/// handler derives <c>origin</c> (:5618) — and is DEAD anyway when seedCellId
/// is supplied (it only feeds the short-circuited DeriveOutdoorSeed). Updates
/// <see cref="RemoteMotion.LastShadowSyncPos"/> so callers can movement-gate.
/// </summary>
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).
/// <summary>
/// R5-V4: retail <c>CPhysicsObj::stick_to_object</c> (0x005127e0) — the
@ -4848,23 +4819,10 @@ public sealed class GameWindow : IDisposable
return false;
}
/// <summary>
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
/// drive (retail <c>MovementManager::UseTime</c> 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 <see cref="AcDream.Core.Physics.Motion.TargetManager"/>
/// voyeur system — the target's own <c>HandleTargetting</c> pushes updates
/// into this entity's <c>HandleUpdateTarget</c>, which is called
/// unconditionally per remote in the per-tick loop BEFORE this drive
/// (retail <c>UpdateObjectInternal</c> order). Safe for any remote: a
/// manager with no armed moveto no-ops, airborne bodies held by
/// <c>UseTime</c>'s contact gate.
/// </summary>
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.
/// <summary>
/// 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 ──