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>
160 lines
9.6 KiB
Markdown
160 lines
9.6 KiB
Markdown
# Design: #185 — local player jams half-way up outdoor stairs (fix)
|
||
|
||
**Date:** 2026-07-08 · **Status:** DESIGN v2 (root cause re-confirmed via live apparatus) — impl not started
|
||
**Milestone:** M1.5
|
||
|
||
> **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
|
||
|
||
Running the LOCAL player up the outside stairs of a house-on-stilts, you hit an invisible wall
|
||
"in the middle of the stairs" (user's words) — the steps look unbroken, you slide sideways and
|
||
cannot advance, and a jump clears it. Jam ≈ world (132, 77.9, 61.5), landblock `0xf682`,
|
||
cell `0xF682002C`.
|
||
|
||
## 2. Root cause — CONFIRMED (live apparatus, code-verified)
|
||
|
||
### 2.1 The bug: a uint32 overflow in the shadow-registry part-id
|
||
|
||
`src/AcDream.App/Rendering/GameWindow.cs:7951`, in the per-landblock-entity collision
|
||
registration loop:
|
||
|
||
```csharp
|
||
uint partId = entity.Id * 256u + partIndex; // OVERFLOWS uint32
|
||
_physicsEngine.ShadowObjects.Register(partId, meshRef.GfxObjId, ...);
|
||
```
|
||
|
||
Landblock entity ids carry a **class prefix in the top byte** (`0x40`/`0x80`/`0xC0`; e.g.
|
||
`0x40F68221`, `0xC0F68221`). For any id ≥ `0x01000000`, `* 256` (== `<< 8`) **overflows uint32
|
||
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*.
|
||
|
||
A sibling scheme in the Setup cyl/sphere path (`shapeId = entity.Id + K*0x10000000u`, `:8032/8068/8093`)
|
||
is a lesser variant of the same "synthetic per-part id" anti-pattern (can collide across entities),
|
||
but the #185 stairs are BSP-bearing so they hit the `*256` overflow specifically.
|
||
|
||
### 2.2 Evidence (capture #3, `ACDREAM_PROBE_CONTACT_PLANE` + `ACDREAM_PROBE_BUILDING`)
|
||
|
||
- **`[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`.
|
||
|
||
### 2.3 Why the wedge is a faithful symptom (do NOT fix it)
|
||
|
||
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.**
|
||
|
||
## 3. The fix — Option A: register multi-part landblock entities via `RegisterMultiPart`
|
||
|
||
**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).
|
||
|
||
**Change (one call site — the per-entity registration block, `GameWindow.cs` ~7876–8100).**
|
||
Replace the two synthetic-id `Register(...)` schemes with a single `RegisterMultiPart` per entity:
|
||
|
||
1. Build a `List<ShadowShape>` for the entity, honoring retail's binary dispatch (BSP-xor-cyl,
|
||
`feedback_retail_binary_dispatch`):
|
||
- If any mesh-ref part has a physics BSP → add one **BSP** `ShadowShape` per BSP part
|
||
(`GfxObjId = meshRef.GfxObjId`, `LocalPosition/LocalRotation/Scale` decomposed from
|
||
`meshRef.PartTransform`, `Radius = part BoundingSphere.Radius × scale`).
|
||
- 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).
|
||
|
||
This uses each entity's **unique 32-bit `entity.Id`** as the sole key — no `*256`, no
|
||
`+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.
|
||
|
||
**Despawn.** Landblock unload must `Deregister(entity.Id)` (not the old synthetic part-ids). Verify
|
||
the unload path deregisters by `entity.Id`; adjust if it currently targets `partId`.
|
||
|
||
**Explicitly NOT changed:** `PrecipiceSlide`, `SetSlidingNormal`, the collision response, the
|
||
step-down chain — all verified faithful; the symptom disappears once geometry is present.
|
||
|
||
## 4. Alternatives considered
|
||
|
||
- **B — widen the shadow-registry key to `ulong`** (`partId = ((ulong)entity.Id<<8)|partIndex`).
|
||
Keeps the non-retail "each part is its own shadow object with a synthetic id" model, merely
|
||
de-overflowed; fragments one retail object into N pseudo-objects; leaves two parallel
|
||
registration paths. Rejected — less architectural, less faithful.
|
||
- **v1 Approach A (grounding retention)** — disproved (§ v2 note): the continuation isn't in the
|
||
collision set to retain grounding on.
|
||
|
||
## 5. Test strategy
|
||
|
||
- **Red→green unit pin (Core):** `ShadowObjectRegistryOverflowTests` — register two entities whose
|
||
ids collide under the OLD `*256` scheme (e.g. `0x40F68221`, `0xC0F68221`) as multi-part; assert
|
||
BOTH remain queryable in their cells (OLD scheme: one overwrites the other; NEW: both present).
|
||
Drive via `RegisterMultiPart` + `GetObjectsInCell`.
|
||
- **App-layer pin:** a focused test over the registration helper (extracted if needed) that feeds
|
||
two colliding-id multi-part entities and asserts two distinct registrations survive. If the
|
||
registration logic is buried in `GameWindow`, extract the shape-list build + register into a
|
||
small testable method (`LandblockEntityCollisionRegistrar`) per Code-Structure-Rule 1.
|
||
- **Keep the dat-free clean-climb replay** (`Issue185OutdoorStairsSeamReplayTests`, already written
|
||
+ passing) as a regression pin (continuous stairs must climb).
|
||
- **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 (captured this session)
|
||
|
||
- `185-recapture3.jsonl` + task `.output` (`[cp-write]`, `[entity-source]`, `[bsp-test]` object map).
|
||
- Overflow analysis (23 collisions) reproducible from the `[entity-source]` log.
|
||
- gfxobj dumps `Fixtures/issue185/0x01000AC5.gfxobj.json` (+ `0x01000ACA`) for the replay.
|
||
|
||
## 7. Bookkeeping (in the fix commit)
|
||
|
||
- Register: this is a bug fix (not a new deviation); if a row implied the old per-part id scheme,
|
||
none does — no register row needed, but note the fix in the ISSUES close.
|
||
- `docs/ISSUES.md`: move #185 to Recently closed with the SHA.
|
||
- `claude-memory/project_physics_collision_digest.md`: banner — #185 was a REGISTRATION overflow
|
||
(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
|
||
|
||
- Local player walks up the full outdoor staircase, no jam/jump.
|
||
- No collision-id collisions in a landblock (the 23-collision analysis returns 0 after the fix).
|
||
- Other landblock statics (torches/trees/buildings) still collide; suites + build/test green.
|
||
|
||
## 9. DO-NOT-RETRY
|
||
|
||
- Do NOT touch `PrecipiceSlide` / `SetSlidingNormal` / step-down — the wedge is a faithful symptom
|
||
of missing geometry, not the cause.
|
||
- Do NOT "widen the key" (Option B) — it preserves the non-retail synthetic-per-part-id model.
|
||
- Do NOT reintroduce a synthetic per-part id (`*256` or `+K*0x10000000`) — the retail model is one
|
||
object id + a part array (`RegisterMultiPart`).
|