diff --git a/docs/superpowers/specs/2026-07-08-185-outdoor-stairs-fix-design.md b/docs/superpowers/specs/2026-07-08-185-outdoor-stairs-fix-design.md index d178912d..7cf733c4 100644 --- a/docs/superpowers/specs/2026-07-08-185-outdoor-stairs-fix-design.md +++ b/docs/superpowers/specs/2026-07-08-185-outdoor-stairs-fix-design.md @@ -1,171 +1,160 @@ # Design: #185 — local player jams half-way up outdoor stairs (fix) -**Date:** 2026-07-08 · **Status:** DESIGN (approved approach: A) — implementation not started -**Milestone:** M1.5 (indoor world feels right) · **Family:** #137 / TS-4 synthetic-sliding-normal at a walkable seam, stair edition -**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) +**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 -block part-way up: you shuffle sideways but cannot advance. **A jump clears it** and you -continue up normally. Pre-existing; unrelated to #184. Jam at world ≈ (132, 77.9, 61.5), -landblock `0xf682`, cell `0xF682002C`. +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 (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 -**step-box objects** (`entityId` `0x00F68220`, `0x00F68221`, `0x00F68222`, …; each holds -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`. +`src/AcDream.App/Rendering/GameWindow.cs:7951`, in the per-landblock-entity collision +registration loop: -### 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 -(`DoStepDown`, `TransitionTypes.cs:1339`). This *succeeds* at most positions (`accept=True` -climbing to Y=77.914). At one seam it fails, and the fabrication chain runs: +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*. -1. The grounded forward move floats the foot **past the current tread's polygon** (kept - "grounded" on the tread plane via the `LastKnownContactPlane` proximity restore) to - Y=78.12, Z=61.65 — but the forward `TransitionalInsert` reaches the **step-down dispatch - 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. +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. -Capture signatures (`185-recapture.jsonl`): pinned `result.position = (131.71, 77.914, 61.485)`; -`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.2 Evidence (capture #3, `ACDREAM_PROBE_CONTACT_PLANE` + `ACDREAM_PROBE_BUILDING`) -### 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 -`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.3 Why the wedge is a faithful symptom (do NOT fix it) -### 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 - 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 — Option A: register multi-part landblock entities via `RegisterMultiPart` -## 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 -`contact_plane_valid` on the forward move** (retail pc 273244), so it does not depend on the -step-down recovery that fails at a coplanar seam. +**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: -**Scope (narrow):** the grounded forward-move path in `Transition.TransitionalInsert` / the -contact-plane retention it feeds (`TransitionTypes.cs` ~1030–1110 Slid-clears + the neg-poly/ -step-down dispatch ~1339, and the `ValidateTransition` LKCP retention ~4645–4735). The change: -when the grounded foot sphere grazes the **coplanar walkable continuation** at an object seam, -retain / re-establish the contact plane from the tread it is resting on, rather than clearing it -(a spurious Slid) and dropping into the step-down recovery. +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). -**Explicitly NOT changed:** the fabrication code, `SetSlidingNormal`, `PrecipiceSlide`, -`FindCrossedEdge`, the multi-object search — all verified faithful (§2.4). +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. -**Pinning the exact line — the #137 method, not a guess:** implementation step 1 is a **dat-backed -replay test** reproducing this exact seam crossing (fixtures already captured — §6). The replay -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`. +**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`. -**Scope guard:** if pinning reveals the faithful fix is larger than a localized retention change, -STOP and return to the user before expanding scope (frozen-internals rule). +**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 — make step-down find the coplanar/at-level continuation.** Bends `DoStepDown`/`StepSphereDown` - away from retail's strictly-downward design; risks being a subtle divergence rather than a faithful - fix. Rejected as primary. -- **C — gate `EdgeSlide`/`PrecipiceSlide` to not fabricate when a live walkable continuation exists.** - Smallest blast radius, and retail's `edge_slide` does gate on `walkable != 0`, but it treats the - symptom site and masks the upstream grounding loss (weaker against no-workarounds). Kept as the - fallback if A proves too invasive. +- **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 pin:** a new dat-backed replay (`Issue185OutdoorStairsSeamReplayTests`, pattern of - `Issue137CorridorSeamReplayTests`) driving the captured trajectory through the failing seam: - pre-fix reproduces the wedge (no advance past Y≈77.9, `slidingNormal=(0,1,0)`); post-fix the - mover climbs past the seam with no fabricated sliding normal. -- **Regression net (the whole point — shared grounded-move path):** - - `Issue137*` suites (`Issue137CorridorSeamReplayTests`, `Issue137SlidingNormalLifecycleTests`, - `Issue137CorridorSeamInspectionTests`) green / kept un-skipped. - - Live spot checks (user visual gate): indoor cellar/corridor stairs, other outdoor buildings, - ramps, and flat ground — **no new phantom blocks AND no players sliding through walkable - edges/ramps.** - - `dotnet build` + `dotnet test` green (Core / App / UI / Net). +- **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 (already captured this session) +## 6. Apparatus / fixtures (captured this session) -- Structured resolve capture: `…/scratchpad/185-recapture.jsonl` (jam pinned at - `(131.71, 77.914, 61.485)`; `slidingNormal (0,1,0)`; `collisionNormal (0,0.78,0.62)`). -- gfxobj geometry: `…/scratchpad/gfxdump/0x01000AC5.gfxobj.json` (+ `0x01000ACA`) — the step-box - 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). +- `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: amend **TS-4** (or add a new row) to record the grounding-retention change; retire/narrow - as the faithful port dictates. -- `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 fix SHA. -- Roadmap/milestones: note the M1.5 #137-family stair fix. +- 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 -- The LOCAL player **walks up the full outdoor staircase with no jam and no jump**; no sideways-slide - pin at the tread seams. -- Regression pass (§5): no new phantom blocks AND no players sliding through walkable ramps/edges; - `Issue137*` green; build + test green. -- Register bookkeeping done in the fix commit. +- 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 (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 "fix the geometry" — the dat is real, coplanar, walkable treads. -- Do NOT un-zero Z in `SetSlidingNormal` — faithful to retail and geometrically moot (§2.4). -- Do NOT ship a per-frame sliding-normal clear or a small-offset-abort patch — fix the PROVENANCE - (the grounding loss), not the symptom (#137 lesson). -- Do NOT read the Tee'd launch log for `[resolve-bldg]` — it's buffered; read the raw task `.output`. +- 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`).