acdream/docs/superpowers/specs/2026-07-07-player-physics-update-verbatim-rebuild-design.md
Erik 80881ed6f1 docs(#182): crowd-collision investigation outcome + velocity-model rebuild design
The #182 CSphere port (96ae2740) failed its visual gate and introduced an
airborne "stuck in the falling animation" regression. A player-attributed retail
cdb trace (tools/cdb/retail-crowd-jump3.cdb) proved retail's LOCAL client fully
runs player-vs-creature collision (76 land_on_sphere, 188 COLLIDED, 130 SLID,
~78% OK, glides across) -- NOT server-authoritative (an earlier unfiltered
land_on_sphere=0 read was a false lead the attributed trace refuted).

acdream's same-repro capture: 50.9% OK, 22.4% stuck, 115 airborne-stuck. Root
divergence: retail CPhysicsObj::UpdateObjectInternal (0x005156b0, pc:283688) sets
cached_velocity = (resolved - old)/dt -- velocity from ACTUAL movement, so a
blocked jump collapses to ~0 -> gravity -> the player falls/glides. acdream
integrates velocity + reflects on collision (PlayerMovementController ~:1008-1069),
so the jump velocity (~18) persists against the creature -> hang.

Fix = verbatim rebuild of the per-frame player-physics loop (UpdateObjectInternal
chain), velocity model first, transition internals kept. Full design +
retail function inventory + the capture apparatus + retail target numbers:
docs/superpowers/specs/2026-07-07-player-physics-update-verbatim-rebuild-design.md.
Implementation deferred to a fresh session (user decision). Also files #183
(floating distant scenery, observed during testing). #182 stays as the base.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:07:56 +02:00

9.4 KiB
Raw Blame History

Design: verbatim rebuild of the per-frame player physics update loop

Date: 2026-07-07 · Status: DESIGN APPROVED (implementation deferred to a fresh session) Driving issue: #182 (player-vs-monster crowd collision) + the airborne "stuck in the falling animation" regression it exposed. Brainstorm decisions (user): verbatim rebuild (highest fidelity); scope = the per-frame update LOOP, keep everything already faithful; stage the velocity model first and measure; do the implementation in a NEW session.


1. Problem (from the user, live)

  1. Crowd jam — packed in a group of monsters, the player gets stuck and can't push through / wiggle out. Retail lets you shove monsters aside and keep moving.
  2. Airborne stuck — jumping up into a crowd, retail lets you land/glide across the monster tops; acdream hangs in the falling animation unable to rise or fall.

The #182 CSphere-family port (committed 96ae2740) was faithful but aimed a layer too low: it fixed the per-contact sphere response, did NOT fix the crowd feel, and introduced the airborne-stuck regression (before #182 the hand-rolled de-penetration shoved the player up onto monsters).

2. Evidence — the traces (the load-bearing part; do not re-derive)

Retail, player-attributed cdb trace (tools/cdb/retail-crowd-jump3.cdb)

Filtered to the local player's CPhysicsObj (captured from set_local_velocity), during a jump-into-a-crowd repro:

pValidate=1482  pCOLLIDED=188 (12.7%)  pSLID=130 (8.8%)  pLand=76   → ~78% OK
  • The retail local client fully runs player-vs-creature collision — 76 CSphere::land_on_sphere (airborne creature landings), 188 COLLIDED, 130 SLID — and still nets ~78% OK. NOT server-authoritative. (An earlier v2 unfiltered read of land_on_sphere=0 was a false lead — the attributed trace refuted it.)
  • The player glides across / lands on the crowd via local physics.

acdream, ACDREAM_CAPTURE_RESOLVE JSONL capture (same repro, current #182 build)

Of the player's move-intent resolves (2883 of 41832 total):

acdream retail
OK (reached target) 50.9% ~78%
partial / slid 26.7% ~9%
stuck (reverted, no advance) 22.4% ~13%
airborne-stuck 115 frames (glides off)

acdream jams ~2× harder, and the 115 airborne-stuck frames ARE the falling-animation bug. In those frames: velocity (0,0,~18) (a jump), a near-horizontal creature collision normal (e.g. (-0.96,-0.25,-0.15)), the transition reverts (no advance), and the sliding normal flips frame-to-frame — the #137 anti-parallel-absorb wedge.

3. Root divergence (verified against decomp + capture)

Retail CPhysicsObj::UpdateObjectInternal (0x005156b0, pc:283611) recomputes the player's velocity from the ACTUAL collided movement every frame:

newFrame   = UpdatePositionInternal(this, dt)          // integrate velocity + gravity
transition = CPhysicsObj::transition(this, m_position, newFrame, 0)   // collision sweep
if transition == null: set_frame(newFrame); cached_velocity = 0
else:
    offset          = get_offset(m_position, transition.sphere_path.curr_pos)
    cached_velocity = offset / dt          // ← VELOCITY = ACTUAL MOVEMENT / dt  (0x005158cb-005158ff)
    SetPositionInternal(transition)

So when the player is blocked (resolved ≈ old position), cached_velocity → ~0, gravity resumes next frame, and the player falls / settles / glides off the obstacle. The collision naturally bleeds the velocity — there is no reflection step.

acdream uses a different model: PhysicsBody integrates velocity/gravity, then PlayerMovementController (~:1008-1069) reflects the into-wall component on collision (Velocity = v + n·k) and clamps Z on landing. For a straight-up jump into a creature whose collision normal is horizontal, the reflection barely touches +Z, so the jump velocity persists — the player keeps shoving up into the creature and hangs. This is the airborne-stuck, and the same "velocity doesn't bleed on block" shape drives the general 2× jam.

This velocity model is the centerpiece of the fix.

4. Design — verbatim rebuild of the per-frame loop

Port retail's UpdateObjectInternal chain verbatim into a single owner of the per-frame player physics. Reuse the already-faithful transition internals as the collision primitive — do not touch them.

4.1 Retail functions to port (chain of UpdateObjectInternal)

Function Address Role
CPhysicsObj::UpdateObjectInternal 0x005156b0 per-frame entry: integrate → transition → commit → velocity
CPhysicsObj::UpdatePositionInternal (grep) integrate velocity + gravity + forces → candidate frame
CPhysicsObj::calc_acceleration 0x00510950 gravity / friction / buoyancy
CPhysicsObj::handle_all_collisions 0x00514780 retail's collision velocity response (verify role vs the cached_velocity model)
CPhysicsObj::SetPositionInternal 0x00515330 / 0x00515bd0 / 0x00516040 (3 overloads) commit the transition's resolved position
CPhysicsObj::transition (grep) collision sweep — maps to acdream ResolveWithTransition, reused as-is

4.2 In scope (rebuilt verbatim)

The per-frame player physics loop: velocity + gravity integration (UpdatePositionInternal/calc_acceleration), the transition orchestration (call → commit), the velocity-from-movement recompute (cached_velocity = (resolved old)/dt), and handle_all_collisions.

4.3 OUT of scope (kept — already faithful, per the user "keep everything faithful")

The transition INTERNALS: ResolveWithTransition and everything under it — BSPQuery, the CSphere/CylSphere collision families (incl. the #182 port), cell membership, terrain, streaming. Retail's transition calls these primitives and so does ours.

4.4 Architecture

A new PhysicsObjUpdater (or a PhysicsBody.UpdateObjectInternal(dt) method) owns the retail chain verbatim. It absorbs: PhysicsBody's ad-hoc gravity/friction integration AND PlayerMovementController's post-resolve bounce/reflect/clamp block (~:1008-1069). PlayerMovementController shrinks to: input → velocity/jump intent → hand off. The retail velocity-from-movement model replaces reflect-and-clamp wholesale. Register a divergence-register row retiring the reflect model + any adaptation rows the port introduces.

4.5 Staging (velocity model first, measured)

  • Slice 1: port UpdatePositionInternal + calc_acceleration + the cached_velocity = (resolvedold)/dt recompute, replacing the integrate+reflect path. A/B against the crowd capture: require the airborne-stuck count → 0 and OK% to climb toward retail's ~78%. Gate (suites + a visual pass on ordinary jump/walk/fall) before continuing.
  • Slice 2: handle_all_collisions verbatim (once Slice 1 shows what residual the velocity model leaves) + any remaining UpdateObjectInternal orchestration.
  • Slice 3+: close the residual ground-jam gap if the velocity model alone doesn't reach ~78% OK; re-measure each slice against the capture.

5. Validation

  • Apparatus (built this session, reuse): ACDREAM_CAPTURE_RESOLVE=<path> JSONL of every player resolve; the py histogram (OK / partial / stuck / airborne-stuck; see §2). Retail target: ~78% OK, 12.7% COLLIDED, 8.8% SLID, 0 airborne-stuck. Retail cdb scripts: tools/cdb/retail-crowd-jump{2,3}.cdb (v3 = player-attributed; safe auto-detach via validate_transition terminal + top-level qd).
  • Conformance tests: velocity-from-movement (blocked → velocity → ~0); jump + gravity integration; the UpdateObjectInternal sequence; a crowd replay.
  • Suites stay green: Core 2603 / App 741 (no regression to walking/stairs/slopes/ walls/streaming — the transition internals are untouched).
  • Visual gates: (a) crowd glide/land like retail (the #182 symptom); (b) a regression pass on normal locomotion — flat walking, slopes, stairs, jumping, falling, wall-slide.

6. Risk

Highest-risk option: it replaces the core of every jump/fall/step. Mitigation: the scope boundary (transition internals untouched), the measured slicing (Slice 1 behind an A/B compare), and heavy gating. If Slice 1 regresses ordinary locomotion, bisect within the velocity model before proceeding.

7. Open items for the new session (verify during writing-plans)

  1. Read UpdatePositionInternal, calc_acceleration (0x00510950), and handle_all_collisions (0x00514780) fully — confirm the integration + the role of handle_all_collisions vs the cached_velocity model (does retail ALSO reflect, or is velocity purely movement-derived?).
  2. Confirm which SetPositionInternal overload the player path uses (3 exist).
  3. Decide whether the general ground-jam (22% stuck vs retail 13%) is fully explained by the velocity model or needs a second divergence (de-penetration / sliding-normal provenance — the #137/TS-45 wedge family). Measure after Slice 1.
  4. #182 stays as the base (user decision). NOTE: until this rebuild lands, the codebase carries the airborne-stuck regression #182 introduced.

8. Pointers

  • Physics/collision SSOT: claude-memory/project_physics_collision_digest.md (updated with the #182 + this-investigation banner).
  • #182 issue: docs/ISSUES.md. CSphere pseudocode: docs/research/2026-07-07-csphere-collision-family-pseudocode.md.
  • Retail debugger toolchain + the safe-detach recipe: memory/project_retail_debugger.md + CLAUDE.md.