acdream/docs/research/2026-07-07-remote-creature-deoverlap-handoff.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

17 KiB
Raw Blame History

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 — 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 8bb8b204e3c4c59b, 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 < 96InterpolateTo (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) + ApplyPositionManagerDeltakeep, 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).