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>
13 KiB
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 everyCPhysicsObjin the object table and callsupdate_object → UpdateObjectInternalon each — no fork between the player and remote creatures. The player only gets an extra camera callback.UpdateObjectInternal(0x005156b0) runs the fulltransition(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 throughCPhysicsObj::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).
- newer teleport-TS OR no cell → hard snap (
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)writessphere_path.curr_pos, not the raw target); the next server update re-aims the catch-up from wherever collision left the creature. Anode_fail_counterblips 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)
- NPC (monster)
UpdatePositionHARD-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. - A two-path fork retail doesn't have (
GameWindow.cs:10076):- Path A — grounded player remotes (
IsPlayerGuid&& !Airborne,:10076-10311): advance via theRemoteMotionCombiner.ComputeOffsetinterp catch-up,ResolveWithTransitionis 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 callResolveWithTransition(:10558) withmoverFlags: EdgeSlide(not IsViewer/IgnoreCreatures), so theCollisionExemptiongate already lets a monster collide against other creatures. BUT the NPC UP hard-snap (#1) overwrites its result, and the swept displacement is onlypreIntegrate→postIntegrate(tiny between UPs), so it can't undo a snapped-in static overlap.
- Path A — grounded player remotes (
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
UpdatePositionreconciliation: replace the hard-snap (GameWindow.cs:5925-5926+ the orientation/velocity snaps:5952-5967+) with the retailMoveOrTeleportrouting already written for player remotes (:5822-5848): far (>96 m) → snap + clear queue; near →Interp.Enqueuethe 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 aMotionDeltaFrame(interp + sticky), then runsResolveWithTransitionand 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 /ObjScaleso differently sized creatures de-overlap at their true radii (retail sizes the mover sphere from its own Setup viainit_sphere). - The
node_fail_counterblip watchdog (already inInterpolationManager) — 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 (
ResolveWithTransitionand 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'sAddShadowObject/change_cellequivalent). No new registration. - The R5 managers (
MovementManager/MoveToManager/TargetManager+ theMotion.PositionManagerSticky/Constraint facade). A verbatimUpdateObjectInternalticks 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
MoveOrTeleportgentle 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
RemotePhysicsUpdaterTestsharness — two registered creature shadows converging on a point, driven through the updater, assert they settle at contact-distance (sum of radii), not overlapping; aMoveOrTeleportrouting 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/10275premise, 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
- Confirm Path B's
ResolveWithTransitionsweep, once driven by the interp catch-up delta, actually de-penetrates a converging neighbor (vs the tiny-displacement no-op) — theRemotePhysicsUpdaterTestsconverging pair pins this before the visual gate. - Confirm the NPC
Interp.Enqueuepath composes withTickRemoteMoveTo/ServerVelocity/ the sticky delta without double-driving the body (theRemoteMotionCombinerREPLACE dichotomy). - Decide whether Slice 1 needs Path B to also route through
Interp(likely) or whether feedingServerVelocityinto the existing integrate+sweep suffices — measure with the converging-pair test.