acdream/docs/research/2026-07-30-265-capture-bisect.md
Erik 96a62a191b docs(research): #265 capture bisect - S1 and S2 both cleared, real culprit is R6 grounded-animation-zero (S3, pre-Campaign-P)
Root-caused via segment mining + a real-trajectory replay harness
(previous commit). Mined two dramatic real "velocity annihilation +
permanent freeze" events from the live capture (a high-speed fall landing
on a moderate, walkable-by-threshold roof slope, then a full velocity
zero + frozen position for the rest of the capture - 12,292 ticks for the
worse of the two).

A/B verdict: S1 (db2889af, #116 shape-1's Path-6 hasSphere1 change) is
provably UNREACHED for the mined trajectory - hit1 never fires once across
the 80-tick replay, and diagnostic instrumentation shows the landing
actually goes through the still-unchanged sphere0 (foot) branch. Reverting
S1 locally produced byte-identical replay output, confirming this
mechanically rather than by inference. S2 (calc_friction's AP-7 threshold)
has zero production call sites (grep-confirmed) - it is dead code and
cannot affect any live behavior in either direction.

The real mechanism, hand-traced against both mined events exactly: the R6
"grounded movement is animation-root-motion-owned" architecture
(PlayerMovementController.cs:1868-1882, landed 2026-07-20 via f961d700,
ten days before Campaign P) unconditionally zeros horizontal Velocity
every tick once OnWalkable is true. With no held movement key at the
instant of landing, the mover never advances again - a frozen-phase design
predating Campaign P entirely, not a regression from S1/S2.

Recommended direction: do not revert S1 (a real, narrow, retail-faithful
fix unrelated to these two events); do not touch S2 until it's actually
wired into a live path; the real target is #166 (downhill sled) plus the
grounded-movement architecture, which needs a brainstorming pass before
any implementation, not a quick S1/S2 revert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:59:57 +02:00

24 KiB
Raw Blame History

#265 capture-driven bisection — steep-slope response family

Status: verdict reached, S1 and S2 both CLEARED for the two concrete mined events; real mechanism identified as a pre-existing (frozen-phase) architecture, not a Campaign P regression. This is a research/bisection pass; no production code was changed. The harness (committed, tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs) and mining tool (tools/analyze_265_steep_slope_capture.py) are permanent; the A/B code toggles described below were applied and reverted locally and never committed.

0. Scope recap

Issue #265 (docs/ISSUES.md): after the TS-4-removal-then-revert (2e27d066+a8a7d64b), the live matrix gate (2026-07-30, scenarios 4/5) found three symptoms: (a) jumping INTO an uphill slope bounces (retail does not), (b) house-roof slides no longer happen, (c) occasional stuck-sliding-on-an-edge. Two remaining Campaign-P suspects were named:

  • S1db2889af ("#116 shape-1"): BSPQuery.cs Path-6's hasSphere1 (head-sphere-only hit while airborne, foot sphere clear) branch changed from a steepness-gated dual path (steep → slide-tangent-then-Slid; shallow → SetCollide+Adjusted) to an unconditional SetCollisionNormal + return Collided.
  • S2 — the AP-7 calc_friction threshold rewrite (merge 26e0334a): 0.00.25, unconditional into-plane velocity subtraction past the threshold.

1. Segment mining

Captures used: artifacts/matrix-session2-resolve.jsonl (15,726 records, copied from the coordinator worktree's artifacts/matrix-session2-resolve.jsonl) and artifacts/matrix-session3-resolve.jsonl (12,145 records at copy time).

Session3 is not usable. Every one of its 12,145 records shows the identical position (60.372223, 9.071998, 79.344925), zero velocity, and transientState=3 (Contact|OnWalkable) from tick 0 to tick 12144 — the player was standing perfectly still (likely AFK / alt-tabbed) for the entire ~7.2-minute capture window. It contains no motion at all and was excluded from further analysis.

1.1 First pass — strict signature scan (tools/analyze_265_steep_slope_capture.py)

Two signatures were scanned for directly on the JSONL fields:

  • Signature A (uphill-jump bounce): an airborne record (bodyBefore Contact bit clear) with result.collisionNormalValid=true and a "steep, non-floor, non-wall" normal (0.02 < normal.Z < FloorZ=0.6642), followed by a next-tick upward jump in bodyBefore.velocity.z while still airborne.
  • Signature B (lost-slide / edge-wedge): ≥6 consecutive ticks with Contact set but OnWalkable clear (resting against a non-walkable steep surface), a non-trivial requested move each tick, and near-zero net advance.

Result: 0 hits for both signatures, in both files. Session2 has only 38 collisionNormalValid=true records total (out of 15,726), and every one of them has normal.Z in the [0.85, 1.0] bucket — i.e. every reported collision normal in this capture is CLOSE TO FLAT/floor-like, never in the "genuinely steep" < FloorZ band my first-pass signature targeted. This is an honest negative result for the specific "steep" heuristic; the actual symptom-bearing frames, mined below, are moderate-angle (Z≈0.860.95, above FloorZ — walkable BY THE THRESHOLD) and are found by a different signature.

1.2 Second pass — velocity-annihilation scan

Widened the signature to "a tick where |horizontal velocity| before is >2 m/s and after (next tick) is <0.05 m/s, then how long the position stays frozen afterward." This found exactly two events, both in session2:

| idx (0-based) | tick | |v_horiz| before | frozen for | cell | |---|---|---|---|---| | 3153 | 3153 | 8.90 m/s | 46+ ticks (session2 continues past it; not EOF) | 0xAAB30007 | | 3434 | 3434 | 18.00 m/s | 12,292 ticks — to EOF | 0xAAB40011 |

Event at idx 3433/3434 (the primary oracle for this pass), full trace (records[3415..3434+41], printed via ad-hoc Python — see tools/analyze_265_steep_slope_capture.py for the reusable scanner):

  • Ticks 34153432: clean ballistic fall. vBefore = (11.15, 14.13, vz) with vz accumulating from 16.72 to 23.14 (pure gravity, no further horizontal drive — a jump/leap with residual momentum, exactly the kind of trajectory the oracle plan's §1.3 predicted would NOT hit the TS-4 degenerate case). Z falls from 92.79 to 79.90.
  • Tick 3433 (the landing): result.collisionNormalValid=true, result.collisionNormal=(0.2857143, 0.42857143, 0.85714287) — exactly (2,3,6)/7, a REAL polygon normal (not the UnitZ degenerate default). result.isOnGround=true. bodyAfter.contactPlaneValid=true, bodyAfter.walkablePolygonValid=true, bodyAfter.walkableVertices = the triangle (240,0,88), (264,0,80), (264,24,68)normal.Z=0.857, well ABOVE PhysicsGlobals.FloorZ (0.6642): a legitimately walkable roof slope, not the "steep, non-walkable" case either S1 or the original TS-4 shortcut ever targeted. bodyAfter.velocity is UNCHANGED (11.15,14.13,23.14) — confirms (see PhysicsEngine.cs:1489, capture fires inside ResolveWithTransition, before any caller-side velocity response) that neither calc_friction nor HandleAllCollisions ran yet.
  • Tick 3434 (the very next resolve call): input.currentPos == input.targetPos (ZERO requested motion this tick — the previous frame's integration already produced zero displacement). bodyBefore.velocity = (0, 0, 0)already fully zeroed by the time THIS resolve call even starts. transientState=7 (Contact|OnWalkable|Sliding). Every subsequent record (12,292 of them, to the literal end of the file) is byte-identical: same position, same zero velocity, same transientState=7.

Event at idx 3152/3153 is the same shape at a shallower ~18° roof edge (normal≈(0,0.32,0.95)): the player glides/climbs cleanly along the edge for ~140 ticks (idx 30163152, gaining ~10 m of Z — this portion is healthy behavior), then at idx 3153 horizontal velocity is forced to exactly zero in one tick and the position freezes for the rest of the examined window.

Both events are the SAME shape: a real, correct, non-default collision normal is recorded on the landing tick; on the very next tick the mover's full horizontal velocity has already vanished and the position never changes again. This is what the user experiences as "roof slides no longer happen" (symptom b) and "occasional stuck-sliding-on-an-edge" (symptom c). Neither event's landing surface is steep by FloorZ — both are moderate, walkable-by-threshold roof pitches.

2. Replay harness

Issue265SteepSlopeCaptureBisectTests.cs builds a synthetic PhysicsEngine containing ONE polygon — the exact real triangle recovered from record 3433's bodyAfter.walkableVertices — registered via ShadowObjectRegistry, then replays the EXACT real captured ballistic state (position + velocity, record index 3415) forward with real gravity at 30 Hz, calling PhysicsEngine.ResolveWithTransition every tick exactly like PlayerMovementController does at the Core boundary. Once the mover reports IsOnGround, the harness keeps REQUESTING the same forward velocity every tick (simulating held input) — this is deliberate: it turns the harness from "replay what the live game did" (which trivially reproduces the freeze, since the live game's own subsequent inputs were already zero — see §4) into "does the physics engine itself allow continued advance across this surface," which is the actual question S1 and S2 bear on.

2.1 Harness commissioning (three real bugs found and fixed while building it — kept as code comments)

  1. ShadowObjectRegistry.Register's broad-phase culls by distance from worldPos. Registering at the literal real-world coordinates (X≈256) while querying at world origin put the polygon ~264 units away — the very first run found zero collisions at all. Fixed by re-anchoring the whole synthetic scene (triangle + approach trajectory) at the triangle's centroid.
  2. CellTransit.BuildShadowCellSet's outdoor flood (CellTransit.AddAllOutsideCells) treats world position as landblock-local (an anchor-frame convention shared with Ts4SteepRoofWedgeCaptureTests/DoorBugTrajectoryReplayTests, active whenever CellGraph.TryGetTerrainOrigin has no real terrain to consult). The real-world coordinates (X≈256) are outside the valid [0,192) per-landblock range even after centroid re-anchoring picked a bad cell id — still zero collisions.
  3. LandDefs.AdjustToOutside (inside the flood) silently re-derives the actual (lx,ly) grid cell from the sphere's real position and corrects a mismatched seed rather than honoring the literal seedCellId passed to Register — an arbitrary chosen cell id (0x00000011) registered successfully (TotalRegistered=1) but GetObjectsInCell(0x00000011) came back empty; the entity had actually landed in cell 0x00000001 (the canonical grid-(0,0) cell, matching the re-anchored centroid). Switching the harness's cell id to 0x00000001 fixed it.

These are documented in the test file's code comments in case another harness hits the same three traps.

3. A/B outcomes

(i) HEAD vs (ii) S1-reverted

The S1 revert (BSPQuery.cs's hasSphere1 branch restored to the pre-db2889af steepness-gated dual path, mirroring the still-current sphere0 branch — applied locally, verified git diff --stat clean before and after, never committed) produced byte-identical output to HEAD for the full 80-tick replay: same landing tick (19), same clean 44-tick glide (ticks 2062, adv=0.5149 every tick, cnv=false), same freeze at tick 63 (adv=0.0000 for 16+ consecutive ticks, capped by the harness's tick budget — it would continue indefinitely), same recorded collisionNormal=(-0.958,0.128,0.256) from that point on.

Why they're identical — confirmed by diagnostic instrumentation (PhysicsDiagnostics.ProbeIndoorBspEnabled/ProbeBuildingEnabled, the [path-dispatch]/[path5-diag] probes db2889af itself added): across the entire 80-tick replay, hit1=True never appears once. The two [path-dispatch] ... collide=True ... contact=False ... lines (Path 6 firing during the airborne approach) are followed by insertType=Placement (Phase 3's walkable-landing retry succeeding) — this is the STILL-UNCHANGED sphere0 (foot) branch's graceful SetCollideAdjusted→Phase-3-Placement chain, not the hasSphere1 branch S1 touched. Once grounded, every subsequent Path-5 dispatch reports hit0=False hitPoly0=False then hit1=False hitPoly1=False — a genuinely clean glide with no collision at all, which is why the S1 edit (which only fires inside if (hit1 || hitPoly1 is not null)) never executes for this trajectory. S1's site is provably unreached by the real mined trajectory that produced the freeze. Reverting code that never runs cannot change the outcome — this is not a coincidence, it's the direct mechanical explanation.

(iii) S2 toggle

Not run as a harness A/B — proven inert by static analysis instead. grep -rn "\.calc_friction(" src/ returns zero production call sites — the only callers of PhysicsBody.calc_friction in the entire repository are its own unit tests (tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs). PlayerMovementController.cs mentions it only in a code comment (line ~2021, "friction next frame") — it is never invoked. Neither ResolveWithTransition nor PlayerMovementController's tick loop calls calc_friction anywhere. S2's threshold value (0.0 vs 0.25) cannot affect any live or replayed behavior, full stop — there is no toggle to run because there is no live code path to toggle.

(iv) Both reverted

Follows immediately from (ii) and (iii): with S1 reverted producing byte-identical output to HEAD, and S2 provably inert, the "both" variant is mathematically identical to (ii), which is identical to (i). No separate run was needed.

A/B summary table

Variant Landing tick Clean glide (ticks 20-62) Freeze at tick 63+ Notes
(i) HEAD 19 yes, adv=0.5149/tick yes, frozen forever hit1 never true
(ii) S1 reverted 19 (identical) yes (identical) yes (identical) S1's branch unreached
(iii) S2 toggle n/a n/a n/a dead code, no call sites
(iv) both 19 (identical) yes (identical) yes (identical) follows from (ii)+(iii)

4. The actual mechanism (found by hand-tracing the live capture against PlayerMovementController.cs, independently confirming it explains BOTH mined freeze events exactly)

Neither S1 nor S2 touch velocity. The full-zero-in-one-tick signature (§1.2) is produced by two pre-existing, Campaign-P-independent pieces working in sequence:

  1. The landing tick (PlayerMovementController.cs, the if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f) block): Contact+OnWalkable are set, and — because Velocity.Z < 0 — ONLY the Z component is hand-zeroed: velocity becomes (11.15, 14.13, 0). PhysicsObjUpdate.HandleAllCollisions then runs with shouldReflect = true (the mover was airborne the frame before: prevOnWalkable=false makes shouldReflect unconditionally true regardless of the new grounded state — see PhysicsObjUpdate.cs:163-164). But dot(velocity, collisionNormal) = dot((11.15,14.13,0), (0.286,0.429,0.857)) ≈ +9.25 — POSITIVE (moving away from, not into, the surface, because the Z component that would have made it negative was just zeroed) — so the if (dot < 0f) reflection guard (PhysicsObjUpdate.cs:177) never fires. Velocity survives this tick as (11.15, 14.13, 0).
  2. The very next tick (PlayerMovementController.cs:1868-1882, added 2026-07-20 by f961d700, "port retail complete object frame pipeline" — R6, well before Campaign P):
    if (_body.OnWalkable)
    {
        float savedWorldVz = _body.Velocity.Z;
        if (hasAnimationRootMotion)
        {
            _body.Velocity = new Vector3(0f, 0f, savedWorldVz);
        }
        ...
    }
    
    OnWalkable is now true (set last tick), so this runs UNCONDITIONALLY, EVERY tick, for as long as the mover stays grounded: it zeros Velocity.X/Y to exactly zero (savedWorldVz is already 0 from step 1), replacing physics-integrated horizontal velocity with animation-root-motion-driven displacement (pmDelta.Origin, populated from _advanceAnimationRootMotion, which only produces nonzero displacement when a movement key is actually held). With no key held at the instant of landing, pmDelta.Origin stays Vector3.Zero forever, and the mover never advances again. This reproduces bodyBefore.velocity = (0,0,0) at record 3434 exactly, and the permanent freeze that follows.

This is the R6 "local player animation-owned grounded movement" architecture: once grounded, walking is driven entirely by held-input + animation root motion, not by integrating Velocity. It has been in place since 2026-07-20 — ten days before Campaign P and the TS-4 removal/revert (2026-07-29/30) — and is explicitly a frozen-phase architecture per the milestones doc (R6 shipped; the freeze list bars rework without a dedicated brainstorm). It is retail-DIVERGENT in one specific way that matters here: retail does not need a held key to carry residual momentum across a landing — a fast fall onto a walkable-but- sloped surface should glide/sled per docs/ISSUES.md #166 ("Slope-landing glide + bounce absent... acdream lands clean and dead"), which is filed, open, and explicitly OUT OF SCOPE for this pass (the Sledding PhysicsStateFlags bit that would let calc_friction's Sledding-gated overrides engage is never set anywhere in the codebase — a separate, already-tracked gap).

git log --oneline -3 -- src/AcDream.Core/Physics/PhysicsObjUpdate.cs confirms HandleAllCollisions itself was also last touched by an unrelated water fix (AP-10, cc8d57a2) — Campaign P did not modify it either.

5. Re-reading the oracle plan's S1 claim against the mined evidence

The task asked specifically: if S1's port is faithful but its SCOPE is wrong, say exactly that. Re-checked against docs/research/2026-07-30-ts4-116-oracle-plan.md §2.3-§2.4 and §3, plus this pass's own finding:

  • S1's port IS faithful in isolation. Its cited sources (acclient_2013_pseudo_c.txt:323824-323834, ACE BSPTree.cs:221-230) are an exact structural match — not a BN misdecompile, not a citation error. This was independently re-verified by reading the current BSPQuery.cs:2259-2302 against the same two sources again this pass; no discrepancy found.
  • S1's scope is narrower than any symptom this pass could reproduce — not wider. The oracle plan's own Addendum 2 (§"implementation session") already found this exact pattern once, for the door tick-22760 capture: the hypothesis assumed the "not-yet-in-Contact" branch would fire, but the mover was actually GROUNDED (Contact set), so dispatch went to Path 5 instead and S1's site was never reached. This pass finds the SAME pattern a second, independent time, for a DIFFERENT capture (a genuine airborne fall, not a grounded door-push): the foot sphere (sphere0) reaches the rising/sloped polygon at the same moment as or before the head sphere, so the if (hit0 || hitPoly0 is not null) branch above hasSphere1's check fires first and RETURNS before hasSphere1's block is ever entered (BSPQuery.cs:2188 gates the whole hasSphere1 block behind falling through that first if). hit1=True never appears once across the entire 80-tick replay, confirming this mechanically, not just by inference.
  • Two independent capture families (a grounded door-push, and now an airborne fall-and-land) both show S1's site going unreached. This strongly suggests S1's real-world reach is much narrower than its authors worried — for it to matter, a trajectory would need the FOOT sphere to stay clear while the HEAD sphere alone grazes a polygon during an airborne (not-yet-grounded) frame — e.g. jumping up under an overhang, or clipping a roof's underside while airborne with the feet still below the eave line. Neither of #265's two concrete mined freeze events is that geometry. S1 remains a real, citable, retail- faithful port-accuracy improvement and should NOT be reverted on this evidence (it fixes a genuine, if narrow, divergence for whenever its exact geometry does occur) — but it is not implicated in the symptoms #265 was filed against.

6. Named culprit

Neither S1 nor S2. This is S3 — but not a NEW regression: it is the pre-existing, frozen-phase R6 "grounded movement is animation-root-motion- owned" architecture (PlayerMovementController.cs:1868-1882, landed 2026-07-20 via f961d700, ten days before Campaign P), which unconditionally zeros the mover's horizontal Velocity every tick once OnWalkable is true, with no gate on approach speed, surface steepness, or how the mover became grounded. It was mechanically traced, tick by tick, against BOTH of #265's concrete mined freeze events and reproduces the observed (0,0,0) velocity and permanent position-freeze exactly.

This explains symptom (b) (roof slides don't continue — there is no "continue," walking requires a held key that landing doesn't supply) and symptom (c) (stuck at the landing spot indefinitely) completely, for both mined events. It does not, by itself, explain symptom (a) (the "bounce" on jumping into an uphill slope) — that is a property of PhysicsObjUpdate.HandleAllCollisions's elastic reflection (shouldReflect = true whenever the mover was NOT already on walkable ground before AND after the resolve — PhysicsObjUpdate.cs:163-164), which is ALSO pre-existing (from the #182 rebuild, well before Campaign P) and fires for ANY valid CollisionNormal reported while airborne, regardless of which BSPQuery branch produced it. This pass did not find or replay a concrete "bounce" event in the captures (the closest analogue — the tick-63 edge-freeze in the replay harness — shows a suspicious secondary normal, (-0.958,0.128,0.256), unrelated to the registered polygon's own plane normal, with Path-5 diagnostics showing no fresh BSP hit during the frozen ticks; this smells like stale ContactPlane/CollisionNormal persistence at a polygon boundary rather than a fresh reflection, and — like the S1 revert — was unaffected by reverting S1. It is flagged as a genuine open question, not resolved this pass, and may be an artifact of this harness's single small (24-unit) synthetic triangle rather than a general production bug; a real roof's continuous mesh would not present a "run off the edge of a 24-unit patch" boundary at all. See §7).

7. What's still open (do not guess, per CLAUDE.md)

  1. Why does the user perceive this as a NEW regression coinciding with Campaign P, if the freeze mechanism (§4) predates it by ten days and is unaffected by S1/S2? Two honest hypotheses, neither confirmed: (a) the roof-jump/fall scenario was specifically exercised for the FIRST time as part of the Campaign P visual matrix (scenarios 4/5), surfacing a pre-existing bug rather than a new one; (b) a genuinely separate, not-yet-isolated interaction exists. Resolving this needs either a live retail-vs-acdream side-by-side of the EXACT same fall-and-land-with-no-input scenario pre-Campaign-P (to confirm the freeze is not new), or a fresh capture of the user's ACTUAL "roof slide" repro (holding a movement key throughout, not a passive fall) to see whether the animation-root-motion path (which DOES produce displacement while a key is held) also fails.
  2. The tick-63 edge freeze in this pass's own harness (§6, closing parenthetical) — a CollisionNormal unrelated to the registered polygon's plane, reported while Path-5 diagnostics show no fresh hit. Candidate next step: extend the harness's synthetic roof to several contiguous polygons (removing the small-triangle-edge artifact) and re-run; if the freeze persists on a much larger interior region, it is a real, separate, third mechanism worth its own root-cause pass (possibly SpherePath.PrecipiceSlide's edge-crossing test, or stale LastKnownContactPlane persistence — NOT yet confirmed, do not guess further).
  3. Symptom (a)'s bounce was analyzed only by static code reading (HandleAllCollisions's reflection math), not independently reproduced against a live-captured bounce event — none of the 38 collisionNormalValid=true records in session2 showed the "airborne, then a large upward Velocity.Z jump next tick" signature this pass's Signature-A scanner looked for. A fresh capture specifically of a jump-into-an-upward-slope repro (ideally with ACDREAM_PROBE_RESOLVE=1 or ACDREAM_CAPTURE_RESOLVE active for the WHOLE approach, not just the moment of impact) would let Signature A actually fire and give a concrete oracle the way records 3433/3434 did for the freeze.

Do not touch S1 (BSPQuery.cs's hasSphere1 branch) — it is a real, narrow, retail-faithful improvement unrelated to #265's two concrete mined events; reverting it would only reopen the #116 shape-1 door-collision gap it was written to close, for zero benefit here.

Do not spend further effort on S2 (calc_friction's threshold) until it is actually wired into a live code path — right now changing it changes nothing observable, in either direction. If/when calc_friction IS wired into PlayerMovementController (a legitimate future piece of closing #166, the downhill-sled issue), the 0.25 threshold becomes live and worth re-testing at that point, not before.

The real target is #166 + the grounded-movement architecture (§4/§6), which is a frozen-phase design question, not a quick fix. Per CLAUDE.md's "the roadmap and the observed bug disagree → brainstorm before writing code" rule, this needs superpowers:brainstorming before any implementation: does acdream want a genuine physics-driven momentum carry across a landing (porting the retail Sledding state + a real calc_friction wiring), or a narrower "if IsOnGround at high incoming speed, force a minimum coast distance regardless of held input" patch? The former is retail-faithful and already has a filed target (#166); the latter would be a new, unfiled design decision. Either way, this is explicitly NOT an S1/S2 code change — it is new work against PlayerMovementController.cs's grounded-movement block and PhysicsBody.calc_friction's wiring, gated on a design conversation, not a revert.