acdream/docs/superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md
Erik 7f7a78d3ea 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>
2026-07-07 20:29:06 +02:00

175 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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