feat(#184): remote-creature de-overlap — placement-snap + shadow-follows-resolved (visual gate passed)

Packed monster remotes interpenetrate in acdream but barely in retail on the same
ACE. Retail de-overlaps them CLIENT-side: it sweeps every remote creature every
tick against neighbours' LIVE resolved positions (the collision shadow == the
resolved m_position, re-registered every moved transition step), with the server
position a gentle catch-up target (CPhysicsObj::MoveOrTeleport 0x00516330), not a
hard-snap. The collision math was already faithful; the bug was the reconciliation
(hard-snap) + the movement model (synth-velocity) + a stale shadow.

A first attempt (reverted) enqueued EVERYTHING and left the shadow at the raw
server position — it gate-failed with invisible monsters (an unplaced body blipped
over a huge distance into the sweep -> garbage pos) and the player stuck on offset
shadows. This redo fixes both root causes, mechanism-proven in a Core test first:

- NPC UpdatePosition routes through MoveOrTeleport with a PLACEMENT-SNAP: the body
  is snapped to the server pos when it is not already near it (first UP, no
  Sequencer to consume the queue, >96 m, or |Body - worldPos| > 4 m); only near DR
  corrections enqueue. This restores the body's placement authority (no invisible
  monsters). Airborne keeps the authoritative hard-snap.
- Grounded movement drives the body from the interp CATCH-UP (ComputeOffset ->
  InterpolationManager::adjust_offset, REPLACE dichotomy) instead of synth-velocity
  (get_state_velocity / SERVERVEL); MovementManager::UseTime runs unconditionally.
- SHADOW-FOLLOWS-RESOLVED: after each tick's sweep the collision shadow is
  re-registered at the resolved body (SyncRemoteShadowToBody), movement-gated
  (|Body - LastShadowSyncPos| > 1 cm). The per-UP :5669 raw-pos sync is now
  PLAYERS-ONLY, so an NPC's shadow is only ever written to its resolved body ->
  neighbours de-overlap against resolved bodies, the spread PERSISTS, and collision
  == render (no stuck-on-nothing). Landing clears the interp queue.

Preserved: airborne path, sticky #171 (gate + StickyManager overwrite of the seeded
frame), omega, the #173 bounce, landing, the node_fail_counter watchdog, and Path A
(player remotes, untouched -- Slice 2 unifies it).

Tests (RemoteDeOverlapMechanismTests): converging pair settles STABLE at 0.86 m
(barely overlapping = the retail look) WITH the shadow-sync vs <0.40 m (full
overlap) WITHOUT it; a third test drives the REAL InterpolationManager loop and
confirms the sweep absorbs the stall-blip (no pop-into-neighbour). 2-lens Opus
review (CONCERNS) addressed: movement-gated re-flood for the town-FPS risk;
players-only :5669; the blip-absorption test.

Register: retires TS-41 (SERVERVEL synth-velocity -> catch-up), narrows TS-44 (NPC
UP unified onto the interp queue; gate kept for orientation), adds AP-86
(shadow-follows-resolved impl) + AP-87 (MoveOrTeleport 4 m/no-Sequencer placement
snap). Known residual: the de-overlap sweep uses the human sphere for the mover, so
large creatures de-overlap at human radii (TS-46; Slice 3 plumbs Setup dims).

Visual gate PASSED (user: monsters visible + spacing much better). Core 2620 /
App 741 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-07 22:36:54 +02:00
parent 7f7a78d3ea
commit 37a94e1fa4
4 changed files with 558 additions and 99 deletions

View file

@ -463,6 +463,15 @@ public sealed class GameWindow : IDisposable
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
public System.Numerics.Vector3 LastServerPos;
/// <summary>
/// #184: the body position at which this remote's collision SHADOW was
/// last (re-)registered. The per-tick shadow-follows-resolved sync
/// (<c>SyncRemoteShadowToBody</c>) skips re-flooding when the body has
/// not moved &gt; ε since this — a resting/idle-town crowd doesn't churn
/// the registry, while a moving/de-overlapping crowd re-registers every
/// tick. Sentinel <c>Zero</c> forces the first sync.
/// </summary>
public System.Numerics.Vector3 LastShadowSyncPos;
/// <summary>
/// Latest server-authoritative velocity for NPC/monster smoothing.
/// Prefer the HasVelocity vector from UpdatePosition; when ACE omits
/// it for a server-controlled creature, derive it from consecutive
@ -4608,6 +4617,31 @@ public sealed class GameWindow : IDisposable
+ 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;
}
/// <summary>
/// R5-V4: retail <c>CPhysicsObj::stick_to_object</c> (0x005127e0) — the
/// mt-0 WIRE sticky (unpack_movement case 0 @00524589, gated on the
@ -5662,8 +5696,15 @@ public sealed class GameWindow : IDisposable
// (its body is the simulator, not a target). Retail does the
// equivalent via SetPosition → change_cell → AddShadowObject
// (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).
if (update.Guid != _playerServerGuid)
if (update.Guid != _playerServerGuid && IsPlayerGuid(update.Guid))
{
// Remote PLAYERS only (#184): a player remote (Path A, no sweep) tracks
// the server position closely, so syncing its shadow to the raw pos is
// fine. NPC shadows are synced to the RESOLVED body instead — by the
// NPC-branch tail below and the per-tick DR loop (SyncRemoteShadowToBody)
// — because an NPC's body is de-overlapped BEHIND its raw server pos;
// writing the raw pos here would snap the shadow into overlap for a frame
// each UP and fight the de-overlap (the #184 review's :5669 finding).
// BR-7: the wire position's full cell id seeds the re-flood
// (retail SetPosition → calc_cross_cells from m_position).
_physicsEngine.ShadowObjects.UpdatePosition(
@ -5923,7 +5964,69 @@ public sealed class GameWindow : IDisposable
$"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})"));
}
if (!snapSuppressedByStick)
rmState.Body.Position = worldPos;
{
// #184 (2026-07-07): retail CPhysicsObj::MoveOrTeleport (0x00516330)
// for grounded NPC remotes. The body is PLACED (hard-snapped) whenever
// it is not already tracking NEAR the server position — the first UP,
// a large correction / teleport, an out-of-view creature (>96 m from
// the local player), or an entity the DR loop won't tick — otherwise the
// server point is a GENTLE dead-reckoning TARGET the per-tick interp
// catch-up walks to, and the KEPT sweep de-overlaps that movement.
//
// The placement-snap is LOAD-BEARING: the earlier attempt (reverted)
// enqueued EVERYTHING, so an unplaced body (origin, first UP) blipped
// over a huge distance into the sweep -> a resolve started in a cell that
// did not contain the body -> garbage resolved pos -> INVISIBLE monster
// while its shadow (synced to server truth) stayed put -> player stuck on
// nothing. Airborne keeps the authoritative hard-snap (arc integrates
// locally, K-fix15). Physics digest 2026-07-07 banner.
if (rmState.Airborne)
{
rmState.Body.Position = worldPos;
}
else
{
const float MaxPhysicsDistanceNpc = 96f; // retail player_distance far-snap
const float BodySnapThresholdNpc = 4f; // large correction / teleport -> snap
var localPlayerPosNpc = _playerController?.Position
?? System.Numerics.Vector3.Zero;
float distNpc = System.Numerics.Vector3.Distance(worldPos, localPlayerPosNpc);
float bodyToTargetNpc = System.Numerics.Vector3.Distance(
rmState.Body.Position, worldPos);
// The 4 m bodyToTargetNpc guard is the LOAD-BEARING placement backstop:
// it snaps any body not already near the target (an unplaced first-UP
// body sits at the origin / spawn pos, far from worldPos). firstUpNpc is
// a belt-and-suspenders hint only — it is NOT a reliable "never placed"
// signal because a UM that enters a locomotion cycle can stamp
// LastServerPosTime before the first UP (~:5340). Don't tune the 4 m
// threshold down without re-checking the unplaced-body case.
bool firstUpNpc = rmState.LastServerPosTime <= 0.0;
// Enqueue only if the per-tick DR loop will CONSUME the queue (a
// non-null Sequencer, the TickAnimations gate); else nothing walks
// the body to the waypoint and it would freeze at its last pos.
bool willBeDrTickedNpc =
_animatedEntities.TryGetValue(entity.Id, out var aeDrNpc)
&& aeDrNpc.Sequencer is not null;
if (firstUpNpc || !willBeDrTickedNpc
|| distNpc > MaxPhysicsDistanceNpc
|| bodyToTargetNpc > BodySnapThresholdNpc)
{
// Placement / far / large-correction: SNAP + clear queue.
rmState.Interp.Clear();
rmState.Body.Position = worldPos;
}
else
{
// Near DR correction: enqueue the waypoint for the per-tick
// catch-up (Path B consumes it via ComputeOffset).
float headingNpc = ExtractYawFromQuaternion(rot);
rmState.Interp.Enqueue(
worldPos, headingNpc, isMovingTo: false,
currentBodyPosition: rmState.Body.Position);
}
}
}
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
// at ~5-10 Hz. The previous K-fix9 logic cleared Airborne on
@ -6005,8 +6108,14 @@ public sealed class GameWindow : IDisposable
}
}
else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity
&& !snapSuppressedByStick)
&& !snapSuppressedByStick && rmState.Airborne)
{
// AIRBORNE-only (#184): a grounded NPC translates by the interp catch-up,
// and the per-tick grounded loop zeroes Body.Velocity before integrating —
// so this write is DEAD for grounded remotes and only muddies the
// "ServerVelocity is diagnostic-only" contract. Gating it to airborne keeps
// the arc's velocity re-sync and prevents synth velocity from leaking into a
// grounded integrate.
rmState.Body.Velocity = rmState.ServerVelocity;
}
@ -6037,6 +6146,15 @@ public sealed class GameWindow : IDisposable
rmState.ServerVelocity);
}
// #184: sync the NPC shadow to the resolved/placed body (NOT the raw
// server pos — the :5669 sync is players-only now) so collision ==
// render and the de-overlap isn't snapped away for a frame each UP.
// Covers the first UP (before any DR tick) and no-Sequencer NPCs (which
// 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);
entity.SetPosition(rmState.Body.Position);
entity.Rotation = rmState.Body.Orientation;
}
@ -10356,43 +10474,30 @@ public sealed class GameWindow : IDisposable
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
| AcDream.Core.Physics.TransientStateFlags.Active;
// #170 residual fix (2026-07-04): an ARMED moveto always
// takes the MOVETO branch. The old arbitration routed the
// tick to SERVERVEL whenever UPs flowed (position-delta
// synthesis sets HasServerVelocity=true for any moving
// NPC), which SKIPPED MoveToManager.UseTime for exactly
// the duration of the server-side chase — the armed
// manager only ever ticked in UP-silent gaps (creature
// stopped server-side), turned toward a stale heading,
// and was interrupted by the next UM before reaching
// BeginMoveForward. Live funnel (launch-drainq.log,
// corrected per-guid attribution): 16 arms → 11 turns
// dispatched → 1 run install; [npc-tick] shows
// branch=SERVERVEL (skips UseTime) mtState=MoveToObject
// for chasing scamps. The legs stayed in Ready while the
// body glided on synthesized velocity — the #170 slide.
// Retail runs MovementManager::UseTime UNCONDITIONALLY
// per tick (CPhysicsObj::UpdateObjectInternal 0x005156b0,
// call at 0x00515998) and has NO wire-velocity leg-driver;
// between UPs a moveto-driven body translates from the
// motion state (get_state_velocity) with UP hard-snaps
// correcting drift. The SERVERVEL leg remains ONLY as the
// legacy fallback for entities WITHOUT an armed moveto
// (scripted-path NPCs / missiles carrying wire velocity).
// Full-stack conformance: RemoteChaseEndToEndHarnessTests
// (turn→run→drain sustained when the manager is ticked).
// #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 };
// R5-V3 (#171): a STUCK entity is also client-side-
// driven — after the sticky arrival the moveto is
// cleaned (Invalid) but StickyManager::adjust_offset
// owns the between-snap translation exactly like an
// armed moveto. Routing it to SERVERVEL would glide
// the body on synthesized wire velocity AGAINST the
// sticky steer (the same starvation class as the #170
// SERVERVEL fix — register TS-41). Retail has no
// SERVERVEL leg at all; it stays the fallback for
// entities with NO client-side movement driver.
bool stickyArmed =
(rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity
@ -10403,62 +10508,23 @@ public sealed class GameWindow : IDisposable
{
rm.ServerVelocity = System.Numerics.Vector3.Zero;
rm.HasServerVelocity = false;
rm.Body.Velocity = System.Numerics.Vector3.Zero;
ApplyServerControlledVelocityCycle(
serverGuid,
ae,
rm,
System.Numerics.Vector3.Zero);
}
else
{
rm.Body.Velocity = rm.ServerVelocity;
}
}
else
{
// R4-V4: the retail MoveToManager drives
// server-directed movement per tick — UseTime runs
// HandleMoveToPosition/HandleTurnToHeading (steering
// + arrival + fail-distance), dispatching its OWN
// per-node locomotion (turn / RunForward) through the
// sink. That per-node dispatch is the only motion the
// per-tick update should issue for a remote.
TickRemoteMoveTo(rm);
// #170 ROOT FIX (2026-07-04): the per-frame
// rm.Motion.apply_current_movement(...) call that used
// to be here is DELETED. For a remote (which has a
// DefaultSink) it re-ran ApplyInterpretedMovement EVERY
// FRAME, re-dispatching the whole interpreted state —
// stance (0x8000003C), forward=attack, sidestep/turn
// stops — and each successful DoInterpretedMotion
// appends a CMotionInterp.pending_motions node. Those
// nodes barely drain for a remote, so pending_motions
// EXPLODED to ~1.3M entries (live capture: add=1.37M vs
// done=5.7K; ~671K of them the unchanged stance).
// MotionsPending() then stayed permanently true, so
// MoveToManager.BeginTurnToHeading (0x00529b90,
// `if (motions_pending) return`) could never start the
// chase turn → the chase never reached BeginMoveForward
// → RunForward, so the creature SLID in an idle+attack
// pose instead of running. Retail dispatches per MOTION
// EVENT (per UM), never per frame — a live cdb drain
// trace showed retail add_to_queue == MotionDone (queue
// stays shallow). The motion is already dispatched per
// UM by the funnel (MoveToInterpretedState) and by the
// MoveToManager per node above; the only thing the
// deleted call still provided was body velocity, which
// the get_state_velocity refresh below performs
// directly. get_state_velocity (0x00527d50, verbatim)
// returns WalkForward→3.12×spd, RunForward→4.0×spd, 0
// otherwise; grounded-only (this branch already asserted
// Contact+OnWalkable), airborne velocity owns the jump.
if (rm.Body.OnWalkable)
rm.Body.set_local_velocity(
rm.Motion.get_state_velocity(),
rm.Body.LastMoveWasAutonomous);
}
// 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
{
@ -10510,20 +10576,72 @@ public sealed class GameWindow : IDisposable
// here to prevent UpdatePhysicsInternal's own omega pass from
// double-integrating.
var preIntegratePos = rm.Body.Position;
// R5-V3 (#171): the sticky steer — retail
// UpdatePositionInternal's PositionManager::adjust_offset
// slot (0x00512c30, call @0x00512d0e): the delta composes
// into this tick's motion BEFORE UpdatePhysicsInternal +
// the transition sweep below, so collision resolves the
// sticky movement like any other motion (preIntegratePos
// is captured first — the sweep covers the steer). No-op
// when nothing is stuck (untouched delta frame).
// 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)
{
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
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;
@ -10586,6 +10704,36 @@ public sealed class GameWindow : IDisposable
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
@ -10654,6 +10802,11 @@ public sealed class GameWindow : IDisposable
&& 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(