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

13 KiB
Raw Blame History

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 < 96InterpolateTo (queue a DR node).
    • has-velocity AND player_distance ≥ 96far 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.