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

11 KiB
Raw Blame History

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 → EdgeSlideAfterStepDownFailedbranch3 (TransitionTypes.cs:1516) → PrecipiceSlideFindCrossedEdge 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_edgeslide_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: faithfulFindObjCollisionsInCell 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.