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:
parent
e3c4c59b84
commit
7f7a78d3ea
2 changed files with 418 additions and 0 deletions
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue