docs: remote-creature de-overlap — design spec + fresh-session handoff

The crowd-tightness residual from the #182 gate: monsters overlap (arms) in acdream but
barely in retail on the SAME ACE. Verified root (workflow wf_d2ff782f-9cb + source): retail
runs UpdateObjectInternal+transition on EVERY remote creature (CPhysics::UseTime 0x00509950,
no fork) so they de-overlap client-side, with the server pos a gentle MoveOrTeleport catch-up
target (0x00516330), NOT a hard-snap. acdream (a) hard-snaps NPC remotes to the raw overlapping
server pos (GameWindow.cs:5925) overwriting the swept de-penetration, and (b) forks player-remotes
(skip sweep) from NPCs (sweep at :10558 but driven by get_state_velocity, not the catch-up).

Collision math already exists + is faithful; the fix is the reconciliation (hard-snap→catch-up)
+ the movement model (synth-velocity→interp catch-up) — a delicate rework of the frozen R4/R5
remote-DR arc, staged NPC-first. Design spec + full handoff (verified code sites, retail anchors,
preserve-list, gotchas, slices) written for a fresh session. Implementation NOT started.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-07 20:29:06 +02:00
parent e3c4c59b84
commit 7f7a78d3ea
2 changed files with 418 additions and 0 deletions

View file

@ -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.