acdream/docs/research/2026-07-30-ts4-116-oracle-plan.md
Erik db2889afda fix(physics): #116 shape-1 — Path-6 head-sphere direct Collided return
Per docs/research/2026-07-30-ts4-116-oracle-plan.md §2.3-§2.4: retail's
airborne (not-yet-Contact) BSPTREE::find_collisions dispatch, when the
FOOT sphere is completely clear but the HEAD sphere hits or near-misses,
does not defer through SetCollide/Adjusted (nor the steep-poly
slide-tangent shortcut) — it records the head polygon's normal directly
and hard-stops: pc:323824-323834 (0x0053a793/0x0053a7a4), independently
cross-checked against ACE BSPTree.cs:221-230 (`SetCollisionNormal` +
`return TransitionState.Collided;`), an exact structural match confirming
this isn't a BN misdecompile. BSPQuery.cs's Path 6 `hasSphere1` branch now
does the same: `collisions.SetCollisionNormal(worldNormal1); return
TransitionState.Collided;`, replacing the old steep-shortcut-or-deferred-
SetCollide handling. This mechanically retires one of TS-4's two
`SetSlidingNormal` write sites (sphere1's) ahead of TS-4's own item.

Added two permanent diagnostics gated on the existing
PhysicsDiagnostics.ProbeIndoorBspEnabled flag (`[path-dispatch]` at
FindCollisionsCore entry, `[path5-diag]` inside Path 5) to make future
BSPQuery dispatch tracing cheaper.

HONEST RESULT of the plan's own confirming instrumentation (re-run of
DoorBugTrajectoryReplayTests.Diagnostic_Tick22760_DumpEngineInternals):
this fix does NOT change the tick-22760 outcome (harness still cn=(0,0,1)
vs live cn=(0,+1,0)). The new dispatch-entry probes show the tick-22760
mover is GROUNDED (Contact set), so it never reaches Path 6 at all — it
dispatches Path 5 -> StepSphereDown (Path 3, both DoStepDown half-steps
fail) -> EdgeSlideAfterStepDownFailed -> SpherePath.PrecipiceSlide, whose
find_crossed_edge-false fallback returns Collided with NO collision-normal
write. A fresh byte-level read of retail's SPHEREPATH::precipice_slide
(pc:274316-274326, 0x0050cc80) confirms this is byte-exact retail
behavior (`if (eax == 0) { walkable = 0; return 2; }`, no
set_collision_normal call) — not a bug. The real tick-22760 divergence is
further upstream, most likely this test's simplified door registration
(BuildEngineWithDoorFixture) not placing the door's BSP where live retail
actually intersected it, or a walkable-polygon state-capture gap — see
the research doc's Addendum 2 for the full trace and open candidates.

#116 shape-1 is therefore NARROWED, not closed: the Path-6 fix is a real,
independent retail-faithfulness improvement; the tick-22760 acceptance
criterion is not met by it and needs further harness/geometry work before
any further code change.

Full AcDream.Core.Tests suite: 4059 passed / 2 skipped, no regressions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:36:26 +02:00

42 KiB

TS-4 / #116 oracle pass — Campaign P final physics slice

Status: RESEARCH ONLY. No source changes. This is a follow-up oracle pass on top of docs/research/2026-07-30-response-layer-edge-family-pseudocode.md (hereafter "the P2 doc"), specifically its §4 (TS-4), §5 (#116), and §7 item 6 (the P2 implementation attempt's wedge diagnosis). That attempt correctly localized the freeze to TransitionalInsert's Phase 2 retry loop but concluded the mechanism was "Phase 3 structurally unreachable" without tracing far enough to find the actual convergence/divergence point. This pass reads one layer deeper — into BSPQuery.cs's Path 4 dispatch (the path.Collide gate) and AdjustOffset's crease-projection math — and finds a concrete, retail-decomp-cited mechanism for both TS-4 and (as a byproduct of reading the same dispatch structure) strong new evidence for #116 shapes 1 and 2.

Every claim is tagged FACT (read directly from the named-retail pseudo-C, ACE source, or current acdream source in this worktree, with file:line / address citations) or INFERENCE (derived from those FACTs by direct reasoning, not yet confirmed by a live capture/cdb run).


0. Binding DO-NOT-RETRY entries (copied verbatim)

From memory/project_physics_collision_digest.md (3-day-old snapshot, re-verified against current source where cited below) and docs/ISSUES.md #116:

  1. Do NOT add SetSlidingNormal calls in the BSP/sphere collision layer. Retail's only in-transition writer of collision_info.sliding_normal is validate_transition (0x0050ac21/0x0050aa70). A leaked normal + success writeback = an absorbing wedge at empty space. This pass's TS-4 finding is a variant of exactly this failure class — see §1 below — but the writer in question (validate_transition's unconditional SetSlidingNormal(CollisionNormal)) IS the retail-faithful one; the problem is not an extra writer, it's what AdjustOffset does with a placeholder UnitZ value when it reads SlidingNormal back.
  2. Do NOT re-add a forced constant-shell de-penetration. Retail slides tangentially and never force-separates.
  3. SphereCollision no longer calls SetSlidingNormal (TS-45 retired) — keep it that way.
  4. Do NOT patch the degenerate-offset guard in slide_sphere ad hoc for #116 — oracle-driven only.
  5. Do NOT re-introduce a topology-based outside-add / radial sweep to cell membership while touching this family.
  6. calc_friction threshold is retail 0.25 vs acdream 0.0` (AP-7) — orthogonal to this slice, do not fold in.
  7. Shape-1 of #116 is NOT the degenerate-offset guard threshold — that guard kills slides under ~1.4 cm; the lost tick-22760 slide was 3.57 cm. The divergence is the collision-normal SOURCE.
  8. Do NOT guess the BN test ah,5 x87 branch polarity/squaring in slide_sphere — Ghidra MCP is down for this pass too; this pass does not touch that question (see §3, shape-2 — the finding here is about dispatch routing, not the x87 comparisons inside slide_sphere/AdjustOffset themselves, which remain unconfirmed and out of scope).
  9. AP-4 (CliffSlide check moved before retail's Branch-1 gate) — a live, load-bearing reordering. Not touched by this pass.
  10. TS-46 (two-scalar sphere reconstruction) is OUT OF SCOPE.

1. TS-4 — the actual convergence/divergence mechanism

1.1 Summary answer (read this first)

Retail does not "avoid" the Adjusted↔retry oscillation inside transitional_insert's attempt loop any differently than acdream does — both structurally deadlock the same way within a single resolve. What lets retail's live trace escape (and what the P2 fixture's synthetic trajectory does not) is that retail's AdjustOffset (CTransition::adjust_offset, 0x0050a370) re-projects the next tick's gravity offset through whatever ContactPlane + SlidingNormal survived the previous tick's collision — and for a pure, zero-horizontal-velocity vertical fall onto a steep surface, that projection is mathematically degenerate and crushes the offset to (near-)zero every tick, which abort-small-offsets before TransitionalInsert even runs again. This is retail-faithful behavior, present identically in the raw decomp, in ACE's port, and in acdream's current port — it is not a bug introduced by the TS-4 shortcut's removal. The Ts4SteepRoofWedgeCaptureTests fixture reproduces it because it drops the body straight down with zero horizontal velocity, which is very likely a different (and more degenerate) input than the live 2026-04-30 debugger trace that validated the shortcut (a player jumping or running onto a roof, which has residual horizontal velocity).

1.2 The chain, FACT by FACT

Step A — Path 6 fires, sets Collide, does not reposition (FACT). BSPQuery.cs:2217-2224 (faithful branch, shortcut removed):

path.SetCollide(worldNormal0);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
return TransitionState.Adjusted;

SpherePath.SetCollide (TransitionTypes.cs:752-759) only sets Collide=true, backs up CheckPos, and stores StepUpNormal — it does not touch CollisionInfo.ContactPlane or CollisionNormal. Matches retail exactly: pseudo-C:323818-323821 (0x0053a7bf, SPHEREPATH::set_collide(&sphere_path, &normal); walkable_allowance = 0.0871556997f; return 3;) — no set_collision_normal, no set_contact_plane call at this site either.

Step B — the SAME attempt's retry does NOT re-hit Path 6; it routes to Path 4 (FACT, both acdream and retail). BSPQuery.cs:1961 gates on if (path.Collide) — checked before the Path 5/6 tests, at the top of the same dispatch function. Since Collide was just set in Step A and is never cleared except inside TransitionalInsert's Phase 3 (sp.Collide = false at TransitionTypes.cs:1816, reachable only on an OK result — never reached while Path 6/Path 4 keep returning Adjusted), every subsequent attempt (within the same resolve and across ticks) dispatches to Path 4, not back to Path 6. Retail: raw pseudo-C:323784 if (eax->sphere_path.collide == 0) {...} else {...} — the identical gate, at the identical position in the dispatch (confirmed independently against ACE BSPTree.cs:163-187, if (path.Collide) { RootNode.find_walkable(...); if (changed) {... return Adjusted;} else return OK; }).

Step C — Path 4 (FindWalkableInternal) is what actually establishes ContactPlaneValid (FACT). BSPQuery.cs:1968-2018: calls FindWalkableInternal; if it finds a candidate (changed && hitPoly is not null), it repositions the sphere (path.AddOffsetToCheckPos(worldOffset)), sets a real ContactPlane via collisions.SetContactPlane(worldPlane, ...) (line 2006), caches the walkable polygon (SetWalkableTransformed), and returns Adjusted. This is the only site that gives the mover a real (steep) contact plane in this whole trajectory — not the Phase-3 DoCheckWalkable gate the P2 doc's item-6 diagnosis assumed was the relevant site (that gate is downstream and, per Step B, unreachable here). Matches ACE BSPTree.cs:163-184 exactly (SetContactPlane, SetWalkable, return Adjusted).

Step D — the attempt-exhausted Adjusted gets collapsed to OK with position reverted, but ContactPlaneValid survives the revert (FACT, both engines). TransitionalInsert's outer for-loop exhausts (acdream hardcodes return TransitionState.Slid; at TransitionTypes.cs:2093; ACE/retail return the true last value, Adjusted here — see §1.4 for why this particular divergence doesn't change the outcome). Either way, ValidateTransition's "not OK" branch runs (TransitionTypes.cs:5493-5501): if (!CollisionNormalValid) SetCollisionNormal(UnitZ); (fires — Path 4/6 never touched CollisionNormal, only ContactPlane/StepUpNormal), then SetCheckPos(CurPos, CurCellId) (revert — no net movement), transitionState = OK. Retail: pseudo-C:272563-272596 (0x0050aad9), identical collapse (COLLIDED_TS/ADJUSTED_TS/SLID_TS all treated the same, default CollisionNormal=UnitZ if unset, revert check_pos to curr_pos). Crucially, none of this touches ContactPlaneValid — it carries forward from Step C untouched by the revert. Then the shared tail (TransitionTypes.cs:5504-5533, retail pc:272621-272656) runs: if (CollisionNormalValid) SetSlidingNormal(CollisionNormal) — now SlidingNormal = UnitZ (the placeholder from the default, not a real second surface) — and if (ContactPlaneValid) { ...; oi.State|=Contact; if (Normal.Z>=FloorZ) OnWalkable=true else false; } — since the steep polygon's Normal.Z (≈0.447 for the fixture's 63.4° slope) < FloorZ (≈0.664), OnWalkable stays false but Contact becomes true. This exactly reproduces the fixture's own captured state at the landing tick: InContact=true, OnWalkable=false.

Step E — the NEXT tick's AdjustOffset crushes a purely-vertical offset to zero (FACT for the math, INFERENCE that this is the actual observed freeze cause — not independently re-run this pass). TransitionTypes.cs:4936-5014 (acdream), Transition.cs:34-87 (ACE), pseudo-C:272271-272393 (0x0050a370, retail) are all structurally identical:

slidingAngle = Dot(offset, SlidingNormal)
if (SlidingNormalValid) { if (slidingAngle < 0) checkSlide = true; else SlidingNormalValid = false; }
...
if (checkSlide) {
    slideOffset = Cross(ContactPlane.Normal, SlidingNormal)
    normalize slideOffset (or zero out if degenerate)
    result = Dot(slideOffset, offset) * slideOffset
}

With offset = (0, 0, -dz) (pure gravity, zero horizontal component), SlidingNormal = UnitZ = (0,0,1): slidingAngle = -dz < 0checkSlide = true. slideOffset = Cross(ContactPlane.Normal, UnitZ) — for any non-vertical plane normal N=(Nx,Ny,Nz), this cross product is (Ny, -Nx, 0) — a horizontal vector (Z=0), lying in the slope's contour line (perpendicular to the downhill direction), not the degenerate/near-zero case (the 63.4° slope's normal is not parallel to UnitZ, so NormalizeCheckSmall does not fire). Dot(slideOffset, offset) = Dot((Ny,-Nx,0), (0,0,-dz)) = 0 exactly, because slideOffset.Z = 0 and offset is purely Z. result = 0 * slideOffset = Vector3.Zero. The projected GlobalOffset is zero (up to float noise), which trips the "abort-small-offset" guard (TransitionTypes.cs:1466-1478, retail's non-viewer |offset|² < F_EPSILON² gate at pseudo-C:272845/0x0050bdf0, cited already in the existing AdjustOffset port comment) before TransitionalInsert is even called again — so ValidateTransition never runs on subsequent ticks either, meaning the stale ContactPlaneValid/SlidingNormal=UnitZ state simply perpetuates unchanged, forever. This is the freeze.

Step F — why the existing frames_stationary_fall (fsf) escape valve can't rescue this case (INFERENCE, follows directly from Step E). The digest's #182 rebuild already ported retail's fsf ladder (TransitionTypes.cs:5625-5667, ACE Transition.cs:1029-1061, pseudo-C:272625-656) — after 3 consecutive non-advancing ticks it manufactures a flat UnitZ contact plane and forces OnWalkable=true, which is exactly the kind of "unstick" mechanism one would look for here. But that ladder lives inside ValidateTransition, which Step E's abort-small-offset guard prevents from ever running again once the crease projection first crushes the offset to zero. The rescue mechanism is downstream of a gate the degenerate input never lets execution reach — in both acdream and (per identical source) retail.

1.3 Why this reconciles the shortcut's own "retail did not wedge" comment (INFERENCE)

The shortcut's comment (BSPQuery.cs:2190-2199) says the interim fix was "Validated against retail debugger trace 2026-04-30: retail body did not wedge." A live player jumping or walking onto a roof virtually always carries some horizontal velocity component (WASD input, residual momentum). For a non-purely-vertical offset, Dot(slideOffset, offset) is generally non-zero (only a component exactly along the pure downhill/gravity line is annihilated by this specific cross product — any lateral drift survives), so AdjustOffset would produce a small but non-zero sideways offset each tick — enough to move the sphere off the exact same collision point, avoid the abort-small-offset short-circuit, let TransitionalInsert/ValidateTransition run again, and (via repeated Path-4 find_walkable re-probes and the fsf ladder) eventually resolve. The Ts4SteepRoofWedgeCaptureTests fixture's pos = (0.5, 0, 3.0) straight-down drop with fallVelocityZ as the only non-zero component is very likely a stricter, more degenerate input than the live 2026-04-30 repro ever exercised. This is not yet independently re-confirmed by re-running the fixture with a horizontal component (see §4 Step 1 below for the concrete next action), so it is flagged INFERENCE — but it is the only hypothesis consistent with every FACT gathered in §1.2, and it does not require inventing any new mechanism.

1.4 The acdream-only bug that does NOT explain the freeze, but is real and should still be fixed

TransitionTypes.cs:2091-2093:

// Exhausted retry attempts — return whatever the last iteration said.
// (Defaults to Slid in practice since that's the only case that retries.)
return TransitionState.Slid;

This is hardcoded, not "whatever the last iteration said" as the comment claims. ACE's equivalent (Transition.cs:933, return transitState;) and retail's (pseudo-C:273363, 0x0050b949, return edi;) both return the true last value — Adjusted in this scenario, not Slid. FACT: this is a real, citable divergence. FACT: it does not explain the freezeValidateTransition's "not OK" branch (§1.2 Step D) treats Collided/Adjusted/Slid identically (acdream TransitionTypes.cs:5493-5501, ACE Transition.cs:993-1017, retail pseudo-C:272563-272596 all gate on result > OK_TS && result <= SLID_TS as one combined range, with no per-value branching). Fixing the hardcoded return is a one-line, zero-risk correctness fix (worth doing — it's a real citable port-accuracy bug and prevents future confusion when tracing this loop) but it is not the TS-4 fix and should not be presented as one.

1.5 What TS-4's actual fix shape is, given this

The mechanism in §1.2 is not something BSPQuery.cs's Path 6 can fix by itself — the freeze happens one tick after Path 6/Path 4 run, inside AdjustOffset, and is a property of the (already retail-faithful) validate_transition + adjust_offset pipeline reacting to a specific degenerate trajectory. Concretely, TS-4's shortcut removal is very likely safe for the realistic case (nonzero horizontal velocity) and only exposes this specific zero-horizontal-velocity degenerate, which:

  • may be a genuine, narrow, retail-faithful edge case (a player falling perfectly plumb onto a slope with zero horizontal drift essentially never happens in live play — WASD input, camera-relative movement, and even tiny numerical noise almost always inject some horizontal component), in which case it is not a blocker for TS-4 at all and should be documented as an accepted (retail-matching) corner case rather than "fixed", or
  • may indicate the fixture itself should be revised to match the original live repro's actual trajectory shape (nonzero horizontal velocity) before it's trusted as TS-4's gating fixture.

See §4 for the concrete, low-cost verification step (re-run the fixture with a small horizontal velocity component) that would settle which of these is true without guessing.


2. #116 shape-1 — collision-normal recording divergence (new candidate, INFERENCE, needs one instrumentation run to confirm)

2.1 What the existing research already ruled out (FACT, restated)

Ghidra-confirmed (2026-06-12, digest lines 1268-1275): acdream's cn=UnitZ default on a blocked move is retail-faithful (validate_transition does the identical default). The real divergence is upstream — at tick-22760, acdream's collision_normal_valid was false where retail's was true (retail had recorded the door-face normal (0,+1,0)). The candidate site named in the P2 doc §5 was "the PathClipped/collide_with_pt arm... or a sibling Path-1-class function not yet read."

2.2 PathClipped is NOT the answer (checked this pass, negative result — FACT)

ObjectInfoState.PathClipped (TransitionTypes.cs:32, bit 0x8) is only set on a mover when MoverPhysicsState & PhysicsStateFlags.Missile != 0 (PhysicsEngine.cs:1160-1163), with an explicit citation to retail's own CPhysicsObj::get_object_info (0x00511CC0): "Missile contributes PathClipped only." A normal player push against a door is not a missile mover, so neither acdream nor retail would set PathClipped for this scenario — this rules out "PathClipped state differs between engines" as shape-1's cause. (The camera/viewer sweep does carry PathClipped via a different, explicit caller-supplied flag, but that's a different mover than the one in the tick-22760 door-push capture.)

2.3 The real candidate: acdream's Path-6 sphere1(head)-hit handling diverges from retail/ACE (FACT for the divergence, INFERENCE that it explains tick-22760)

Retail's BSPTREE::find_collisions, in the not-yet-in-Contact branch (state&1==0, i.e. airborne / first contact — pseudo-C:323784- 323836, 0x0053a4e3-0x0053a730+): when sphere0 (foot) does not hit but num_sphere > 1 and sphere1 (head) does hit, retail does not defer through SetCollide/Adjusted — it calls COLLISIONINFO::set_collision_normal directly with the head poly's transformed normal and returns COLLIDED_TS (2) immediately (pseudo-C:323824-323834, 0x0053a793/0x0053a7a4). Cross-checked independently against ACE BSPTree.cs:221-230:

else if (path.NumSphere > 1)
{
    if (RootNode.sphere_intersects_poly(localSphere_, movement, ref hitPoly, ref contactPoint) || hitPoly != null)
    {
        var collisionNormal = path.LocalSpacePos.LocalToGlobalVec(hitPoly.Plane.Normal);
        collisions.SetCollisionNormal(collisionNormal);
        return TransitionState.Collided;
    }
}

— an exact structural match to the raw decomp, confirming this is not a BN misdecompile.

acdream's corresponding code (BSPQuery.cs:2227-2264) does NOT do this. It applies the same SetCollide-and-defer (or steep→Slid) treatment to a sphere1 hit as it does to sphere0 — there is no branch that returns Collided with a direct SetCollisionNormal write for "foot clear, head hit" while airborne. This means: in acdream, an airborne mover whose HEAD sphere alone contacts a polygon (foot sphere clear) gets SetCollide + deferred Adjusted (no immediate CollisionNormal write) — exactly the same "the real normal gets lost until validate_transition's UnitZ default kicks in" symptom the digest already diagnosed for shape-1. A door push where the player's capsule brushes the door frame near chest/head height while the foot sphere tracks slightly differently (a very plausible geometry for "pushing a closed door face at a near-perpendicular angle," matching the tick-22760 description) is a strong candidate for exactly this code path.

Caveat, stated honestly: this is contingent on sphere0 (foot) not fully hitting while sphere1 (head) does — if the door's collision geometry is a full vertical plane, sphere0 would very likely hit too, and the code would never reach the sphere1 branch (BSPQuery.cs:2156's if (hit0 || hitPoly0 is not null) returns early). This has not been confirmed against the actual tick-22760 replay this pass — it is the single next concrete step (see §4).

2.4 Instrumentation to run (concrete, low-cost, no guessing required)

Extend DoorBugTrajectoryReplayTests.Diagnostic_Tick22760_DumpEngineInternals (tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs:162) to log, at the tick-22760 resolve, which of hit0/hitPoly0/hit1/ hitPoly1 were non-null/true inside BSPQuery.cs's Path-6 dispatch (a one-line Console.WriteLine gated behind the existing ProbeIndoorBspEnabled/ProbeBuildingEnabled diagnostics, or a new narrowly-scoped probe flag per the project's diagnostic-owner pattern).

Accept criterion: if hit0/hitPoly0 are both null/false and hit1/hitPoly1 fire, §2.3's hypothesis is confirmed — the fix is to port retail's direct sphere1-hit-without-sphere0-hit → Collided + SetCollisionNormal branch into BSPQuery.cs's Path 6 (mirroring the already-correct Path 5/Contact-branch treatment at BSPQuery.cs:2103-2140, which already handles the analogous grounded case correctly — this would be a narrow, well-precedented port, not a new design).

Reject criterion: if sphere0 hits too (hit0 or hitPoly0 truthy), this hypothesis is wrong for tick-22760 specifically, and the search should move to the other named-retail sibling not yet read this pass — BSPTREE::collide_with_pt's own internal structure for a non-PathClipped context is not reachable (its outer gate requires state&8), so the next candidate would be whatever governs CObjCell::find_obj_collisions's insertion order relative to find_env_collisions for a door's building channel (the BR-7/A6.P4 per-cell shadow architecture) — not yet examined this pass; would need a fresh read of that dispatch specifically for polygon ordering/precedence when multiple candidate polys are tested per cell.


3. #116 shape-2 — first-airborne-frame hard-stop vs in-frame slide (strong structural finding, INFERENCE, narrows but does not eliminate the need for a confirming run)

3.1 The dispatch structure resolves the ROUTING question without cdb (FACT, cross-referenced against 3 sources: raw BN pseudo-C, ACE, current acdream)

Both the raw retail decomp and ACE's BSPTree.cs (an independent, clean-language port — the "fastest oracle" the mission suggested) show the same two-tier gate, keyed on ObjectInfoState.Contact:

  • Already grounded (Contact set) + head-sphere hitslide_sphere called directly, in-line, same tick (ACE BSPTree.cs:192-202; retail pseudo-C region immediately following 0x0053a730's state&1 branch — the else arm at ~323838+, not fully re-quoted here but structurally mirrored by ACE's clean port). acdream's BSPQuery.cs Path 5 (:2103-2120) already matches this exactly — SlideSphere called directly for a grounded head-hit.
  • NOT yet grounded (Contact unset, i.e. airborne / first contact) + foot-sphere hit → the Path-6 default: SetCollide + WalkableAllowance=LandingZ + return Adjustedno repositioning, no slide_sphere call at all (ACE BSPTree.cs:210-219; retail pseudo-C:323815-323821). Only a sphere1(head)-hit-without- sphere0-hit gets an immediate response in this branch, and that response is Collided (§2.3), still not slide_sphere.

This means: for a genuine first-airborne-frame FOOT-sphere wall hit (the D4 fixture's actual shape — a mover falling into a tall wall), neither retail nor ACE's port calls slide_sphere on contact frame 1. The sphere is left exactly where it was (SetCollide does not reposition — confirmed in §1.2 Step A), Collide gets set, and the very next retry attempt (same tick, same TransitionalInsert loop, per §1.2 Step B) routes to Path 4 (find_walkable) instead. For a tall, vertical wall (D4's actual geometry — "TallWall" per the test name), find_walkable's nearby-walkable-surface search would very plausibly find no candidate (a sheer vertical face has no near-horizontal polygon to "land" on nearby) — changed=false — so Path 4 returns OK (ACE BSPTree.cs:185-186, else return TransitionState.OK;). TransitionalInsert's Phase 3 (if (sp.Collide), now finally reachable since objState==OK) then runs: ContactPlaneValid is false (Path 4's changed=false arm never sets it), so the else reset=true; branch fires (TransitionTypes.cs:1842-1843), RestoreCheckPos() reverts to the pre-hit position, and the retail-faithful gate at TransitionTypes.cs:1863-1898 (matching pseudo-C:273231-273239 exactly, already cited in-code) fires: since this is the first airborne contact, LastKnownContactPlaneValid is false, so SetCollisionNormal(sp.StepUpNormal) (the wall's real normal, captured back at the original Path-6 hit) runs and the function returns Collided — a hard stop, in place, with the correct wall normal recorded — not a slide.

3.2 What this means for D4

INFERENCE, well-supported but not independently re-run this pass: the D4 pin's original expectation (frame 1 hard-stops at Z=2.0, the slide begins frame 2 off the cached sliding normal) is structurally much closer to what retail's own dispatch produces for a true vertical-wall first-contact than the engine's current in-frame slide-to-Z=1.92 behavior. This narrows — but does not eliminate — the open question. What remains genuinely unconfirmed by static reading (and is exactly the class of question DO-NOT-RETRY item 8 warns against guessing):

  • Whether find_walkable's internal walkable-search radius/height actually returns "nothing found" for the specific D4 fixture geometry (a wall tall enough that no nearby floor exists within its search envelope) — this is a testable, non-cdb question: instrument or step through FindWalkableInternal for the D4 geometry and confirm changed=false.
  • The exact x87 comparison polarities inside slide_sphere and find_walkable themselves (unrelated to this pass's routing finding) remain unconfirmed per DO-NOT-RETRY item 8 — but those don't matter for D4 if slide_sphere is never reached on frame 1 in the first place.
  1. First (cheap, no cdb): run/instrument the existing BSPStepUpTests.D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames fixture (currently Skip-tagged citing #116) with a probe on which BSPQuery.cs path fires on frame 1 (Path 6 vs Path 4 vs a full-hit- the-second-attempt path) and whether FindWalkableInternal returns changed=true or false for that specific wall. Accept: if Path 6 fires (SetCollide+Adjusted, no reposition), Path 4 then fires with changed=false, and the final result is Collided with StepUpNormal as the recorded normal — this confirms §3.1/§3.2, and the fix is to flip the D4 pin back to hard-stop (retire the Skip, assert Z=2.0 frame 1) rather than changing the engine. Reject: if the trace shows something else (e.g. Path 4 actually finds a walkable candidate for this wall, or a different dispatch arm fires entirely) — then the routing hypothesis in §3.1 doesn't hold for this specific fixture geometry, and a live cdb trace becomes necessary after all.
  2. Only if step 1 disagrees with the FACT-cited dispatch structure: a live cdb trace of an actual airborne wall hit in retail, per the CLAUDE.md "Retail debugger toolchain" section. Concrete script outline (adapting the documented pattern):
    .logopen ts4-116-airborne-wallhit.log
    .sympath C:\Users\erikn\source\repos\acdream\refs
    .symopt+ 0x40
    .reload /f acclient.exe
    
    r $t0 = 0
    bp acclient!BSPTREE::find_collisions "r $t0 = @$t0 + 1; .if (@$t0 % 1 == 0) { .printf \"hit %d: state=%%d collide=%%d\\n\", @$t0 } gc"
    bp acclient!CSphere::slide_sphere "r $t1 = @$t1 + 1; .printf \"SLIDE_SPHERE HIT #%d\\n\", @$t1; .if (@$t1 >= 3) { qd } .else { gc }"
    bp acclient!BSPTREE::collide_with_pt "r $t2 = @$t2 + 1; .printf \"COLLIDE_WITH_PT HIT #%d\\n\", @$t2; gc"
    g
    
    User reproduces: jump toward a tall vertical wall so the FIRST wall contact happens while airborne (not already grounded). The key signal is whether slide_sphere fires on the same engine tick as the first find_collisions hit against that wall (in-frame slide, confirming the CURRENT engine behavior) or only on a later tick (confirming the hard-stop-then-slide-frame-2 pin). Auto-detaches via qd after 3 slide_sphere hits to bound game lag.

  1. [Lowest risk, do first] Fix the TransitionalInsert exhausted-loop hardcoded return (§1.4): change return TransitionState.Slid; to return the real last transitState value, matching ACE/retail. Blast radius: essentially zero — ValidateTransition treats Collided/Adjusted/Slid identically downstream (confirmed §1.2 Step D), so this is a pure code-correctness fix with no observable behavior change in any currently-passing test. Good precursor because it removes a misleading comment/return before anyone traces this loop again.

  2. [Cheap, decides whether TS-4 needs anything further] Re-run Ts4SteepRoofWedgeCaptureTests with a small horizontal velocity component (e.g. vx = 0.3 m/s alongside the existing straight-down fall), shortcut removed. Per §1.3's hypothesis, this should not wedge (the crease projection produces a non-zero tangential offset). Accept (doesn't wedge): TS-4's shortcut removal is safe for the realistic case; land it, retire the TS-4 register row, and either (a) accept the pure-vertical case as a documented, retail-faithful corner case (cite §1.2/§1.3 in the register row) or (b) if the team wants zero residual risk, also file a narrow follow-up for the zero-horizontal-velocity degenerate specifically (not a TS-4 blocker). Reject (still wedges even with horizontal velocity): §1.3's hypothesis is wrong or incomplete; do NOT land TS-4 yet — re-open with a fresh capture of the actual velocity vector at the wedge point and compare against what AdjustOffset computes step by step (a ACDREAM_DUMP_EDGE_SLIDE-style trace of AdjustOffset's intermediate slidingAngle/collisionAngle/slideOffset values, not yet instrumented, would be the concrete next apparatus).

  3. [Independent of 1-2] #116 shape-1 instrumentation (§2.4): add the one-line hit0/hitPoly0/hit1/hitPoly1 probe to Diagnostic_Tick22760_DumpEngineInternals and re-run. Blast radius: zero (diagnostic-only). If confirmed, the fix (porting retail's direct sphere1-hit → Collided+SetCollisionNormal branch into Path 6) is a narrow, well-precedented addition mirroring the already-correct Path 5 treatment — moderate blast radius (touches the shared Path-6 dispatch used by every airborne two-sphere mover), needs the existing SphereCollisionFamilyTests/Issue137* suites re-run plus a fresh tick-22760 comparison before landing.

  4. [Independent of 1-3] #116 shape-2 instrumentation (§3.3 step 1): add the BSPQuery-path + FindWalkableInternal changed probe to the D4 fixture. Blast radius: zero (diagnostic-only) for the instrumentation itself. If confirmed, flipping the D4 pin (un-skip, assert hard-stop frame 1) is a test-only change with zero production code change — the engine's current dispatch already produces this result per §3.1's reading; only the test's own expectation is currently wrong. This is the lowest-risk of all four items once confirmed, because it requires touching zero engine code.

Suggested order given the above: 1 → 4 → 3 → 2, since 4 (#116 shape-2) is the cheapest to fully resolve (test-only fix, zero engine change, per this pass's structural finding) and 2 (TS-4's own confirming run) benefits from having item 1's return-value fix landed first (removes a confusing false signal before re-tracing).


5. What genuinely still needs cdb or Ghidra (not resolved by this pass)

  1. #116 shape-2, only if §3.3 step 1's confirming run disagrees with the FACT-cited dispatch structure. The routing question itself (does frame 1 reach slide_sphere) is resolved by static reading against 3 independent sources in this pass; only a surprising, contradicting instrumentation result would re-open the need for a live trace. The cdb script outline is in §3.3 step 2.
  2. The x87 comparison polarities inside slide_sphere, find_walkable, and AdjustOffset's own internal branches (DO-NOT-RETRY item 8) — untouched by this pass, remain Ghidra/cdb- gated as before. This pass's findings are about which function gets called (dispatch routing), not the exact comparison operators inside those functions.
  3. AP-7's cos(10°) vs 0.99999536f discrepancy (P2 doc §1) — unrelated to this pass, still needs a Ghidra decompile of 0050ee70 when Ghidra MCP is back up.
  4. TS-1 gaps #2/#3's last_known_contact_plane maintenance and Path-4 LandingZ acceptance audit (P2 doc §2, §6 Step 2) — per the current source read in this pass, this already carries an in-code citation ("TS-1 gap #3 (register AD-54, Campaign P Slice P2 2026-07-30)") suggesting it was addressed in the same implementation session that produced the P2 doc's item-6 update; not independently re-verified this pass.

6. One-paragraph summary for the calling agent

TS-4: the Adjusted↔retry loop the P2 doc's implementation attempt found is real, but its root cause is one layer downstream of where that attempt looked. Path 6 sets Collide=true without moving the sphere; every subsequent attempt (same tick and later ticks, since Collide is never cleared outside Phase 3) routes to Path 4 (FindWalkableInternal), which is what actually establishes the steep ContactPlane (matching the fixture's observed InContact=true, OnWalkable=false). The freeze itself happens one tick later, inside AdjustOffset: validate_transition's retail-faithful CollisionNormal → UnitZ default feeds SetSlidingNormal, and AdjustOffset's crease-projection (Cross(ContactPlane.Normal, SlidingNormal)) is mathematically orthogonal to a purely vertical input offset — every subsequent tick's gravity-only offset gets crushed to zero and abort-small-offsets before the engine can run again. This exact mechanism is present identically in the raw retail decomp, ACE's port, and acdream's current port — it is very likely not a code bug but a narrow degenerate case that a live player's residual horizontal velocity (present in the original validating debugger trace) would not trigger. The concrete next step is cheap and decisive: re-run Ts4SteepRoofWedgeCaptureTests with a small horizontal velocity component before deciding whether TS-4's shortcut removal needs anything beyond the register-row writeup.

Addendum (P-review byte decode, 2026-07-30): AD-55 RESOLVED — retail's sled flatness test is cos(10°), ACE's constant is a radians/degrees bug

Raw bytes of CPhysicsObj::calc_friction @ 0x0050ee70 (PDB-paired binary, technique reference_pe_byte_decode), Sledding fast-sled branch at 0x0050ef52-0x0050ef6a:

d9 86 38 01 00 00   fld  dword [esi+0x138]    ; contact_plane.Normal.Z
dd 05 28 6b 7c 00   fld  qword [0x007c6b28]   ; = 0.17453292519943295 (10 deg in RADIANS)
d9 ff               fcos                       ; st0 = cos(10 deg) = 0.984807753
de d9               fcompp
df e0 / f6 c4 41 / 7a  fnstsw; test ah,0x41; jp

FACT: retail genuinely computes cos(10°) ≈ 0.9848078 at runtime and compares Normal.Z against it. ACE's 0.99999536f equals cos(0.1745 DEGREES) — the radian literal evaluated in degree mode; a proven ACE porting error, not a BN artifact. Sibling constants byte-confirmed: threshold float 0.25 @0x007c6b00, doubles 6.25/1.5625 @0x007c6b30/38, friction overrides 1.0f/0.2f as immediates.

Feel impact: retail's 0.2-friction fast-sled override engages on ground within 10° of flat; the shipped ACE-derived constant engages only within 0.17° (never, in practice) — part of the #166 sled family. FIX (queue for the TS-4/#116 implementation slice, which owns PhysicsBody): replace 0.99999536f with 0.98480775f (cos 10°), cite this addendum, retire register row AD-55 in the same commit.

Addendum 2 (implementation session, 2026-07-30): #116 shape-1's tick-22760

confirming run DISAGREES with this plan's hypothesis — the real mechanism

is one layer further upstream, and it's retail-faithful there too

Per this doc's own §4 execution order, item 1 (the TransitionalInsert exhausted-loop hardcoded return) landed first — mechanical, zero observable behavior change, confirmed by the full AcDream.Core.Tests suite (4059 passed / 2 skipped, no change in pass count). Then §2.3's shape-1 fix landed verbatim in BSPQuery.cs's Path 6 hasSphere1 branch: a foot-clear/head-hit airborne contact now returns TransitionState.Collided with a direct collisions.SetCollisionNormal write, exactly matching pc:323824-323834 (0x0053a793/0x0053a7a4) and ACE BSPTree.cs:221-230. This is a real, independently-decomp-confirmed port-accuracy fix and is kept regardless of the result below.

The confirming instrumentation run (§2.4) DISAGREES with the plan's tick-22760 hypothesis. Re-running DoorBugTrajectoryReplayTests.Diagnostic_Tick22760_DumpEngineInternals after the fix landed shows no change: harness still reports cn=(0,0,1) (the UnitZ ground-fallback default) against live's cn=(0,+1,0) (the door-face normal). Adding a dispatcher-entry probe ([path-dispatch], [path5-diag], gated on the existing ProbeIndoorBspEnabled flag, kept in BSPQuery.cs as permanent diagnostics) traced the ACTUAL call sequence for this capture:

  1. The seeded body's TransientState (131 = Contact | OnWalkable | Active) means ObjectInfo.State & Contact != 0 for this mover — it is grounded, so BSPQuery.FindCollisionsCore dispatches to Path 5 (the Contact/grounded branch), never Path 6 at all. The plan's shape-1 hypothesis was explicitly scoped to "the not-yet-in- Contact branch" (§2.3) — that scoping was itself the unconfirmed part, and it does not hold for tick-22760.
  2. Path 5's own dispatch for the door's BSP shape at this exact position finds neither sphere hitting nor near-missing (hit0=False hitPoly0=False hit1=False hitPoly1=False) — the simplified fixture registration this test uses (BuildEngineWithDoorFixture, which places the raw GfxObj BSP directly at its captured world-space bounding-sphere center rather than via the faithful ShadowShapeBuilder.FromSetup + PlacementFrame transform that BuildFaithfulDoorEngine uses elsewhere in the same file) returns OK for the door here.
  3. TransitionalInsert's step-down gate then fires (contactInvalidOrSteep is true because the per-substep walk loop clears ContactPlaneValid before every TransitionalInsert call — TransitionTypes.cs around the FindValidPosition per-step reset — so the door BSP is queried TWICE MORE via DoStepDown's two half- height attempts, dispatching to Path 3 (StepSphereDownFindWalkableInternal), which also finds no walkable candidate here (the door face is not a floor-like polygon) and returns OK both times.
  4. Both DoStepDown calls therefore fail (return false), which routs into EdgeSlideAfterStepDownFailed. With ContactPlaneValid false, OnWalkable true (seeded), EdgeSlide true (mover flags), and the RESTORED walkable polygon from the body's own snapshot (a flat triangle (144,0,94)-(144,24,94)-(120,24,94), Normal.Z=1 >= FloorZ), execution reaches sp.PrecipiceSlide(this) (TransitionTypes.cs "branch3/precipice-slide").
  5. SpherePath.PrecipiceSlide calls BSPQuery.FindCrossedEdge against that seeded triangle. The player's actual sweep (X≈133, Y from 18.02 to 17.60) does not cross ANY of that triangle's three edges (the triangle spans roughly X∈[120,144], and its hypotenuse sits at X+Y=144 — at X=133 that's Y≈11, far south of the player's Y range). FindCrossedEdge returns false, and acdream's PrecipiceSlide (TransitionTypes.cs:1039-1054) does exactly what retail's SPHEREPATH::precipice_slide does on the identical branch — read fresh this session, pc:274316-274326, 0x0050cc80:
    int32_t eax = CPolygon::find_crossed_edge(...);
    if (eax == 0) { this->walkable = eax; return 2; /* COLLIDED_TS */ }
    
    No set_collision_normal call on this path in retail either. This is a byte-exact match, not an inference — acdream's ClearWalkable(); return TransitionState.Collided; on a failed FindCrossedEdge is retail-faithful. ValidateTransition's UnitZ-default-on-invalid-normal fires identically in both engines for this exact mechanism.

Conclusion: the tick-22760 divergence is NOT explained by anything this plan identified, and the mechanism this pass traced down to (Path 5 → StepSphereDown → EdgeSlideAfterStepDownFailed → PrecipiceSlide's no-crossed-edge fallback) is independently confirmed retail-faithful at every step, including a fresh byte-level read of precipice_slide itself. The remaining candidates, none guessed at here: (a) this specific harness (BuildEngineWithDoorFixture) may simply not place the door's BSP polygons where live retail's did at that exact tick — a harness/fixture-geometry gap, not a response-layer code bug — worth re-running this same capture through BuildFaithfulDoorEngine's Setup-based registration to check whether a REAL BSP hit against the door (rather than the seeded generic floor triangle) changes the outcome; (b) the seeded WalkableVertices triangle itself may not match what retail's own walkable-polygon bookkeeping held at that instant (a state-capture gap in the original 2026-05-24 live-capture tooling, not necessarily an engine bug); (c) a genuinely different upstream mechanism not yet traced. Per CLAUDE.md's no-guessing rule, none of these is adopted without further evidence — #116 shape-1 stays narrowed, not closed: the Path-6 fix is a real, independent retail-faithfulness improvement, and the original tick-22760 acceptance criterion is NOT met by it. See ISSUES.md #116 for the updated status.