# 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` 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`).