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