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>
This commit is contained in:
Erik 2026-07-07 13:07:56 +02:00
parent 96ae274081
commit 80881ed6f1
5 changed files with 305 additions and 1 deletions

View file

@ -46,13 +46,50 @@ Copy this block when adding a new issue:
--- ---
## #183 — Floating distant scenery: trees from another biome hover in the distance
**Status:** OPEN
**Severity:** LOW
**Filed:** 2026-07-07
**Component:** rendering / scenery / streaming
**Description:** Observed during #182 crowd testing (Holtburg area, `+Acdream`): trees
that appear to belong to a different biome render **floating** in the distance —
detached from the ground, at the wrong Z and/or wrong placement. Distant/far-radius
scenery only; unrelated to the collision work.
**Root cause / status:** Unknown. Candidates: far-tier streaming placing scenery
before its terrain Z is resolved (procedural scenery Z uses the terrain height at the
cell — a stale/zero height would float it), a biome/terrain-type mismatch selecting
the wrong scene set, or a far-LOD placement offset. Not yet investigated.
**Files:** likely `src/AcDream.App/Rendering/Wb/` scenery pipeline
(SceneryRenderManager / SceneryHelpers) + the two-tier streamer's far tier.
**Acceptance:** distant scenery sits on the ground with correct biome/placement.
---
## #182 — Player wedges in a packed monster crowd, can't wiggle free (hand-rolled SphereCollision) ## #182 — Player wedges in a packed monster crowd, can't wiggle free (hand-rolled SphereCollision)
**Status:** DONE (fix landed; pending user visual gate) **Status:** IN-PROGRESS — CSphere port shipped (`96ae2740`) but **visual-gate FAILED**;
the real fix is a velocity-model rebuild (see design spec), deferred to a new session.
**Severity:** MEDIUM **Severity:** MEDIUM
**Filed:** 2026-07-07 **Filed:** 2026-07-07
**Component:** physics / collision **Component:** physics / collision
**GATE OUTCOME (2026-07-07):** The CSphere-family port below was faithful but did NOT
fix the crowd feel and INTRODUCED an airborne "stuck in the falling animation"
regression. Player-attributed retail cdb trace proved retail's local client fully runs
player-vs-creature collision (~78% OK, glides) — NOT server-authoritative. Root
divergence: retail's per-frame `UpdateObjectInternal` sets velocity = ACTUAL movement/dt
(blocked → velocity collapses → gravity → glide); acdream integrates+reflects (jump
velocity persists → hang). Fix = verbatim rebuild of the per-frame player-physics loop,
velocity model first. Design:
`docs/superpowers/specs/2026-07-07-player-physics-update-verbatim-rebuild-design.md`.
#182 stays as the base (user), so the codebase carries the airborne regression until
the rebuild lands.
**Description:** In a large group of monsters packed around the player it was too **Description:** In a large group of monsters packed around the player it was too
easy to get stuck — the player couldn't shuffle/slide out. Retail leaves room to easy to get stuck — the player couldn't shuffle/slide out. Retail leaves room to
wiggle free. wiggle free.

View file

@ -0,0 +1,167 @@
# 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.

View file

@ -0,0 +1,38 @@
.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\vigorous-joliot-f0c3ad\retail-crowd-jump.log
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
r $t0 = 0
r $t1 = 0
r $t2 = 0
r $t3 = 0
.echo === ARMED: land_on_sphere + validate_transition + FindObjCollisions; auto-detach after 25000 SetPositionInternal ===
* CSphere::land_on_sphere (0x005379a0) — an AIRBORNE mover's foot sphere hit another
* sphere (a creature body or a sphere-object). If this fires when you jump onto the
* monster pack, the LOCAL client is attempting a creature landing (branch 4 airborne).
* Rare -> always gc, never strands.
bp acclient!CSphere::land_on_sphere "r $t0 = @$t0 + 1; .printf \"[LAND-ON-SPHERE] #%d\\n\", @$t0; gc"
* CTransition::validate_transition (0x0050aa70) — the commit/revert. arg2 (at esp+4,
* thiscall) is the transitional_insert RESULT fed in: 1=OK 2=COLLIDED 3=ADJUSTED 4=SLID.
* Rate-limited so the log stays readable. A burst of arg2=2 (COLLIDED) during the
* jump-land = the local transition hit the creature and reverted (server must place you).
bp acclient!CTransition::validate_transition "r $t1 = @$t1 + 1; .if (@$t1 % 60 == 0) { .printf \"[VALIDATE] #%d arg2=%d\\n\", @$t1, poi(@esp+4) }; gc"
* CPhysicsObj::FindObjCollisions (0x0050f050) — count only. Confirms the local player's
* transition is testing object collisions at all during the repro.
bp acclient!CPhysicsObj::FindObjCollisions "r $t3 = @$t3 + 1; gc"
* CPhysicsObj::SetPositionInternal — the position commit AND the detach anchor. Fires for
* every object's position set (player + creatures), so it always reaches the threshold in
* a live crowd. On the 25000th hit it prints the summary and does NOT gc -> cdb breaks ->
* g returns -> the top-level qd detaches cleanly. (qd is NEVER inside a bp action.)
bp acclient!CPhysicsObj::SetPositionInternal "r $t2 = @$t2 + 1; .if (@$t2 < 25000) { gc } .else { .printf \"[SUMMARY] land_on_sphere=%d validate=%d findObjColl=%d setPos=%d\\n\", @$t0, @$t1, @$t3, @$t2 }"
g
.echo === DETACHING ===
qd
.logclose

View file

@ -0,0 +1,28 @@
.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\vigorous-joliot-f0c3ad\retail-crowd-jump2.log
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
r $t0 = 0
r $t1 = 0
r $t2 = 0
.echo === ARMED v2: land_on_sphere + validate(COLLIDED count); AUTO-DETACH after 6000 validate ===
* CSphere::land_on_sphere (0x005379a0) — an airborne mover's foot sphere hit another
* sphere. RARE -> cheap, no meaningful lag. $t1 (validate count) timestamps each hit so we
* can read the PATTERN: a brief burst per jump = the local client lands on the creature;
* a long steady burst = it's stuck locally (arming every frame) and the SERVER lifts you.
bp acclient!CSphere::land_on_sphere "r $t0 = @$t0 + 1; .printf \"[LAND] #%d vt=%d\\n\", @$t0, @$t1; gc"
* CTransition::validate_transition (0x0050aa70, UNAMBIGUOUS) — the commit/revert, AND the
* detach anchor. arg2 (poi esp+4) = the transitional_insert result: 1=OK 2=COLLIDED 4=SLID.
* We count COLLIDED and log every 5th so a jump that reverts locally shows a COLLIDED spike.
* On the 6000th hit (~15-20 s) it prints the summary and does NOT gc -> cdb breaks -> g
* returns -> the top-level qd detaches cleanly. qd is NEVER inside a bp action.
bp acclient!CTransition::validate_transition "r $t1 = @$t1 + 1; .if (@$t1 < 6000) { r $t3 = poi(@esp+4); .if (@$t3 == 2) { r $t2 = @$t2 + 1; .if (@$t2 % 5 == 0) { .printf \"[COLLIDED] #%d vt=%d\\n\", @$t2, @$t1 } }; gc } .else { .printf \"[DETACH] validate=%d land_on_sphere=%d collided=%d\\n\", @$t1, @$t0, @$t2 }"
g
.echo === DETACHING ===
qd
.logclose

View file

@ -0,0 +1,34 @@
.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\vigorous-joliot-f0c3ad\retail-crowd-jump3.log
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
r $t9 = 0
r $t0 = 0
r $t1 = 0
r $t2 = 0
r $t4 = 0
r $t5 = 0
.echo === ARMED v3 (PLAYER-ATTRIBUTED): capture player ptr from set_local_velocity; filter validate + land_on_sphere to it; auto-detach after 40000 validate ===
* Capture the LOCAL player's CPhysicsObj* from the first input-driven local-velocity set.
* Creatures are dead-reckoned (interpolated), not set via set_local_velocity, so the first
* hit after you start moving is the player. Printed so we can sanity-check it's one pointer.
bp acclient!CPhysicsObj::set_local_velocity "r $t8 = @ecx; .if (@$t9 == 0) { r $t9 = @$t8; .printf \"[PLAYER-PTR] %p\\n\", @$t9 }; gc"
* land_on_sphere: mover = arg2->object = poi(poi(esp+4)) (OBJECTINFO.object at offset 0).
* Log ONLY when the mover is the player. If this stays 0 while you jump onto monsters, the
* local client is NOT computing your creature landing (server places you).
bp acclient!CSphere::land_on_sphere "r $t7 = poi(poi(@esp+4)); .if (@$t7 == @$t9) { r $t0 = @$t0 + 1; .printf \"[PLAYER-LAND] #%d vt=%d\\n\", @$t0, @$t1 }; gc"
* validate_transition: mover = poi(ecx) (CTransition.object_info.object at offset 0). For the
* PLAYER only, tally the transition result arg2 (poi esp+4): 2=COLLIDED, 4=SLID, else OK/ADJ.
* pCOL/pSLID near 0 during the crowd jump = the player is NOT locally colliding with creatures.
* $t1 (ALL validates) is the unambiguous detach anchor -> break at 40000 -> qd.
bp acclient!CTransition::validate_transition "r $t1 = @$t1 + 1; .if (@$t1 >= 0n40000) { .printf \"[DETACH] pPtr=%p pValidate=%d pCOLLIDED=%d pSLID=%d pLand=%d\\n\", @$t9, @$t2, @$t4, @$t5, @$t0 } .else { .if (poi(@ecx) == @$t9) { r $t2 = @$t2 + 1; r $t3 = poi(@esp+4); .if (@$t3 == 2) { r $t4 = @$t4 + 1 } .else { .if (@$t3 == 4) { r $t5 = @$t5 + 1 } } }; gc }"
g
.echo === DETACHING ===
qd
.logclose