feat(#184): Slice 2b — unify the remote player/NPC fork (players collide faithfully)

Collapse the two-path fork in RemotePhysicsUpdater.Tick: the former Path A
(grounded PLAYER remotes advanced by the interp catch-up with ResolveWithTransition
deliberately OMITTED, per the now-retired issue-#40 premise) is gone. Every remote --
player and NPC -- now runs the SAME per-tick catch-up + sweep + shadow-follows-resolved.
Retail's UpdateObjectInternal (0x005156b0) has no player/remote fork; this is the
faithful shape. Player remotes now get terrain-Z snap (no slope staircase), wall
collision, and MONSTER collision -- previously (Path A) they skipped ALL collision.

RETAIL PvP (adversarial review caught this): two non-PK players WALK THROUGH each
other in AC (you can stand inside another non-PK player) -- they do NOT de-overlap.
The remote-player mover now carries IsPlayer|EdgeSlide (mirroring the LOCAL player at
PlayerMovementController), so CollisionExemption's PvP block exempts a non-PK pair,
exactly as retail sets IsPlayer on every object's own transition (OBJECTINFO::init
0x0050cf30) and FindObjCollisions (pc:276812) exempts it. The first 2b draft passed
bare EdgeSlide and de-overlapped players (MORE solid than retail); the 3-lens review
flagged it. PK/PKLite/Impenetrable are not plumbed onto the remote mover yet -- the
same M1.5 gap the local player carries (TS-23, extended).

#40 proven dead in code (before the gate): PlayerVsMonster_DeOverlapsAndAbsorbsTheStallBlip
drives the real ComputeOffset -> InterpolationManager catch-up (incl. the fail_count
blip-to-tail) for a player mover converging on a monster and asserts de-overlap +
maxSpike<0.30 -- the sweep absorbs the stall-blip. ConvergingPlayers_WalkThroughEachOther
proves the PvP exemption (non-PK players pass through). Path B already ran the
#40-feared config stably; #40 (May 2026) predates the CSphere/#137/#170/#171 rebuild.

Placement (HIGH, review finding 3): the player UP routing gains the SAME placement-snap
backstop Slice 1 gave NPCs (AP-87). Without it a UM-first player (RemoteMotion seeded
to the spawn pos, then a first UP in a different cell) would sweep from a stale cell ->
garbage -> the digest's invisible/misplaced-player bug. The 4 m bodyToTarget guard +
!willBeDrTicked + dist>96 snap; near placed corrections still enqueue for smooth
catch-up. Also seed Body.Position=worldPos at UP-handler RemoteMotion creation
(mirrors the UM handler :5176) for the UP-first case.

Coupled shadow edits (research finding 9): RETIRED the players-only raw-worldPos shadow
sync -- now that players run the sweep + shadow-follows-resolved, the raw sync would
re-snap a packed player's shadow into overlap each UP. Player shadows follow the
RESOLVED body via the DR-tick loop + a new player UP-branch-tail SyncRemoteShadowToBody.

Surviving player/NPC split (AP-88): the omega -- grounded PLAYERS keep the
ObservedOmega-or-seqOmega world-frame (Concatenate) fallback ("rectangle when running
circles"); NPCs + airborne keep ObservedOmega-only body-frame (Multiply). They commute
for an upright body + yaw omega, so the fork is faithful.

Register: TS-23 extended (remote-player mover PK gap); AP-86 updated (raw sync retired
for players too, Where column fixed); AP-88 added (omega fork + eval-order note).

Tests: Core 2623 / App 741 green, 0 warnings. 3-lens adversarial review + per-finding
verification (10 agents); all 6 confirmed findings addressed (2 substantive: PvP mover
flags + player placement-snap; 4 doc/cosmetic).

VISUAL GATE (acceptance test) owed by the user -- NOTE the corrected expectation:
  (a) a player remote on a hill -- no slope staircase;
  (b) two packed player remotes -- they WALK THROUGH each other (retail PvP), NOT
      de-overlap (this corrects the design's original "players de-overlap" gate);
  (c) a player remote cannot stand inside a MONSTER (new: player-vs-monster collision);
  (d) remote walk/run/jump/land/turn UNCHANGED.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-08 00:45:44 +02:00
parent e1ac56cce9
commit ddb5a96799
4 changed files with 303 additions and 309 deletions

View file

@ -5654,21 +5654,17 @@ 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 && 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(
entity.Id, worldPos, rot, origin.X, origin.Y, p.LandblockId,
seedCellId: p.LandblockId);
}
// #184 Slice 2b: the former players-only RAW-pos shadow sync is RETIRED.
// It was a Slice-1 stopgap while grounded player remotes (old Path A) skipped
// the sweep and tracked the server position closely. Now that Slice 2b runs
// the SAME per-tick sweep + shadow-follows-resolved for players, writing the
// raw (overlapping) server pos here would re-snap a packed player's shadow
// into overlap once per UP and fight the in-tick de-overlap (research
// finding 9). Player shadows now follow the RESOLVED body — via the DR-tick
// loop (SyncRemoteShadowToBody, movement-gated) and the player UP-branch tail
// below (first-UP / no-Sequencer case), exactly like NPCs. Local-player
// broadphase still tests an up-to-date remote shadow; it is just the resolved
// body now, not the raw wire pos.
// Track remote-entity motion for stop detection. Only record the
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates
@ -5710,6 +5706,18 @@ public sealed class GameWindow : IDisposable
// Hard-snap orientation on first spawn so the per-tick
// slerp doesn't visibly rotate from Identity to truth.
rmState.Body.Orientation = rot;
// #184 Slice 2b: PLACE the body at the server position on creation,
// mirroring the UM handler's seed (:5176 `Body.Position =
// entity.Position`). A UP-first RemoteMotion (created here before any
// UM) was left at the default (0,0,0). Path A never swept, so that
// stale origin was harmless — it caught up gradually. Now that Slice
// 2b runs the sweep for grounded PLAYERS too, an unplaced body would
// sweep from (0,0,0) in the server cell that does not contain it →
// garbage resolved pos → the digest's INVISIBLE/misplaced-body bug.
// Seeding here is the root-cause fix (the UP creation path should
// seed exactly like the UM path); worldPos == entity.Position (the
// unconditional snap at the top of this handler already ran).
rmState.Body.Position = worldPos;
}
// L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
@ -5819,25 +5827,45 @@ public sealed class GameWindow : IDisposable
}
// ── GROUNDED ROUTING (CPhysicsObj::MoveOrTeleport) ────────────
const float MaxPhysicsDistance = 96f;
const float MaxPhysicsDistance = 96f; // retail player_distance far-snap
const float BodySnapThreshold = 4f; // large correction / teleport / unplaced -> snap
var localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero;
float dist = System.Numerics.Vector3.Distance(worldPos, localPlayerPos);
// #184 Slice 2b: the player UP routing gains the SAME placement-snap
// backstop the NPC routing got in Slice 1 (AP-87). Now that grounded
// PLAYER remotes run the sweep, an unplaced / stale-cell body — a
// UM-first RemoteMotion seeded to the spawn pos (:5176) then a first UP
// in a DIFFERENT cell, which the UP-creation seed (:5720) does NOT cover
// — would enqueue and the per-tick sweep would run from a cell that does
// not contain the body -> garbage resolved pos -> the digest's
// INVISIBLE/misplaced player. The 4 m bodyToTarget guard is the
// LOAD-BEARING backstop (AP-87; firstUp via LastServerPosTime is a poor
// signal for players — it is already set by the VEL_DIAG block above);
// !willBeDrTicked snaps a no-Sequencer player whose queue nothing would
// consume; dist>96 is retail's far-snap. Placed + near corrections still
// enqueue for the smooth catch-up.
float bodyToTarget = System.Numerics.Vector3.Distance(
rmState.Body.Position, worldPos);
bool willBeDrTicked =
_animatedEntities.TryGetValue(entity.Id, out var aeDrPl)
&& aeDrPl.Sequencer is not null;
if (dist > MaxPhysicsDistance)
if (dist > MaxPhysicsDistance || !willBeDrTicked
|| bodyToTarget > BodySnapThreshold)
{
// Beyond view bubble: SetPositionSimple slide-snap. Clear queue.
// Beyond view bubble / large correction / unplaced body:
// SetPositionSimple slide-snap. Clear queue.
rmState.Interp.Clear();
rmState.Body.Position = worldPos;
}
else
{
// Within view bubble: enqueue waypoint for adjust_offset to walk to.
// The per-frame TickAnimations player-remote path drives the
// actual body advancement via InterpolationManager.AdjustOffset.
// Pass body's current position so the InterpolationManager can
// detect a far-distance enqueue (>100 m from body) and pre-arm
// an immediate blip — avoids body drifting visibly toward a
// far waypoint instead of teleporting to it.
// Within view bubble, placed + near: enqueue waypoint for
// adjust_offset to walk to. The per-frame TickAnimations player-
// remote path drives the actual body advancement via
// InterpolationManager.AdjustOffset. Pass body's current position so
// the InterpolationManager can detect a far-distance enqueue (>100 m
// from body) and pre-arm an immediate blip.
float headingFromQuat = ExtractYawFromQuaternion(rot);
rmState.Interp.Enqueue(
worldPos,
@ -5871,6 +5899,18 @@ public sealed class GameWindow : IDisposable
// the near-enqueue branch this prevents a 1-frame teleport-then-
// yank-back rubber-band as TickAnimations chases worldPos via the
// queue.
//
// #184 Slice 2b: sync the player-remote shadow to the RESOLVED/placed
// body (mirrors the NPC UP-branch tail). Now that grounded players run
// the DR-tick sweep + shadow-follows-resolved, the retired raw-pos sync
// (top of this handler) would have re-snapped the shadow into overlap
// each UP; this keeps collision == render for the first UP (before any
// DR tick) and for no-Sequencer players. rmState.CellId is the server
// cell adopted above (:5735). The per-tick loop keeps it current
// between UPs (movement-gated).
if (rmState.CellId != 0)
_remotePhysicsUpdater.SyncRemoteShadowToBody(
entity.Id, rmState, _liveCenterX, _liveCenterY);
entity.SetPosition(rmState.Body.Position);
return;
}
@ -6105,8 +6145,8 @@ public sealed class GameWindow : IDisposable
}
// #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.
// server pos — the raw-pos sync was RETIRED for players too in Slice 2b)
// so collision == render and the de-overlap isn't snapped away 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.