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>
11 KiB
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:
- The grounded forward move floats the foot past the current tread's polygon (kept
"grounded" on the tread plane via the
LastKnownContactPlaneproximity restore) to Y=78.12, Z=61.65 — but the forwardTransitionalInsertreaches the step-down dispatch withci.ContactPlaneValid = false(the seed frombody.ContactPlaneis lost mid-sweep; pinning why is implementation step 1, §3). Note:ValidateTransition's LKCP proximity restore later re-validates it, sobodyAfter.contactPlaneValid = truein the capture even though the mover is wedged — the loss is transient-but-decisive, at the dispatch, not at frame end. - Because contact is invalid at the dispatch, the step-down branch fires.
DoStepDownfirst drops the sphere the fullstepDownHeight(Z 61.65 → 60.90) then sweepsfind_walkabledownward — 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). DoStepDownfails →EdgeSlideAfterStepDownFailed→ branch3 (TransitionTypes.cs:1516) →PrecipiceSlide→FindCrossedEdgereturnscross(tread_normal, top_edge)=(0,±0.78,±0.62)→SlideSpheresets it asCollisionNormal.ValidateTransition(:4635) →SetSlidingNormalzeroes Z →(0,±1,0).- Body persists it (
PhysicsEngine.cs:1136); next frame seeds it (:1020);AdjustOffsetabsorbs 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)
PrecipiceSlidemath (find_crossed_edge→slide_sphere, conditional sign-negate to oppose travel): faithful (retailprecipice_slidepc 274316/274354; conditional negate pc 274347).SetSlidingNormalzeroing Z: faithful — retailset_sliding_normal(0x0050a060, pc 272137) also setssliding_normal.z = 0fthen normalizes. Un-zeroing is a DEAD END and would itself be a divergence; and it is geometrically moot (bothcp.Nand 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 —
FindObjCollisionsInCelliterates the full per-cell shadow list; both stair objects are tested ([resolve-bldg]confirms hits on0xF68220/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 ~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.
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/StepSphereDownaway from retail's strictly-downward design; risks being a subtle divergence rather than a faithful fix. Rejected as primary. - C — gate
EdgeSlide/PrecipiceSlideto not fabricate when a live walkable continuation exists. Smallest blast radius, and retail'sedge_slidedoes gate onwalkable != 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 ofIssue137CorridorSeamReplayTests) 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 testgreen (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-stepentOrigin_lbe.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.