docs(#185): design v2 — REAL root cause = shadow part-id uint32 overflow

Live capture #3 (cp-write + entity-source + full bsp-test object map) disproved
v1's grounding-retention theory and pinned the real bug: GameWindow.cs:7951
partId = entity.Id*256+partIndex OVERFLOWS uint32 for class-prefixed landblock
ids (0x40/0x80/0xC0), dropping the prefix byte so different-class entities sharing
the low 24 bits collide on one shadow part-id; Register deregisters the loser
(last-writer-wins), silently deleting collision geometry while render shows every
step. Landblock 0xF682 has 23 such collisions incl. the stair runs. The player
floats into the collision hole and the PrecipiceSlide wedge fires = the invisible
wall (a faithful symptom). Fix = Option A: RegisterMultiPart per entity (unique
32-bit entity.Id, retail add_shadows_to_cells/AddPartsShadow model), unifying on
the one faithful multi-part path and deleting both synthetic-id schemes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-08 09:55:24 +02:00
parent 75e3b76445
commit b62b13ce21

View file

@ -1,171 +1,160 @@
# Design: #185 — local player jams half-way up outdoor stairs (fix) # Design: #185 — local player jams half-way up outdoor stairs (fix)
**Date:** 2026-07-08 · **Status:** DESIGN (approved approach: A) — implementation not started **Date:** 2026-07-08 · **Status:** DESIGN v2 (root cause re-confirmed via live apparatus) — impl not started
**Milestone:** M1.5 (indoor world feels right) · **Family:** #137 / TS-4 synthetic-sliding-normal at a walkable seam, stair edition **Milestone:** M1.5
**Investigation handoff:** `docs/research/2026-07-08-185-outdoor-stairs-phantom-handoff.md`
**Decomp cross-check workflow:** `wf_3c1120c4-a04` (5 tracers + 3 adversarial verifiers, all high-confidence) > **v2 note (2026-07-08):** v1 of this spec chased a collision-*response* cause ("grounding
> retention at a convex coplanar seam", Approach A). Live capture #3 (`[cp-write]` +
> `[entity-source]` + full `[bsp-test]` object map) **disproved** that and pinned the REAL root:
> a uint32 overflow in the shadow-registry part-id that silently drops some landblock objects'
> collision geometry. The wedge is a faithful *symptom*. v2 supersedes v1 entirely.
--- ---
## 1. Symptom ## 1. Symptom
Running the **LOCAL player** up the outside stairs of a house-on-stilts, you hit an invisible Running the LOCAL player up the outside stairs of a house-on-stilts, you hit an invisible wall
block part-way up: you shuffle sideways but cannot advance. **A jump clears it** and you "in the middle of the stairs" (user's words) — the steps look unbroken, you slide sideways and
continue up normally. Pre-existing; unrelated to #184. Jam at world ≈ (132, 77.9, 61.5), cannot advance, and a jump clears it. Jam ≈ world (132, 77.9, 61.5), landblock `0xf682`,
landblock `0xf682`, cell `0xF682002C`. cell `0xF682002C`.
## 2. Root cause — CONFIRMED (decomp cross-check + live apparatus) ## 2. Root cause — CONFIRMED (live apparatus, code-verified)
### 2.1 The geometry (dumped this session) ### 2.1 The bug: a uint32 overflow in the shadow-registry part-id
The staircase is a **continuous, coplanar 38.7° ramp** built from stacked multi-part `src/AcDream.App/Rendering/GameWindow.cs:7951`, in the per-landblock-entity collision
**step-box objects** (`entityId` `0x00F68220`, `0x00F68221`, `0x00F68222`, …; each holds registration loop:
3 step parts; gfxObj `0x01000AC5` = one step-box, `0x01000ACA` = a sibling variant). Every
step's tread (poly id 4, local plane `(-0.625,0,0.781)`, world `(0,-0.625,0.781)`) lies on
the **same infinite plane** — verified: adjacent treads share both normal AND plane-d, so
they are genuinely coplanar, forming one continuous ramp. The tread quads abut at **0.5 m
seams in world Y**, and those seams fall on **object boundaries** (the objects interleave;
e.g. `0xF68222` part-3 at Y≈76.49 abuts `0xF68221` part-1 at Y≈76.50). gfxobj dumps:
`…/scratchpad/gfxdump/0x01000AC5.gfxobj.json`, `0x01000ACA.gfxobj.json`.
### 2.2 The mechanism (live capture `185-recapture.jsonl` + `[step-walk]` / `[stepdown-decide]`) ```csharp
uint partId = entity.Id * 256u + partIndex; // OVERFLOWS uint32
_physicsEngine.ShadowObjects.Register(partId, meshRef.GfxObjId, ...);
```
Walking up, acdream re-grounds every frame via the **step-down-to-maintain-contact** recovery Landblock entity ids carry a **class prefix in the top byte** (`0x40`/`0x80`/`0xC0`; e.g.
(`DoStepDown`, `TransitionTypes.cs:1339`). This *succeeds* at most positions (`accept=True` `0x40F68221`, `0xC0F68221`). For any id ≥ `0x01000000`, `* 256` (== `<< 8`) **overflows uint32
climbing to Y=77.914). At one seam it fails, and the fabrication chain runs: and drops the prefix byte**: `0x40F68221 * 256` and `0xC0F68221 * 256` both truncate to
`0xF6822100`. So **different-class entities that share the low 24 bits collide on the same shadow
part-id.** `ShadowObjectRegistry.Register` calls `Deregister(partId)` before inserting
(`ShadowObjectRegistry.cs:117`), so the second registrant **silently deletes the first's
collision geometry** (last-writer-wins). Rendering uses the full 32-bit `entity.Id` (no overflow),
so every step still *draws*.
1. The grounded forward move floats the foot **past the current tread's polygon** (kept A sibling scheme in the Setup cyl/sphere path (`shapeId = entity.Id + K*0x10000000u`, `:8032/8068/8093`)
"grounded" on the tread plane via the `LastKnownContactPlane` proximity restore) to is a lesser variant of the same "synthetic per-part id" anti-pattern (can collide across entities),
Y=78.12, Z=61.65 — but the forward `TransitionalInsert` reaches the **step-down dispatch but the #185 stairs are BSP-bearing so they hit the `*256` overflow specifically.
with `ci.ContactPlaneValid = false`** (the seed from `body.ContactPlane` is lost mid-sweep;
pinning *why* is implementation step 1, §3). Note: `ValidateTransition`'s LKCP proximity
restore later re-validates it, so `bodyAfter.contactPlaneValid = true` in the capture even
though the mover is wedged — the loss is transient-but-decisive, at the dispatch, not at
frame end.
2. Because contact is invalid at the dispatch, the step-down branch fires. `DoStepDown` first
**drops the sphere the full `stepDownHeight`** (Z 61.65 → 60.90) then sweeps `find_walkable`
**downward** — but the continuation tread is **coplanar (at the foot's level), now *above*
the dropped sphere** → nothing found → `[stepdown-decide] accept=False` (340× in the run).
3. `DoStepDown` fails → `EdgeSlideAfterStepDownFailed`**branch3** (`TransitionTypes.cs:1516`)
`PrecipiceSlide``FindCrossedEdge` returns `cross(tread_normal, top_edge)` =
**`(0,±0.78,±0.62)`** → `SlideSphere` sets it as `CollisionNormal`.
4. `ValidateTransition` (`:4635`) → `SetSlidingNormal` **zeroes Z****`(0,±1,0)`**.
5. Body persists it (`PhysicsEngine.cs:1136`); next frame **seeds** it (`:1020`); `AdjustOffset`
absorbs the +Y up-stairs offset → **sideways-only wedge**, pinned at Y=77.914. Self-sustaining
(seed→absorb→step-0-abort), with a fresh `(0,0.78,0.62)` re-fabricated every ~6 ticks. A jump's
+Z velocity survives the horizontal `(0,1,0)` plane, which is why a jump clears it.
Capture signatures (`185-recapture.jsonl`): pinned `result.position = (131.71, 77.914, 61.485)`; ### 2.2 Evidence (capture #3, `ACDREAM_PROBE_CONTACT_PLANE` + `ACDREAM_PROBE_BUILDING`)
`bodyAfter.slidingNormal = (0,1,0)`; `result.collisionNormal = (0,0.78,0.62)` on the re-fabrication
ticks; `contactPlane = walkablePlane = (0,-0.625,0.781)` (the real tread).
### 2.3 The retail divergence (named-retail decomp, high confidence) - **`[cp-write]`** attributes the contact-plane loss to `Transition.FindTransitionalPosition` (the
forward main-sweep) 530× — i.e. the mover loses grounding at the seam and drops into step-down →
the `PrecipiceSlide` wedge (the "invisible wall"). This is the SYMPTOM.
- **`[bsp-test]` object map** — the collision set has stair steps at world Y = 74.24→77.25 (lower
flight) and 79.25→80.25 (upper flight), with **NO objects at Y≈77.75/78.25/78.75**. The player
climbs the lower flight, floats past its top tread (Y=77.495) into the collision hole, finds no
ground, and wedges. The user confirms the steps there are **visually present** ("steps look
unbroken").
- **`[entity-source]` part-id collision analysis** — landblock `0xF682` has **23 part-ids with a
collision** (>1 entity → same part-id), including the stair runs themselves:
`0xF6822100 ← {0x40F68221, 0xC0F68221}`, `0xF6821200 ← {0x40F68212, 0xC0F68212}`,
`0xF6822000`, `0xF6822200`, … Overflow check: `0x40F68221*256 & 0xFFFFFFFF == 0xF6822100 ==
0xC0F68221*256 & 0xFFFFFFFF`.
Retail's grounded forward move **keeps `contact_plane_valid`** and hits the short-circuit ### 2.3 Why the wedge is a faithful symptom (do NOT fix it)
`if (this->collision_info.contact_plane_valid != 0) return 1;` (`transitional_insert`, pc 273244 /
`0x0050b818`) — it **never runs step-down at an interior ramp point**, so it never reaches
`edge_slide`/`precipice_slide`. acdream instead **loses grounding on the forward move** and leans
on the fragile per-frame step-down recovery, which cannot reach a **coplanar (at-level)**
continuation because step-down is strictly downward. The wedge is a *symptom* of over-relying on
step-down where retail stays grounded.
### 2.4 What is FAITHFUL — do NOT touch (adversarially verified) Once a step's collision is silently dropped, there is genuinely no ground there, so
`PrecipiceSlide` firing at the walkable edge is *correct* retail behavior for a real edge. The
`PrecipiceSlide` math, the `SetSlidingNormal` Z-zero, and the multi-object search are all
retail-faithful (decomp cross-check `wf_3c1120c4-a04`). Fixing the registration makes the geometry
present, the mover climbs normally, and the wedge never fires. **No change to the frozen collision
internals.**
- `PrecipiceSlide` math (`find_crossed_edge``slide_sphere`, conditional sign-negate to oppose ## 3. The fix — Option A: register multi-part landblock entities via `RegisterMultiPart`
travel): **faithful** (retail `precipice_slide` pc 274316/274354; conditional negate pc 274347).
- `SetSlidingNormal` **zeroing Z**: **faithful** — retail `set_sliding_normal` (`0x0050a060`,
pc 272137) also sets `sliding_normal.z = 0f` then normalizes. Un-zeroing is a DEAD END and would
itself be a divergence; and it is geometrically moot (both `cp.N` and the fabricated normal have
x=0, so the crease is pure-X for both the zeroed `(0,1,0)` and the 3D `(0,0.78,0.62)` — the +Y
offset is annihilated identically).
- The whole-cell **multi-object search**: **faithful**`FindObjCollisionsInCell` iterates the
full per-cell shadow list; both stair objects are tested (`[resolve-bldg]` confirms hits on
`0xF68220/21/22`). Not a scoping/registration gap.
## 3. The fix — Approach A (keep the mover grounded on the forward move) **Retail model (the anchor).** A multi-part physics object is one `CPhysicsObj` + `CPartArray`;
collision registration is `CPhysicsObj::add_shadows_to_cells` (0x00514ae0) →
`CPartArray::AddPartsShadow`, which walks the part array and writes each part's shadow into the
flooded cells, **all keyed to the single object** — no synthetic per-part id.
`ShadowObjectRegistry.RegisterMultiPart` is the direct port (its doc cites exactly this).
**Invariant to restore:** a grounded mover walking a continuous walkable surface **retains **Change (one call site — the per-entity registration block, `GameWindow.cs` ~78768100).**
`contact_plane_valid` on the forward move** (retail pc 273244), so it does not depend on the Replace the two synthetic-id `Register(...)` schemes with a single `RegisterMultiPart` per entity:
step-down recovery that fails at a coplanar seam.
**Scope (narrow):** the grounded forward-move path in `Transition.TransitionalInsert` / the 1. Build a `List<ShadowShape>` for the entity, honoring retail's binary dispatch (BSP-xor-cyl,
contact-plane retention it feeds (`TransitionTypes.cs` ~10301110 Slid-clears + the neg-poly/ `feedback_retail_binary_dispatch`):
step-down dispatch ~1339, and the `ValidateTransition` LKCP retention ~46454735). The change: - If any mesh-ref part has a physics BSP → add one **BSP** `ShadowShape` per BSP part
when the grounded foot sphere grazes the **coplanar walkable continuation** at an object seam, (`GfxObjId = meshRef.GfxObjId`, `LocalPosition/LocalRotation/Scale` decomposed from
retain / re-establish the contact plane from the tread it is resting on, rather than clearing it `meshRef.PartTransform`, `Radius = part BoundingSphere.Radius × scale`).
(a spurious Slid) and dropping into the step-down recovery. - Else if the Setup has CylSpheres/Spheres/Radius → add **Cylinder** `ShadowShape`s
(same local-offset/scale math as today, but as `LocalPosition` relative to the entity — the
registry rotates by `entityWorldRot` internally).
2. `if (shapes.Count > 0 && !entity.IsBuildingShell) ShadowObjects.RegisterMultiPart(entity.Id,
entity.Position, entity.Rotation, shapes, state:0, flags:None, origin.X, origin.Y,
lb.LandblockId, seedCellId: entity.ParentCellId ?? 0)`.
3. Keep the `IsBuildingShell` skip (building shells collide via the building channel — retail).
**Explicitly NOT changed:** the fabrication code, `SetSlidingNormal`, `PrecipiceSlide`, This uses each entity's **unique 32-bit `entity.Id`** as the sole key — no `*256`, no
`FindCrossedEdge`, the multi-object search — all verified faithful (§2.4). `+K*0x10000000`, no overflow, no collision — and **unifies the codebase on the one faithful
multi-part registration path** (the door + every server entity already use it). It removes the
redundant per-part loop.
**Pinning the exact line — the #137 method, not a guess:** implementation step 1 is a **dat-backed **Despawn.** Landblock unload must `Deregister(entity.Id)` (not the old synthetic part-ids). Verify
replay test** reproducing this exact seam crossing (fixtures already captured — §6). The replay the unload path deregisters by `entity.Id`; adjust if it currently targets `partId`.
reproduces the wedge headlessly; the forward-move contact-plane transitions are instrumented
*inside the test* (plus a short `ACDREAM_PROBE_CONTACT_PLANE` live pass ONLY if the replay can't
reach the exact frame) to identify the precise line where `contact_plane_valid` is lost. The fix
targets *that* line to match retail's retention. Same pattern as `Issue137CorridorSeamReplayTests`.
**Scope guard:** if pinning reveals the faithful fix is larger than a localized retention change, **Explicitly NOT changed:** `PrecipiceSlide`, `SetSlidingNormal`, the collision response, the
STOP and return to the user before expanding scope (frozen-internals rule). step-down chain — all verified faithful; the symptom disappears once geometry is present.
## 4. Alternatives considered ## 4. Alternatives considered
- **B — make step-down find the coplanar/at-level continuation.** Bends `DoStepDown`/`StepSphereDown` - **B — widen the shadow-registry key to `ulong`** (`partId = ((ulong)entity.Id<<8)|partIndex`).
away from retail's strictly-downward design; risks being a subtle divergence rather than a faithful Keeps the non-retail "each part is its own shadow object with a synthetic id" model, merely
fix. Rejected as primary. de-overflowed; fragments one retail object into N pseudo-objects; leaves two parallel
- **C — gate `EdgeSlide`/`PrecipiceSlide` to not fabricate when a live walkable continuation exists.** registration paths. Rejected — less architectural, less faithful.
Smallest blast radius, and retail's `edge_slide` does gate on `walkable != 0`, but it treats the - **v1 Approach A (grounding retention)** — disproved (§ v2 note): the continuation isn't in the
symptom site and masks the upstream grounding loss (weaker against no-workarounds). Kept as the collision set to retain grounding on.
fallback if A proves too invasive.
## 5. Test strategy ## 5. Test strategy
- **Red→green pin:** a new dat-backed replay (`Issue185OutdoorStairsSeamReplayTests`, pattern of - **Red→green unit pin (Core):** `ShadowObjectRegistryOverflowTests` — register two entities whose
`Issue137CorridorSeamReplayTests`) driving the captured trajectory through the failing seam: ids collide under the OLD `*256` scheme (e.g. `0x40F68221`, `0xC0F68221`) as multi-part; assert
pre-fix reproduces the wedge (no advance past Y≈77.9, `slidingNormal=(0,1,0)`); post-fix the BOTH remain queryable in their cells (OLD scheme: one overwrites the other; NEW: both present).
mover climbs past the seam with no fabricated sliding normal. Drive via `RegisterMultiPart` + `GetObjectsInCell`.
- **Regression net (the whole point — shared grounded-move path):** - **App-layer pin:** a focused test over the registration helper (extracted if needed) that feeds
- `Issue137*` suites (`Issue137CorridorSeamReplayTests`, `Issue137SlidingNormalLifecycleTests`, two colliding-id multi-part entities and asserts two distinct registrations survive. If the
`Issue137CorridorSeamInspectionTests`) green / kept un-skipped. registration logic is buried in `GameWindow`, extract the shape-list build + register into a
- Live spot checks (user visual gate): indoor cellar/corridor stairs, other outdoor buildings, small testable method (`LandblockEntityCollisionRegistrar`) per Code-Structure-Rule 1.
ramps, and flat ground — **no new phantom blocks AND no players sliding through walkable - **Keep the dat-free clean-climb replay** (`Issue185OutdoorStairsSeamReplayTests`, already written
edges/ramps.** + passing) as a regression pin (continuous stairs must climb).
- `dotnet build` + `dotnet test` green (Core / App / UI / Net). - **Regression:** `ShadowObjectRegistryTests`, `ShadowObjectRegistryMultiPartTests`,
`DoorBugTrajectoryReplayTests`, `Issue137*`, `CellarUp*`, `SphereCollisionFamilyTests` green;
full `dotnet build` + `dotnet test`.
- **Live gate (acceptance, user):** walk up the FULL staircase — no jam, no jump; plus a spot check
that torches/trees/other statics still collide (the registration path is shared).
## 6. Apparatus / fixtures (already captured this session) ## 6. Apparatus / fixtures (captured this session)
- Structured resolve capture: `…/scratchpad/185-recapture.jsonl` (jam pinned at - `185-recapture3.jsonl` + task `.output` (`[cp-write]`, `[entity-source]`, `[bsp-test]` object map).
`(131.71, 77.914, 61.485)`; `slidingNormal (0,1,0)`; `collisionNormal (0,0.78,0.62)`). - Overflow analysis (23 collisions) reproducible from the `[entity-source]` log.
- gfxobj geometry: `…/scratchpad/gfxdump/0x01000AC5.gfxobj.json` (+ `0x01000ACA`) — the step-box - gfxobj dumps `Fixtures/issue185/0x01000AC5.gfxobj.json` (+ `0x01000ACA`) for the replay.
tread (poly 4) + faces; local tread plane `(-0.625,0,0.781, d=-0.312)`.
- `[resolve-bldg]` per-object world origins + hit-poly world verts (raw task `.output`); tread N
world verts `(132.75,77.495,61.015)…`; per-step `entOrigin_lb` e.g. `0xF6822103 → (132.0,77.2,60.4)`.
- Reusable probes: `ACDREAM_CAPTURE_RESOLVE`, `ACDREAM_PROBE_STEP_WALK`, `ACDREAM_PROBE_INDOOR_BSP`
(`[stepdown-decide]`), `ACDREAM_PROBE_BUILDING` (`[resolve-bldg]`, heavy — raw `.output`, not the
Tee'd log), `ACDREAM_PROBE_CONTACT_PLANE` (forward-move contact transitions — for site pinning),
`ACDREAM_DUMP_GFXOBJS` (fixture extraction).
## 7. Bookkeeping (in the fix commit) ## 7. Bookkeeping (in the fix commit)
- Register: amend **TS-4** (or add a new row) to record the grounding-retention change; retire/narrow - Register: this is a bug fix (not a new deviation); if a row implied the old per-part id scheme,
as the faithful port dictates. none does — no register row needed, but note the fix in the ISSUES close.
- `claude-memory/project_physics_collision_digest.md`: add the #185 banner + DO-NOT-RETRY entries. - `docs/ISSUES.md`: move #185 to Recently closed with the SHA.
- `docs/ISSUES.md`: move #185 to Recently closed with the fix SHA. - `claude-memory/project_physics_collision_digest.md`: banner — #185 was a REGISTRATION overflow
- Roadmap/milestones: note the M1.5 #137-family stair fix. (part-id `*256` uint collision), NOT a collision-response bug; the wedge was a faithful symptom.
DO-NOT-RETRY: don't chase the wedge/precipice; the fix is the registration id.
## 8. Acceptance ## 8. Acceptance
- The LOCAL player **walks up the full outdoor staircase with no jam and no jump**; no sideways-slide - Local player walks up the full outdoor staircase, no jam/jump.
pin at the tread seams. - No collision-id collisions in a landblock (the 23-collision analysis returns 0 after the fix).
- Regression pass (§5): no new phantom blocks AND no players sliding through walkable ramps/edges; - Other landblock statics (torches/trees/buildings) still collide; suites + build/test green.
`Issue137*` green; build + test green.
- Register bookkeeping done in the fix commit.
## 9. DO-NOT-RETRY (carried from the handoff + this investigation) ## 9. DO-NOT-RETRY
- Do NOT touch step-up budget / step-up logic — there is no riser; it is a continuous walkable ramp. - Do NOT touch `PrecipiceSlide` / `SetSlidingNormal` / step-down — the wedge is a faithful symptom
- Do NOT "fix the geometry" — the dat is real, coplanar, walkable treads. of missing geometry, not the cause.
- Do NOT un-zero Z in `SetSlidingNormal` — faithful to retail and geometrically moot (§2.4). - Do NOT "widen the key" (Option B) — it preserves the non-retail synthetic-per-part-id model.
- Do NOT ship a per-frame sliding-normal clear or a small-offset-abort patch — fix the PROVENANCE - Do NOT reintroduce a synthetic per-part id (`*256` or `+K*0x10000000`) — the retail model is one
(the grounding loss), not the symptom (#137 lesson). object id + a part array (`RegisterMultiPart`).
- Do NOT read the Tee'd launch log for `[resolve-bldg]` — it's buffered; read the raw task `.output`.