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>
175 lines
13 KiB
Markdown
175 lines
13 KiB
Markdown
# 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.
|