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>
243 lines
17 KiB
Markdown
243 lines
17 KiB
Markdown
# Handoff: verbatim port of retail's per-remote physics tick so packed monsters de-overlap
|
||
|
||
**Date:** 2026-07-07 · **Status:** DESIGN SPEC WRITTEN + APPROVED, IMPLEMENTATION NOT STARTED
|
||
(fresh session). **Driving report (user, side-by-side vs retail on the SAME ACE):** monsters
|
||
packed around the player **overlap** (arms interpenetrating) in acdream; in the retail client they
|
||
barely overlap. That overlap is why there's "no room to slide out" of a crowd.
|
||
|
||
**Read these first (in order):**
|
||
1. Design spec: [`docs/superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md`](../superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md) — the full design.
|
||
2. This handoff — the executable detail + the exact verified code sites + what to preserve.
|
||
3. Physics digest banner (SSOT): `claude-memory/project_physics_collision_digest.md` (2026-07-07,
|
||
incl. the "the room is SLIDING, user RETRACTED the jiggle framing" note).
|
||
|
||
---
|
||
|
||
## 0. Session context — what shipped, what this is
|
||
|
||
This work is a **new, distinct thread** from the #182 *player* physics rebuild that shipped THIS
|
||
session (6 commits `8bb8b204`→`e3c4c59b`, Core 2617 / App 741 green, airborne-stuck **confirmed
|
||
fixed by the user's visual gate**: "Works better, don't get stuck in the animation"). That rebuild
|
||
is landed — do NOT redo it. Its own residual (the grounded ground-jam, #137 family) is a separate
|
||
Slice 3 tracked in ISSUES #182.
|
||
|
||
During that gate the user noticed the crowd still feels wrong and diagnosed it side-by-side vs
|
||
retail: **the monsters are packed tighter / overlapping in acdream**. That's THIS handoff.
|
||
|
||
---
|
||
|
||
## 1. Verified root cause (decomp + source — do NOT re-derive)
|
||
|
||
Research workflow `wf_d2ff782f-9cb` (4 read-only agents, Opus) + my own direct source reads. Retail
|
||
`pc:`/`0x…` anchors are into `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
|
||
|
||
**Retail runs ONE unified per-object tick and collides every creature locally:**
|
||
- `CPhysics::UseTime` (**0x00509950**, pc:271640) walks **every** `CPhysicsObj` in the object table
|
||
and runs `update_object → UpdateObjectInternal` on each — **no fork** player-vs-remote. The player
|
||
only gets an extra camera callback.
|
||
- `UpdateObjectInternal` (**0x005156b0**) runs the full `transition` (collision) 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*
|
||
or *IGNORE_CREATURES* mover. So **moving remote creatures de-overlap against each other, client-side.**
|
||
- A remote stays "active" via the motion system feeding velocity each tick (`apply_interpreted_movement`
|
||
0x00528600 → `set_local_velocity` 0x005114d0 → `set_velocity` 0x005113f0 sets the 0x80 active bit).
|
||
|
||
**Retail's server position is a GENTLE dead-reckoning TARGET, not a hard-snap:**
|
||
- `SmartBox::HandleReceivedPosition` (**0x00453fd0**) → `CPhysicsObj::MoveOrTeleport` (**0x00516330**):
|
||
teleport-TS / no-cell → hard snap; has-velocity AND `player_distance < 96` → **`InterpolateTo`**
|
||
(queue a DR node); has-velocity AND `≥ 96` → far snap.
|
||
- `InterpolationManager::adjust_offset` (**0x00555d30**) catches up toward the queued position at
|
||
**≤ 2× the creature's max animation speed × dt**. The per-tick **collision-resolved** position is
|
||
stored (`SetPositionInternal` writes `sphere_path.curr_pos`, NOT the raw target) and **persists**;
|
||
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.
|
||
|
||
**So retail's de-overlap is PROACTIVE (movement collision-stopped before interpenetration) and
|
||
PERSISTENT (never snapped-into-overlap).** ACE broadcasts each creature's own overlapping pathed
|
||
position; the CLIENT spreads them. (ACE/holtburger source are NOT checked out — empty submodule
|
||
dirs; the decomp + the #182 live cdb trace settle client-responsibility.)
|
||
|
||
**acdream's two divergences (both verified against source):**
|
||
1. **NPC (monster) `UpdatePosition` HARD-SNAPS** to the raw server position — `GameWindow.cs:5925-5926`:
|
||
`if (!snapSuppressedByStick) rmState.Body.Position = worldPos;` (suppressed only while a sticky
|
||
melee lease is armed). A packed monster teleports into the overlapping server position every UP,
|
||
**overwriting** the swept de-penetration. The sweep resolves *movement*, not a static overlap it
|
||
gets snapped into.
|
||
2. **A two-path fork retail doesn't have** (`GameWindow.cs:10076`):
|
||
- **Path A** — grounded *player* remotes (`IsPlayerGuid && !Airborne`, `:10076-10311`): advance
|
||
via `RemoteMotionCombiner.ComputeOffset` interp catch-up, **`ResolveWithTransition` NOT called**
|
||
(`:10274-10281` "collision is the sender's problem" — factually wrong per the decomp).
|
||
- **Path B** — NPCs/monsters + airborne player remotes (`:10312-10698`): **DO** call
|
||
`ResolveWithTransition` (`:10558`, `moverFlags: EdgeSlide`, self-skip `movingEntityId: kv.Key`)
|
||
— so a monster already collides against other creatures. But it drives the body from
|
||
`get_state_velocity()` (synth locomotion), and the NPC hard-snap (#1) overwrites the result.
|
||
|
||
**KEY INSIGHT: the collision math already exists and is faithful.** The bug is the **reconciliation
|
||
(hard-snap)** + the **movement model (synth-velocity instead of catch-up)** + the **fork**.
|
||
|
||
---
|
||
|
||
## 2. SCOPE REFINEMENT — read this before planning (discovered reading Path B)
|
||
|
||
The fix is **bigger than "replace the hard-snap."** Path B drives the body from
|
||
`get_state_velocity()` (the animation locomotion velocity), NOT the interp catch-up toward the
|
||
server target. For a monster to de-overlap, its per-tick **movement must chase the server target**
|
||
(so the sweep resolves *that* movement against neighbors and stops it at contact). So the faithful
|
||
fix **reworks Path B's movement model**: `synth-velocity → interp catch-up (ComputeOffset, like
|
||
Path A) → sweep → store resolved`. Retail's `UpdatePositionInternal` uses a **REPLACE dichotomy**
|
||
(the catch-up REPLACES the anim root motion when the queue is active; the anim just animates the
|
||
legs). This is a **delicate change to the FROZEN R4/R5 remote-DR arc** — treat it with care.
|
||
|
||
---
|
||
|
||
## 3. Exact code sites (all verified against source THIS session)
|
||
|
||
`RemoteMotion` type — `GameWindow.cs:441`: `.Body` (`:443` the sim PhysicsBody), `.Movement` (`:453`
|
||
R5 MovementManager; `.Motion => Movement.Minterp` `:455`, `.MoveTo` `:478`), `.Host` (`:485`
|
||
EntityPhysicsHost → TargetManager + the faithful `Motion.PositionManager` Sticky/Constraint facade),
|
||
`.Interp` (`:581` the `InterpolationManager` catch-up queue), `.Position` (`:590` the
|
||
`RemoteMotionCombiner`, NOT the retail facade).
|
||
|
||
**A. Remote `UpdatePosition` handler (`OnLivePositionUpdated`), `GameWindow.cs:~5640-5980`:**
|
||
- `:5655` `entity.SetPosition(worldPos)` (unconditional render snap, undone later per-branch).
|
||
- `:5665-5672` `ShadowObjects.UpdatePosition(entity.Id, worldPos, …)` — **keep** (server-truth
|
||
shadow sync; retail `change_cell`/`AddShadowObject`). NOTE: this syncs the *collision broadphase*
|
||
target to server truth even though the rendered body de-overlaps — verify this doesn't re-pack.
|
||
- `:5724` `if (IsPlayerGuid(update.Guid))` → **the player-remote branch ALREADY implements retail
|
||
MoveOrTeleport** (airborne no-op `:5776`; landing `:5791`; far>96 snap+clear `:5827-5832`; near
|
||
`Interp.Enqueue(worldPos, heading, isMovingTo:false, currentBodyPosition:…)` `:5843-5847`). **This
|
||
is the model to extend to NPCs.**
|
||
- The `else` (NPC) branch, `:5879-5975`: computes `ServerVelocity` synth (`:5863`/`:5889`, keep for
|
||
`[VEL_DIAG]`); **`:5925-5926` the HARD-SNAP** `rmState.Body.Position = worldPos` (suppressed while
|
||
`snapSuppressedByStick`); `:5952` `rmState.CellId = p.LandblockId`; `:5967+` orientation hard-snap
|
||
(also stick-gated). **THIS is what Slice 1 replaces with the MoveOrTeleport Enqueue.**
|
||
|
||
**B. Path B tick (NPC/legacy), `GameWindow.cs:~10312-10698`:**
|
||
- `:10457-10460` `rm.Body.set_local_velocity(rm.Motion.get_state_velocity(), …)` when OnWalkable —
|
||
**the synth-velocity movement source to REPLACE with the interp catch-up.**
|
||
- `:10484-10492` manual omega/orientation integration (ObservedOmega) — **keep**.
|
||
- `:10512` `preIntegratePos = rm.Body.Position`.
|
||
- `:10521-10526` sticky delta via `rm.Host.PositionManager.AdjustOffset(pmDelta, dt)` +
|
||
`ApplyPositionManagerDelta` — **keep, composes with the catch-up (retail adjust_offset order)**.
|
||
- `:10527-10528` `calc_acceleration()` + `UpdatePhysicsInternal(dt)` (integrates the synth velocity).
|
||
- `:10549-10583` **the SWEEP** `ResolveWithTransition(preIntegratePos, postIntegratePos, rm.CellId,
|
||
0.48f, 1.835f, 0.4f, 0.4f, isOnGround:!rm.Airborne, body:rm.Body, moverFlags:EdgeSlide,
|
||
movingEntityId:kv.Key)` — **KEEP; this is the creature-vs-creature de-overlap** (Slice 3 makes the
|
||
0.48/1.835 Setup-derived).
|
||
- `:10585` `rm.Body.Position = resolveResult.Position` + `:10586-10587` CellId writeback.
|
||
- `:10606+` the #173 airborne reflect (old AD-25 airborne-only bounce) — leave for now (or later
|
||
fold into `PhysicsObjUpdate.HandleAllCollisions` like the player path; NOT Slice 1).
|
||
|
||
**C. Path A tick (player remote, ComputeOffset, NO sweep), `GameWindow.cs:10076-10311`:**
|
||
- `:10173` `rm.Position.ComputeOffset(dt, rm.Body.Position, seqVel, ori, rm.Interp, maxSpeed,
|
||
terrainNormal)` — the catch-up model (REPLACE dichotomy). `:10274-10281` the "ResolveWithTransition
|
||
NOT called" opt-out. **Slice 2 adds the sweep here and unifies with Path B.**
|
||
|
||
**D. `RemoteMotionCombiner.ComputeOffset`, `RemoteMotionCombiner.cs:59-115`:** interp catch-up
|
||
(`interp.AdjustOffset` toward the queue head) REPLACES the frame when non-zero; else seqVel anim
|
||
fallback projected on the terrain plane. This is the movement primitive both paths should use.
|
||
|
||
**E. Substrate (KEEP, already retail-shaped):** shadow registry `RegisterLiveEntityCollision`
|
||
`GameWindow.cs:4171` (`:4276 RegisterMultiPart`, `IsCreature` `:4271`); `CollisionExemption`
|
||
`CollisionExemption.cs:59-129` (creature-vs-creature exempt only for viewer/ignore-creatures mover —
|
||
an NPC mover with `EdgeSlide` is NOT exempt, so it DOES collide with other creatures).
|
||
|
||
---
|
||
|
||
## 4. The plan (slices) — see §2.2/§2.5 of the design spec
|
||
|
||
**Slice 1 — NPC de-overlap (the reported symptom). The two changes are COUPLED, land together:**
|
||
1. **NPC UP handler** (`:5925-5926` + orientation `:5967+`): replace the hard-snap with the retail
|
||
`MoveOrTeleport` routing — mirror the player-remote branch (`:5822-5848`): far>96 → snap + clear
|
||
queue; near → `Interp.Enqueue`; teleport/parent → snap. **Keep** the `snapSuppressedByStick`
|
||
escape (it's retail's "armed stick owns the frame, server correction can't fight it", TS-41/44).
|
||
Keep the orientation hard-snap (retail hard-snaps orientation on UP).
|
||
2. **Path B tick** (`:10457-10528`): replace the `get_state_velocity` movement with the interp
|
||
catch-up as the movement source (use `rm.Position.ComputeOffset` / `rm.Interp.AdjustOffset` toward
|
||
the queued server target, REPLACE dichotomy), compose the sticky delta, **KEEP the sweep** (`:10558`)
|
||
and store `resolveResult.Position`. The anim (legs) still plays via the sequencer. `ServerVelocity`
|
||
stays for diagnostics only.
|
||
- **Gate:** monsters de-overlap side-by-side vs retail; no remote jitter/rubber-band/desync; sticky
|
||
melee (#171) unbroken; walk/run/jump/land unchanged; Core 2617 / App 741 green.
|
||
|
||
**Slice 2 — unify the fork:** collapse Path A into the same catch-up+sweep model (player remotes gain
|
||
the sweep → packed *players* de-overlap too). Per Code Structure Rule 1 (no new feature body in the
|
||
>10k-line `GameWindow`), extract a **`RemotePhysicsUpdater`** class (`src/AcDream.App/Physics/` or
|
||
`Rendering/`) owning the unified per-remote `UpdateObjectInternal` chain (interp delta → sweep →
|
||
commit); `GameWindow`'s loop shrinks to `RemotePhysicsUpdater.Tick(rm, dt)`. Extract the
|
||
`MoveOrTeleport` routing into a shared method so NPC + player-remote reconciliation is ONE impl.
|
||
Retire the "remotes skip the transition" adaptation (`:10089`/`:10275`, filed #40, ISSUES.md ~:4899).
|
||
|
||
**Slice 3 — Setup-derived mover sphere:** replace the hard-coded `0.48f/1.835f` (`:10551-10556`) with
|
||
the creature's Setup sphere / `ObjScale` so differently-sized creatures de-overlap at true radii.
|
||
|
||
---
|
||
|
||
## 5. What to PRESERVE (regression watch — the R4/R5 arc has many hard-won fixes)
|
||
|
||
- The **sweep + transition internals** (`ResolveWithTransition` and below), **shadow registry**,
|
||
**`CollisionExemption`** — untouched (already faithful).
|
||
- The **R5 managers** (`MovementManager`/`MoveToManager`/`TargetManager` + `Motion.PositionManager`
|
||
Sticky/Constraint) — TICKED by the unified update, not rewritten.
|
||
- **Sticky melee #171** (`snapSuppressedByStick` escape + the sticky delta at `:10521-10526`) — MUST
|
||
survive; it's the "monster facing while attacking" fix.
|
||
- The **airborne remote path** (`!IsGrounded` no-op `:5776`, landing transition `:5791`, gravity
|
||
integration between UPs) — MUST survive.
|
||
- The **omega/orientation manual integration** (`:10484-10492`) and orientation hard-snap — keep.
|
||
- The **`node_fail_counter` blip watchdog** in `InterpolationManager` — keep (retail's stuck→snap
|
||
escape; without it a genuinely-blocked remote freezes).
|
||
- The **#182 local-player rebuild** (this session) — orthogonal, untouched.
|
||
|
||
## 6. Gotchas
|
||
- **Don't double-move:** the catch-up REPLACES the synth-velocity (retail's REPLACE dichotomy), it is
|
||
NOT additive on top of `get_state_velocity`.
|
||
- **The two Slice-1 changes are coupled** — Enqueue is useless unless Path B consumes the queue; a
|
||
half-change (Enqueue without the catch-up movement) breaks DR (body stops tracking the server).
|
||
- **`ShadowObjects.UpdatePosition` (`:5665`) still syncs the broadphase to server truth** — verify
|
||
the de-overlapped *rendered* body and the server-truth *collision target* don't fight (retail's
|
||
shadow follows the collided position; check whether acdream should sync the shadow to the
|
||
collision-resolved position instead of raw server truth). Potential subtle bug — probe it.
|
||
- **The sweep only de-overlaps MOVING creatures** (it resolves movement, not static overlap). Retail
|
||
never lets deep static overlap form (proactive). The converging-pair test must drive real movement.
|
||
|
||
## 7. Validation
|
||
- **Conformance test** (`RemotePhysicsUpdaterTests`, easiest after the Slice-2 extraction): two
|
||
registered creature shadows given converging catch-up targets → assert they settle at
|
||
contact-distance (sum of radii), not overlapping; a `MoveOrTeleport` routing test (near→enqueue,
|
||
far→snap, teleport→snap); a persistence test (a de-penetrated position survives the next server UP).
|
||
- **Suites green:** Core 2617 / App 741 (no regression to remote anim, DR smoothness, sticky #171,
|
||
the #182 player fix).
|
||
- **Visual gate (acceptance):** 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/desync;
|
||
sticky-melee facing (#171) unbroken; remote walk/run/jump/land unchanged.
|
||
- **Apparatus:** `ACDREAM_REMOTE_VEL_DIAG=1` (existing, UP/seq pace); add a `[remote-deoverlap]` probe
|
||
(rendered pos vs raw server UP + neighbor overlap depth) if a residual needs it.
|
||
|
||
## 8. Risk
|
||
Touches the **frozen R4/R5 remote-DR arc**. The synth-velocity model was tuned against live ACE pace
|
||
over the R4/R5 sessions; replacing it with the catch-up model risks regressing remote walk/run
|
||
smoothness, the slope "staircase" fix, and the sticky melee. Mitigate: stage NPC-first + gate before
|
||
Slice 2; preserve every escape (§5); keep the sweep/registry/managers; the `node_fail_counter`
|
||
prevents a stuck-remote freeze.
|
||
|
||
## 9. Register bookkeeping (do in the landing commits)
|
||
- Retire the "remotes skip the transition / server already collision-resolved" adaptation
|
||
(`GameWindow.cs:10089`/`:10275`, #40) — its "Risk if assumption breaks" IS the arms-overlap. Retire
|
||
in the Slice-2 commit that adds the sweep to Path A.
|
||
- Add rows for: the NPC `MoveOrTeleport` near-distance constant (96 m), the sticky-suppression
|
||
retention, and (if kept) the shadow-follows-server-truth vs collision-resolved decision.
|
||
|
||
## 10. Retail anchors (addresses, for `grep named-first` in the fresh session)
|
||
`CPhysics::UseTime` 0x00509950 · `update_object` 0x00515d10 · `UpdateObjectInternal` 0x005156b0 ·
|
||
`UpdatePositionInternal` 0x00512c30 · `transition` 0x00512dc0 · `find_obj_collisions` 0x0052b750 ·
|
||
`MoveOrTeleport` 0x00516330 · `HandleReceivedPosition` 0x00453fd0 · `InterpolateTo` 0x005104f0 /
|
||
`InterpolationManager::InterpolateTo` 0x00555b20 · `adjust_offset` 0x00555d30 · `UseTime` 0x00555f20 ·
|
||
`set_local_velocity` 0x005114d0 · `apply_interpreted_movement` 0x00528600.
|
||
|
||
## 11. Pointers
|
||
- Design spec: `docs/superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md`.
|
||
- Physics digest (SSOT + DO-NOT-RETRY): `claude-memory/project_physics_collision_digest.md`.
|
||
- The #182 player rebuild (shipped this session, orthogonal base): plan
|
||
`docs/superpowers/plans/2026-07-07-player-physics-update-verbatim-rebuild.md`; the histogram
|
||
`tools/analyze_resolve_capture.py`.
|
||
- Research: workflow `wf_d2ff782f-9cb` (4-agent read-only sweep; findings distilled into §1-§3 here).
|