acdream/docs/superpowers/specs/2026-07-08-185-outdoor-stairs-fix-design.md
Erik 06c3ecd89d docs(#185): design — Approach A (keep mover grounded on the forward move)
Root cause confirmed (decomp cross-check wf_3c1120c4-a04 + live apparatus): the
outdoor stairs are a continuous coplanar 38.7-degree ramp of stacked step-box
objects; at a seam the grounded forward move loses contact_plane_valid, the
step-down recovery can't reach the coplanar (at-level) continuation, and
EdgeSlide/PrecipiceSlide fabricates a horizontal (0,1,0) sliding normal that
absorbs the up-stairs motion (the #137/TS-4 family). Fabrication math, the
SetSlidingNormal Z-zero, and the multi-object search are all verified faithful;
the divergence is upstream (retail keeps contact_plane_valid, pc 273244).

Approach A: restore retail's grounded forward-move retention so the fragile
step-down recovery isn't needed at seams. Exact retention-loss line pinned by a
dat-backed replay test (the #137 method), not guessed. Alternatives B/C recorded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 08:37:24 +02:00

171 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)
---
## 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`.
## 2. Root cause — CONFIRMED (decomp cross-check + live apparatus)
### 2.1 The geometry (dumped this session)
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`.
### 2.2 The mechanism (live capture `185-recapture.jsonl` + `[step-walk]` / `[stepdown-decide]`)
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:
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.
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.3 The retail divergence (named-retail decomp, high confidence)
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.4 What is FAITHFUL — do NOT touch (adversarially verified)
- `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 — Approach A (keep the mover grounded on the forward move)
**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.
**Scope (narrow):** the grounded forward-move path in `Transition.TransitionalInsert` / the
contact-plane retention it feeds (`TransitionTypes.cs` ~10301110 Slid-clears + the neg-poly/
step-down dispatch ~1339, and the `ValidateTransition` LKCP retention ~46454735). 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.
**Explicitly NOT changed:** the fabrication code, `SetSlidingNormal`, `PrecipiceSlide`,
`FindCrossedEdge`, the multi-object search — all verified faithful (§2.4).
**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`.
**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).
## 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.
## 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).
## 6. Apparatus / fixtures (already 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).
## 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.
## 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.
## 9. DO-NOT-RETRY (carried from the handoff + this investigation)
- 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`.