diff --git a/docs/research/2026-07-07-remote-creature-deoverlap-handoff.md b/docs/research/2026-07-07-remote-creature-deoverlap-handoff.md new file mode 100644 index 00000000..1fff2c2b --- /dev/null +++ b/docs/research/2026-07-07-remote-creature-deoverlap-handoff.md @@ -0,0 +1,243 @@ +# Handoff: verbatim port of retail's per-remote physics tick so packed monsters de-overlap + +**Date:** 2026-07-07 · **Status:** DESIGN SPEC WRITTEN + APPROVED, IMPLEMENTATION NOT STARTED +(fresh session). **Driving report (user, side-by-side vs retail on the SAME ACE):** monsters +packed around the player **overlap** (arms interpenetrating) in acdream; in the retail client they +barely overlap. That overlap is why there's "no room to slide out" of a crowd. + +**Read these first (in order):** +1. Design spec: [`docs/superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md`](../superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md) — the full design. +2. This handoff — the executable detail + the exact verified code sites + what to preserve. +3. Physics digest banner (SSOT): `claude-memory/project_physics_collision_digest.md` (2026-07-07, + incl. the "the room is SLIDING, user RETRACTED the jiggle framing" note). + +--- + +## 0. Session context — what shipped, what this is + +This work is a **new, distinct thread** from the #182 *player* physics rebuild that shipped THIS +session (6 commits `8bb8b204`→`e3c4c59b`, Core 2617 / App 741 green, airborne-stuck **confirmed +fixed by the user's visual gate**: "Works better, don't get stuck in the animation"). That rebuild +is landed — do NOT redo it. Its own residual (the grounded ground-jam, #137 family) is a separate +Slice 3 tracked in ISSUES #182. + +During that gate the user noticed the crowd still feels wrong and diagnosed it side-by-side vs +retail: **the monsters are packed tighter / overlapping in acdream**. That's THIS handoff. + +--- + +## 1. Verified root cause (decomp + source — do NOT re-derive) + +Research workflow `wf_d2ff782f-9cb` (4 read-only agents, Opus) + my own direct source reads. Retail +`pc:`/`0x…` anchors are into `docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +**Retail runs ONE unified per-object tick and collides every creature locally:** +- `CPhysics::UseTime` (**0x00509950**, pc:271640) walks **every** `CPhysicsObj` in the object table + and runs `update_object → UpdateObjectInternal` on each — **no fork** player-vs-remote. The player + only gets an extra camera callback. +- `UpdateObjectInternal` (**0x005156b0**) runs the full `transition` (collision) for any active + object with spheres whose origin moved. `CObjCell::find_obj_collisions` (**0x0052b750**, pc:308916) + iterates the cell's shadow list; creature-vs-creature collision is exempt **only** for a *viewer* + or *IGNORE_CREATURES* mover. So **moving remote creatures de-overlap against each other, client-side.** +- A remote stays "active" via the motion system feeding velocity each tick (`apply_interpreted_movement` + 0x00528600 → `set_local_velocity` 0x005114d0 → `set_velocity` 0x005113f0 sets the 0x80 active bit). + +**Retail's server position is a GENTLE dead-reckoning TARGET, not a hard-snap:** +- `SmartBox::HandleReceivedPosition` (**0x00453fd0**) → `CPhysicsObj::MoveOrTeleport` (**0x00516330**): + teleport-TS / no-cell → hard snap; has-velocity AND `player_distance < 96` → **`InterpolateTo`** + (queue a DR node); has-velocity AND `≥ 96` → far snap. +- `InterpolationManager::adjust_offset` (**0x00555d30**) catches up toward the queued position at + **≤ 2× the creature's max animation speed × dt**. The per-tick **collision-resolved** position is + stored (`SetPositionInternal` writes `sphere_path.curr_pos`, NOT the raw target) and **persists**; + the next server update re-aims the catch-up **from wherever collision left the creature**. A + `node_fail_counter` blips to the server point only after ~5 genuinely-stuck frames. + +**So retail's de-overlap is PROACTIVE (movement collision-stopped before interpenetration) and +PERSISTENT (never snapped-into-overlap).** ACE broadcasts each creature's own overlapping pathed +position; the CLIENT spreads them. (ACE/holtburger source are NOT checked out — empty submodule +dirs; the decomp + the #182 live cdb trace settle client-responsibility.) + +**acdream's two divergences (both verified against source):** +1. **NPC (monster) `UpdatePosition` HARD-SNAPS** to the raw server position — `GameWindow.cs:5925-5926`: + `if (!snapSuppressedByStick) rmState.Body.Position = worldPos;` (suppressed only while a sticky + melee lease is armed). A packed monster teleports into the overlapping server position every UP, + **overwriting** the swept de-penetration. The sweep resolves *movement*, not a static overlap it + gets snapped into. +2. **A two-path fork retail doesn't have** (`GameWindow.cs:10076`): + - **Path A** — grounded *player* remotes (`IsPlayerGuid && !Airborne`, `:10076-10311`): advance + via `RemoteMotionCombiner.ComputeOffset` interp catch-up, **`ResolveWithTransition` NOT called** + (`:10274-10281` "collision is the sender's problem" — factually wrong per the decomp). + - **Path B** — NPCs/monsters + airborne player remotes (`:10312-10698`): **DO** call + `ResolveWithTransition` (`:10558`, `moverFlags: EdgeSlide`, self-skip `movingEntityId: kv.Key`) + — so a monster already collides against other creatures. But it drives the body from + `get_state_velocity()` (synth locomotion), and the NPC hard-snap (#1) overwrites the result. + +**KEY INSIGHT: the collision math already exists and is faithful.** The bug is the **reconciliation +(hard-snap)** + the **movement model (synth-velocity instead of catch-up)** + the **fork**. + +--- + +## 2. SCOPE REFINEMENT — read this before planning (discovered reading Path B) + +The fix is **bigger than "replace the hard-snap."** Path B drives the body from +`get_state_velocity()` (the animation locomotion velocity), NOT the interp catch-up toward the +server target. For a monster to de-overlap, its per-tick **movement must chase the server target** +(so the sweep resolves *that* movement against neighbors and stops it at contact). So the faithful +fix **reworks Path B's movement model**: `synth-velocity → interp catch-up (ComputeOffset, like +Path A) → sweep → store resolved`. Retail's `UpdatePositionInternal` uses a **REPLACE dichotomy** +(the catch-up REPLACES the anim root motion when the queue is active; the anim just animates the +legs). This is a **delicate change to the FROZEN R4/R5 remote-DR arc** — treat it with care. + +--- + +## 3. Exact code sites (all verified against source THIS session) + +`RemoteMotion` type — `GameWindow.cs:441`: `.Body` (`:443` the sim PhysicsBody), `.Movement` (`:453` +R5 MovementManager; `.Motion => Movement.Minterp` `:455`, `.MoveTo` `:478`), `.Host` (`:485` +EntityPhysicsHost → TargetManager + the faithful `Motion.PositionManager` Sticky/Constraint facade), +`.Interp` (`:581` the `InterpolationManager` catch-up queue), `.Position` (`:590` the +`RemoteMotionCombiner`, NOT the retail facade). + +**A. Remote `UpdatePosition` handler (`OnLivePositionUpdated`), `GameWindow.cs:~5640-5980`:** +- `:5655` `entity.SetPosition(worldPos)` (unconditional render snap, undone later per-branch). +- `:5665-5672` `ShadowObjects.UpdatePosition(entity.Id, worldPos, …)` — **keep** (server-truth + shadow sync; retail `change_cell`/`AddShadowObject`). NOTE: this syncs the *collision broadphase* + target to server truth even though the rendered body de-overlaps — verify this doesn't re-pack. +- `:5724` `if (IsPlayerGuid(update.Guid))` → **the player-remote branch ALREADY implements retail + MoveOrTeleport** (airborne no-op `:5776`; landing `:5791`; far>96 snap+clear `:5827-5832`; near + `Interp.Enqueue(worldPos, heading, isMovingTo:false, currentBodyPosition:…)` `:5843-5847`). **This + is the model to extend to NPCs.** +- The `else` (NPC) branch, `:5879-5975`: computes `ServerVelocity` synth (`:5863`/`:5889`, keep for + `[VEL_DIAG]`); **`:5925-5926` the HARD-SNAP** `rmState.Body.Position = worldPos` (suppressed while + `snapSuppressedByStick`); `:5952` `rmState.CellId = p.LandblockId`; `:5967+` orientation hard-snap + (also stick-gated). **THIS is what Slice 1 replaces with the MoveOrTeleport Enqueue.** + +**B. Path B tick (NPC/legacy), `GameWindow.cs:~10312-10698`:** +- `:10457-10460` `rm.Body.set_local_velocity(rm.Motion.get_state_velocity(), …)` when OnWalkable — + **the synth-velocity movement source to REPLACE with the interp catch-up.** +- `:10484-10492` manual omega/orientation integration (ObservedOmega) — **keep**. +- `:10512` `preIntegratePos = rm.Body.Position`. +- `:10521-10526` sticky delta via `rm.Host.PositionManager.AdjustOffset(pmDelta, dt)` + + `ApplyPositionManagerDelta` — **keep, composes with the catch-up (retail adjust_offset order)**. +- `:10527-10528` `calc_acceleration()` + `UpdatePhysicsInternal(dt)` (integrates the synth velocity). +- `:10549-10583` **the SWEEP** `ResolveWithTransition(preIntegratePos, postIntegratePos, rm.CellId, + 0.48f, 1.835f, 0.4f, 0.4f, isOnGround:!rm.Airborne, body:rm.Body, moverFlags:EdgeSlide, + movingEntityId:kv.Key)` — **KEEP; this is the creature-vs-creature de-overlap** (Slice 3 makes the + 0.48/1.835 Setup-derived). +- `:10585` `rm.Body.Position = resolveResult.Position` + `:10586-10587` CellId writeback. +- `:10606+` the #173 airborne reflect (old AD-25 airborne-only bounce) — leave for now (or later + fold into `PhysicsObjUpdate.HandleAllCollisions` like the player path; NOT Slice 1). + +**C. Path A tick (player remote, ComputeOffset, NO sweep), `GameWindow.cs:10076-10311`:** +- `:10173` `rm.Position.ComputeOffset(dt, rm.Body.Position, seqVel, ori, rm.Interp, maxSpeed, + terrainNormal)` — the catch-up model (REPLACE dichotomy). `:10274-10281` the "ResolveWithTransition + NOT called" opt-out. **Slice 2 adds the sweep here and unifies with Path B.** + +**D. `RemoteMotionCombiner.ComputeOffset`, `RemoteMotionCombiner.cs:59-115`:** interp catch-up +(`interp.AdjustOffset` toward the queue head) REPLACES the frame when non-zero; else seqVel anim +fallback projected on the terrain plane. This is the movement primitive both paths should use. + +**E. Substrate (KEEP, already retail-shaped):** shadow registry `RegisterLiveEntityCollision` +`GameWindow.cs:4171` (`:4276 RegisterMultiPart`, `IsCreature` `:4271`); `CollisionExemption` +`CollisionExemption.cs:59-129` (creature-vs-creature exempt only for viewer/ignore-creatures mover — +an NPC mover with `EdgeSlide` is NOT exempt, so it DOES collide with other creatures). + +--- + +## 4. The plan (slices) — see §2.2/§2.5 of the design spec + +**Slice 1 — NPC de-overlap (the reported symptom). The two changes are COUPLED, land together:** +1. **NPC UP handler** (`:5925-5926` + orientation `:5967+`): replace the hard-snap with the retail + `MoveOrTeleport` routing — mirror the player-remote branch (`:5822-5848`): far>96 → snap + clear + queue; near → `Interp.Enqueue`; teleport/parent → snap. **Keep** the `snapSuppressedByStick` + escape (it's retail's "armed stick owns the frame, server correction can't fight it", TS-41/44). + Keep the orientation hard-snap (retail hard-snaps orientation on UP). +2. **Path B tick** (`:10457-10528`): replace the `get_state_velocity` movement with the interp + catch-up as the movement source (use `rm.Position.ComputeOffset` / `rm.Interp.AdjustOffset` toward + the queued server target, REPLACE dichotomy), compose the sticky delta, **KEEP the sweep** (`:10558`) + and store `resolveResult.Position`. The anim (legs) still plays via the sequencer. `ServerVelocity` + stays for diagnostics only. + - **Gate:** monsters de-overlap side-by-side vs retail; no remote jitter/rubber-band/desync; sticky + melee (#171) unbroken; walk/run/jump/land unchanged; Core 2617 / App 741 green. + +**Slice 2 — unify the fork:** collapse Path A into the same catch-up+sweep model (player remotes gain +the sweep → packed *players* de-overlap too). Per Code Structure Rule 1 (no new feature body in the +>10k-line `GameWindow`), extract a **`RemotePhysicsUpdater`** class (`src/AcDream.App/Physics/` or +`Rendering/`) owning the unified per-remote `UpdateObjectInternal` chain (interp delta → sweep → +commit); `GameWindow`'s loop shrinks to `RemotePhysicsUpdater.Tick(rm, dt)`. Extract the +`MoveOrTeleport` routing into a shared method so NPC + player-remote reconciliation is ONE impl. +Retire the "remotes skip the transition" adaptation (`:10089`/`:10275`, filed #40, ISSUES.md ~:4899). + +**Slice 3 — Setup-derived mover sphere:** replace the hard-coded `0.48f/1.835f` (`:10551-10556`) with +the creature's Setup sphere / `ObjScale` so differently-sized creatures de-overlap at true radii. + +--- + +## 5. What to PRESERVE (regression watch — the R4/R5 arc has many hard-won fixes) + +- The **sweep + transition internals** (`ResolveWithTransition` and below), **shadow registry**, + **`CollisionExemption`** — untouched (already faithful). +- The **R5 managers** (`MovementManager`/`MoveToManager`/`TargetManager` + `Motion.PositionManager` + Sticky/Constraint) — TICKED by the unified update, not rewritten. +- **Sticky melee #171** (`snapSuppressedByStick` escape + the sticky delta at `:10521-10526`) — MUST + survive; it's the "monster facing while attacking" fix. +- The **airborne remote path** (`!IsGrounded` no-op `:5776`, landing transition `:5791`, gravity + integration between UPs) — MUST survive. +- The **omega/orientation manual integration** (`:10484-10492`) and orientation hard-snap — keep. +- The **`node_fail_counter` blip watchdog** in `InterpolationManager` — keep (retail's stuck→snap + escape; without it a genuinely-blocked remote freezes). +- The **#182 local-player rebuild** (this session) — orthogonal, untouched. + +## 6. Gotchas +- **Don't double-move:** the catch-up REPLACES the synth-velocity (retail's REPLACE dichotomy), it is + NOT additive on top of `get_state_velocity`. +- **The two Slice-1 changes are coupled** — Enqueue is useless unless Path B consumes the queue; a + half-change (Enqueue without the catch-up movement) breaks DR (body stops tracking the server). +- **`ShadowObjects.UpdatePosition` (`:5665`) still syncs the broadphase to server truth** — verify + the de-overlapped *rendered* body and the server-truth *collision target* don't fight (retail's + shadow follows the collided position; check whether acdream should sync the shadow to the + collision-resolved position instead of raw server truth). Potential subtle bug — probe it. +- **The sweep only de-overlaps MOVING creatures** (it resolves movement, not static overlap). Retail + never lets deep static overlap form (proactive). The converging-pair test must drive real movement. + +## 7. Validation +- **Conformance test** (`RemotePhysicsUpdaterTests`, easiest after the Slice-2 extraction): two + registered creature shadows given converging catch-up targets → assert they settle at + contact-distance (sum of radii), not overlapping; a `MoveOrTeleport` routing test (near→enqueue, + far→snap, teleport→snap); a persistence test (a de-penetrated position survives the next server UP). +- **Suites green:** Core 2617 / App 741 (no regression to remote anim, DR smoothness, sticky #171, + the #182 player fix). +- **Visual gate (acceptance):** side-by-side vs the retail client on the SAME ACE — packed monsters + spread to retail spacing (arms no longer interpenetrating); remotes don't jitter/rubber-band/desync; + sticky-melee facing (#171) unbroken; remote walk/run/jump/land unchanged. +- **Apparatus:** `ACDREAM_REMOTE_VEL_DIAG=1` (existing, UP/seq pace); add a `[remote-deoverlap]` probe + (rendered pos vs raw server UP + neighbor overlap depth) if a residual needs it. + +## 8. Risk +Touches the **frozen R4/R5 remote-DR arc**. The synth-velocity model was tuned against live ACE pace +over the R4/R5 sessions; replacing it with the catch-up model risks regressing remote walk/run +smoothness, the slope "staircase" fix, and the sticky melee. Mitigate: stage NPC-first + gate before +Slice 2; preserve every escape (§5); keep the sweep/registry/managers; the `node_fail_counter` +prevents a stuck-remote freeze. + +## 9. Register bookkeeping (do in the landing commits) +- Retire the "remotes skip the transition / server already collision-resolved" adaptation + (`GameWindow.cs:10089`/`:10275`, #40) — its "Risk if assumption breaks" IS the arms-overlap. Retire + in the Slice-2 commit that adds the sweep to Path A. +- Add rows for: the NPC `MoveOrTeleport` near-distance constant (96 m), the sticky-suppression + retention, and (if kept) the shadow-follows-server-truth vs collision-resolved decision. + +## 10. Retail anchors (addresses, for `grep named-first` in the fresh session) +`CPhysics::UseTime` 0x00509950 · `update_object` 0x00515d10 · `UpdateObjectInternal` 0x005156b0 · +`UpdatePositionInternal` 0x00512c30 · `transition` 0x00512dc0 · `find_obj_collisions` 0x0052b750 · +`MoveOrTeleport` 0x00516330 · `HandleReceivedPosition` 0x00453fd0 · `InterpolateTo` 0x005104f0 / +`InterpolationManager::InterpolateTo` 0x00555b20 · `adjust_offset` 0x00555d30 · `UseTime` 0x00555f20 · +`set_local_velocity` 0x005114d0 · `apply_interpreted_movement` 0x00528600. + +## 11. Pointers +- Design spec: `docs/superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md`. +- Physics digest (SSOT + DO-NOT-RETRY): `claude-memory/project_physics_collision_digest.md`. +- The #182 player rebuild (shipped this session, orthogonal base): plan + `docs/superpowers/plans/2026-07-07-player-physics-update-verbatim-rebuild.md`; the histogram + `tools/analyze_resolve_capture.py`. +- Research: workflow `wf_d2ff782f-9cb` (4-agent read-only sweep; findings distilled into §1-§3 here). diff --git a/docs/superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md b/docs/superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md new file mode 100644 index 00000000..4aa60fa0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md @@ -0,0 +1,175 @@ +# Design: verbatim port of retail's per-remote physics tick so packed monsters de-overlap + +**Date:** 2026-07-07 · **Status:** DESIGN APPROVED (user: "I want retail behavior. So make it work.") +**Driving report (user, side-by-side vs retail on the SAME ACE):** monsters packed around the +player **overlap** in acdream (arms interpenetrating); in the retail client they barely overlap. +This is the crowd "no room to slide out" feel — the room is created by the monsters *not* +occupying the same space. + +--- + +## 1. Verified root cause (decomp + source, not guessed) + +Research workflow `wf_d2ff782f-9cb` (4 read-only agents) + direct source reads settle it. Retail +anchors are into `docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +### Retail: ONE unified per-object tick that collides every creature locally +- **`CPhysics::UseTime`** (0x00509950, pc:271640) walks **every** `CPhysicsObj` in the object + table and calls `update_object → UpdateObjectInternal` on each — **no fork** between the player + and remote creatures. The player only gets an *extra* camera callback. +- **`UpdateObjectInternal`** (0x005156b0) runs the full `transition` (collision sweep) for any + active object with spheres whose origin moved. `CObjCell::find_obj_collisions` (0x0052b750, + pc:308916) iterates the cell's shadow list; creature-vs-creature collision is exempt **only** + for a *viewer* mover or an *IGNORE_CREATURES* mover. So **moving remote creatures de-overlap + against each other, client-side.** +- A remote creature stays "active" (transient bit 0x80) because the motion/animation system feeds + velocity every tick via `apply_interpreted_movement` (0x00528600) → `set_local_velocity` + (0x005114d0) → `set_velocity` (0x005113f0, which sets 0x80). + +### Retail: the server position is a GENTLE DR target, not a hard-snap +- **`SmartBox::HandleReceivedPosition`** (0x00453fd0) routes a remote through + **`CPhysicsObj::MoveOrTeleport`** (0x00516330): + - newer teleport-TS OR no cell → **hard snap** (`SetPosition`). + - has-velocity AND `player_distance < 96` → **`InterpolateTo`** (queue a DR node). + - has-velocity AND `player_distance ≥ 96` → **far snap** (`SetPositionSimple`). +- **`InterpolationManager::adjust_offset`** (0x00555d30) catches up toward the queued server + position at **≤ 2× the creature's max animation speed × dt**. The per-tick collision-resolved + position is **stored and persists** (`SetPositionInternal(transition)` writes + `sphere_path.curr_pos`, not the raw target); the *next* server update re-aims the catch-up + **from wherever collision left the creature**. A `node_fail_counter` blips to the server point + only after ~5 genuinely-stuck frames (`CREATURE_FAILED_INTERPOLATION_PERCENTAGE=0.3`, + `MIN_DISTANCE_TO_REACH_POSITION=0.2`). + +**Net retail behavior:** de-overlap is **proactive** (movement is collision-stopped *before* +creatures interpenetrate) and **persistent** (the resolved position is never overwritten by a +snap-into-overlap). ACE broadcasts each creature's own pathed (overlapping) position with no +server-side separation; **the client spreads them.** (ACE/holtburger source not checked out — +empty submodule dirs — but the decomp + the #182 live cdb trace establish client-responsibility.) + +### acdream: the two divergences (both verified in source) +1. **NPC (monster) `UpdatePosition` HARD-SNAPS to the raw server position.** `GameWindow.cs:5925-5926`: + `if (!snapSuppressedByStick) rmState.Body.Position = worldPos;` — only suppressed while a sticky + melee lease is armed. So a packed monster teleports into the overlapping server position every + UP, **overwriting** whatever de-penetration the per-tick sweep achieved. The sweep resolves + *movement*, not a static overlap it gets snapped into, so the overlap persists. +2. **A two-path fork retail doesn't have** (`GameWindow.cs:10076`): + - **Path A** — grounded **player** remotes (`IsPlayerGuid` && !Airborne, `:10076-10311`): advance + via the `RemoteMotionCombiner.ComputeOffset` interp catch-up, **`ResolveWithTransition` is + deliberately NOT called** (`:10274-10281` "collision is the sender's problem" — factually + wrong per the decomp). Player remotes never de-overlap. + - **Path B** — NPCs/monsters + airborne player remotes (`:10312-10698`): DO call + `ResolveWithTransition` (`:10558`) with `moverFlags: EdgeSlide` (not IsViewer/IgnoreCreatures), + so the `CollisionExemption` gate already lets a monster collide against other creatures. BUT + the NPC UP hard-snap (#1) overwrites its result, and the swept displacement is only + `preIntegrate→postIntegrate` (tiny between UPs), so it can't undo a snapped-in static overlap. + +**So the collision math is already faithful and present.** The bug is the **reconciliation** +(hard-snap) + the **fork** (player remotes skip the sweep). Retail's gentle catch-up + fork-free +tick is what makes the resolved position persist. + +--- + +## 2. Design — port retail's unified per-remote tick + MoveOrTeleport reconciliation + +Make every remote creature run the retail `UpdateObjectInternal` shape: **interp-catch-up delta + +anim velocity → `ResolveWithTransition` sweep → store the collision-resolved position**, with the +server `UpdatePosition` fed in as a **gentle DR target** (MoveOrTeleport), not a hard-snap. This +collapses Path A and Path B into one and lets the per-tick de-overlap persist. + +### 2.1 Retail functions being ported (chain) +| Function | Address | Role | acdream target | +|---|---|---|---| +| `CPhysics::UseTime` | 0x00509950 | walk ALL objects, tick each | the `_animatedEntities` per-tick loop `GameWindow.cs:10019` (collapse the fork) | +| `CPhysicsObj::MoveOrTeleport` | 0x00516330 | teleport-snap / near-interpolate(<96) / far-snap(≥96) | the remote `UpdatePosition` handler `GameWindow.cs:5879+` (extend the player-remote routing to NPCs) | +| `InterpolationManager::adjust_offset` | 0x00555d30 | speed-capped catch-up (≤2×max_speed·dt) + fail-counter blip | acdream `InterpolationManager` (`rm.Interp` — already present) | +| `UpdateObjectInternal` | 0x005156b0 | integrate → transition → commit resolved pos | the unified per-remote tick (reuse `ResolveWithTransition`) | + +### 2.2 In scope (rebuilt verbatim) +- **NPC `UpdatePosition` reconciliation:** replace the hard-snap (`GameWindow.cs:5925-5926` + + the orientation/velocity snaps `:5952-5967+`) with the retail `MoveOrTeleport` routing already + written for player remotes (`:5822-5848`): far (>96 m) → snap + clear queue; near → `Interp.Enqueue` + the server waypoint; teleport/parent → snap. Keep the sticky-suppression escape (it is retail's + "stuck → server correction can't fight the armed stick" behavior, TS-41/44). +- **Unify the tick:** every remote (Path A player *and* Path B NPC) advances by the interp + catch-up (`Interp.AdjustOffset`) **composed into a `MotionDeltaFrame`** (interp + sticky), then + runs `ResolveWithTransition` and **stores the resolved position** — the pattern Path B already + uses for the sticky delta at `:10521-10528`. Path A gains the sweep; Path B gains the interp + catch-up as its movement source (so the swept displacement IS the catch-up). +- **Setup-derived mover sphere:** replace the hard-coded human dims `0.48f/1.835f` + (`GameWindow.cs:10551-10556`) with the creature's own Setup sphere / `ObjScale` so differently + sized creatures de-overlap at their true radii (retail sizes the mover sphere from its own Setup + via `init_sphere`). +- The `node_fail_counter` blip watchdog (already in `InterpolationManager`) — keep; it is the + retail "genuinely blocked → snap to server after ~5 frames" escape. + +### 2.3 OUT of scope (kept — already faithful) +- The **transition internals** (`ResolveWithTransition` and below — BSPQuery, the CSphere/CylSphere + families incl. the #182 port, `CollisionExemption`, cell membership). Retail's remote tick calls + these primitives; so does ours. Untouched. +- The **shadow registry** (`ShadowObjects.Register`/`UpdatePosition`, `GameWindow.cs:4171/5665`) — + every remote creature is already registered + live-synced (retail's `AddShadowObject`/`change_cell` + equivalent). No new registration. +- The **R5 managers** (`MovementManager`/`MoveToManager`/`TargetManager` + the `Motion.PositionManager` + Sticky/Constraint facade). A verbatim `UpdateObjectInternal` ticks exactly these — the port ticks + them, doesn't rewrite them. +- The **#182 local-player rebuild** (this session) — orthogonal; stays as the base. + +### 2.4 Architecture (Code Structure Rule 1: no new feature bodies in GameWindow) +`GameWindow.cs` is already >10k lines and owns the remote tick. The unified per-remote update body +goes into a **new `RemotePhysicsUpdater`** (`src/AcDream.App/Physics/` or `Rendering/`) that takes +a `RemoteMotion` + dt + the `PhysicsEngine` and runs the retail `UpdateObjectInternal` chain +(interp delta → sweep → commit). `GameWindow`'s per-tick loop shrinks to: resolve the entity → +`RemotePhysicsUpdater.Tick(rm, dt)` → write `entity.SetPosition(rm.Body.Position)`. The +`UpdatePosition` `MoveOrTeleport` routing is extracted into a `RemotePhysicsUpdater.OnServerPosition` +(or a small `RemoteReconciler`) so the NPC and player-remote paths share ONE implementation. + +### 2.5 Staging (measured, de-overlap first) +- **Slice 1 — NPC reconciliation (the reported symptom):** replace the NPC hard-snap with the + `MoveOrTeleport` gentle catch-up (Enqueue), and drive Path B's body advancement from the interp + catch-up so its existing sweep resolves the catch-up movement against other creatures. Gate: + packed monsters visibly de-overlap side-by-side vs retail; no remote jitter/rubber-band regression. +- **Slice 2 — unify the fork:** collapse Path A into the same `RemotePhysicsUpdater.Tick` (player + remotes gain the sweep → packed *players* de-overlap too). Extract the shared updater + (§2.4). Gate: remote player crowd de-overlaps; observed-motion (walk/run/jump/land) unchanged. +- **Slice 3 — Setup-derived mover sphere** (§2.2). Gate: large/small creatures de-overlap at true + radii. + +--- + +## 3. Validation +- **Conformance tests (Core/App):** a `RemotePhysicsUpdaterTests` harness — two registered creature + shadows converging on a point, driven through the updater, assert they settle at + contact-distance (sum of radii), not overlapping; a `MoveOrTeleport` routing test (near→enqueue, + far→snap, teleport→snap); a reconciliation persistence test (a de-penetrated position survives + the next server UP instead of snapping into overlap). +- **Suites stay green:** Core 2617 / App 741 — no regression to remote animation (walk/run/jump/ + land/turn), dead-reckoning smoothness, sticky melee (#171), or the #182 player fix. +- **Visual gate (the acceptance test):** side-by-side vs the retail client on the same ACE — packed + monsters spread to retail spacing (arms no longer interpenetrating); remotes don't jitter, + rubber-band, or desync from the server; sticky-melee facing (#171) unbroken. +- **Apparatus:** `ACDREAM_REMOTE_VEL_DIAG` (existing) for the UP/seq pace; a `[remote-deoverlap]` + probe optional (rendered pos vs raw server UP + neighbor overlap depth) if a residual needs it. + +## 4. Risk +Touches the **R4/R5 remote dead-reckoning arc** (a shipped area): the NPC hard-snap and the +two-path fork. Mitigations: the collision + shadow-registry + R5-manager substrate is UNTOUCHED +(the change is the reconciliation + fork), the sticky-suppression escape is preserved (protects +#171), staged so the NPC de-overlap lands + gates first, and `node_fail_counter` keeps a genuinely +stuck remote from freezing. Perf: N creatures × a sweep per tick — Path B already does this for +NPCs; Slice 2 adds it for player remotes (bounded by the visible-crowd count). + +## 5. Register bookkeeping +- Retire the "remotes skip the transition / server already collision-resolved" adaptation (the + `GameWindow.cs:10089/10275` premise, filed under #40 / ISSUES.md ~:4899) — it's the exact + divergence whose "Risk if assumption breaks" is the arms-overlap. Same commit that lands Slice 2. +- Any adaptation the port introduces (e.g. the NPC MoveOrTeleport near-distance constant, the + sticky-suppression retention) gets its row in the same commit. + +## 6. Open items to verify during implementation +1. Confirm Path B's `ResolveWithTransition` sweep, once driven by the interp catch-up delta, + actually de-penetrates a converging neighbor (vs the tiny-displacement no-op) — the + `RemotePhysicsUpdaterTests` converging pair pins this before the visual gate. +2. Confirm the NPC `Interp.Enqueue` path composes with `TickRemoteMoveTo` / `ServerVelocity` / + the sticky delta without double-driving the body (the `RemoteMotionCombiner` REPLACE dichotomy). +3. Decide whether Slice 1 needs Path B to *also* route through `Interp` (likely) or whether feeding + `ServerVelocity` into the existing integrate+sweep suffices — measure with the converging-pair test.