merge: #265 capture bisect - S1/S2 exonerated; real mechanism is the grounded velocity zeroing (f961d700, pre-campaign)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
457f65d991
3 changed files with 1037 additions and 0 deletions
437
docs/research/2026-07-30-265-capture-bisect.md
Normal file
437
docs/research/2026-07-30-265-capture-bisect.md
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
# #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:
|
||||||
|
|
||||||
|
- **S1** — `db2889af` ("#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.0` → `0.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.86–0.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 3415–3432: 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 3016–3152, 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 20–62, `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
|
||||||
|
`SetCollide`→`Adjusted`→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):
|
||||||
|
```csharp
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 8. Recommended fix direction
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
@ -0,0 +1,366 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #265 capture-driven bisection: "Steep-slope response set" (uphill-jump
|
||||||
|
/// bounce, lost roof slide, edge wedge). This harness replays a REAL trajectory
|
||||||
|
/// mined from a live capture (<c>ACDREAM_CAPTURE_RESOLVE</c>,
|
||||||
|
/// <c>artifacts/matrix-session2-resolve.jsonl</c>, records 3415-3434) through a
|
||||||
|
/// synthetic single-polygon <see cref="PhysicsEngine"/> built from the EXACT
|
||||||
|
/// polygon the live capture landed on
|
||||||
|
/// (<c>bodyAfter.walkableVertices</c>: (240,0,88),(264,0,80),(264,24,68),
|
||||||
|
/// normal (2,3,6)/7 = (0.2857,0.4286,0.8571) — a moderate, WALKABLE-BY-THRESHOLD
|
||||||
|
/// roof slope, <c>normal.Z=0.857 > PhysicsGlobals.FloorZ (0.6642)</c>).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Live symptom this reproduces (mining evidence):</b> the player falls
|
||||||
|
/// (v ≈ (11.15, 14.13, -23.14) m/s at landing) onto this roof slope. Live capture
|
||||||
|
/// record 3433 shows <c>collisionNormalValid=true</c>, the correct real polygon
|
||||||
|
/// normal, and <c>walkablePolygonValid=true</c> — a legitimate walkable landing.
|
||||||
|
/// Record 3434 (the very next tick) shows the body FROZEN: velocity forced to
|
||||||
|
/// exactly (0,0,0), transientState=7 (Contact|OnWalkable|Sliding), and the
|
||||||
|
/// position stays byte-identical for the remaining 12,292 captured ticks (to
|
||||||
|
/// the end of the file) — i.e. the player never moves again. A second,
|
||||||
|
/// independent instance of the same shape appears at records 3153-3199+ (a
|
||||||
|
/// shallower ~18° roof edge, frozen for 46+ captured ticks). Full mining
|
||||||
|
/// evidence: <c>docs/research/2026-07-30-265-capture-bisect.md</c>.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Candidate mechanism (S1):</b> commit <c>db2889af</c> ("#116 shape-1")
|
||||||
|
/// changed <c>BSPQuery.cs</c>'s Path-6 <c>hasSphere1</c> (head-sphere-only hit
|
||||||
|
/// while airborne, foot sphere clear) branch from a steepness-gated
|
||||||
|
/// SetCollide→Adjusted (shallow, Z≥FloorZ) / slide-tangent-then-Slid (steep,
|
||||||
|
/// Z<FloorZ) dual path — IDENTICAL in shape to the still-unchanged sphere0
|
||||||
|
/// (foot) branch a few lines above it — to an UNCONDITIONAL
|
||||||
|
/// <c>SetCollisionNormal + return Collided</c>, regardless of steepness. A
|
||||||
|
/// `Collided` return short-circuits <c>TransitionalInsert</c> immediately
|
||||||
|
/// (<c>if (transitState == TransitionState.Collided) return
|
||||||
|
/// TransitionState.Collided;</c>) — it never reaches the retry loop's Phase 3
|
||||||
|
/// (<c>if (sp.Collide) ...DoCheckWalkable...Placement retry...</c>), which is
|
||||||
|
/// the ONLY place a shallow/walkable head-sphere hit can smoothly commit to a
|
||||||
|
/// real <c>ContactPlane</c> + <c>OnWalkable</c> via the SetCollide+Adjusted
|
||||||
|
/// path. This test's real captured polygon has <c>normal.Z=0.857</c> — well
|
||||||
|
/// ABOVE <c>FloorZ</c> (0.6642) — so it is the SHALLOW case, not the steep one;
|
||||||
|
/// S1 removed the steepness branch entirely, so this shallow graze now takes
|
||||||
|
/// the SAME hard-stop path a steep hit would.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Method:</b> <see cref="ReplayRealRoofLanding"/> integrates the EXACT
|
||||||
|
/// captured ballistic state (position + velocity) from record 3415 forward
|
||||||
|
/// with real gravity (dt=1/30s, matching retail's tick rate), calling
|
||||||
|
/// <see cref="PhysicsEngine.ResolveWithTransition"/> every tick exactly like
|
||||||
|
/// <see cref="AcDream.Runtime.Gameplay.PlayerMovementController"/> does at the
|
||||||
|
/// Core boundary (this harness intentionally stops at that boundary — it does
|
||||||
|
/// NOT call <c>PhysicsObjUpdate.HandleAllCollisions</c> or model the R6
|
||||||
|
/// animation-root-motion grounded-movement zeroing, both of which live outside
|
||||||
|
/// Core and are confirmed NOT part of the S1/S2 candidate set — see the research
|
||||||
|
/// doc). Once the mover reports <c>IsOnGround</c>, the harness keeps REQUESTING
|
||||||
|
/// forward motion each tick (simulating held input) so a genuine "does the
|
||||||
|
/// engine allow continued advance across this surface" signal is observable,
|
||||||
|
/// rather than trivially replaying the live capture's own (already-frozen,
|
||||||
|
/// no-input) subsequent targets.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>A/B protocol (see the research doc for the executed results):</b> this
|
||||||
|
/// same test is run unmodified against (i) HEAD, (ii) <c>BSPQuery.cs</c> with
|
||||||
|
/// the S1 sphere1 branch reverted to mirror the still-current sphere0 shape
|
||||||
|
/// (local, uncommitted diagnostic edit), (iii) production unaffected by S2
|
||||||
|
/// (calc_friction's AP-7 threshold) since S2 has zero call sites outside its
|
||||||
|
/// own unit test — confirmed by <c>grep -rn "\.calc_friction(" src/</c> — so no
|
||||||
|
/// S2 toggle is needed for THIS harness, and (iv) both. The per-tick dump
|
||||||
|
/// (<see cref="TickSample"/> list, printed via <see cref="ITestOutputHelper"/>)
|
||||||
|
/// is the diff target.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class Issue265SteepSlopeCaptureBisectTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public Issue265SteepSlopeCaptureBisectTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
// ── Real captured polygon (bodyAfter.walkableVertices, record 3433) ──────
|
||||||
|
// artifacts/matrix-session2-resolve.jsonl, tick 3433, cell 0xAAB40011.
|
||||||
|
// normal = cross(v1-v0, v2-v0) normalized = (2,3,6)/7 exactly.
|
||||||
|
private static readonly Vector3 RoofV0 = new(240f, 0f, 88f);
|
||||||
|
private static readonly Vector3 RoofV1 = new(264f, 0f, 80f);
|
||||||
|
private static readonly Vector3 RoofV2 = new(264f, 24f, 68f);
|
||||||
|
|
||||||
|
// Outdoor cell suffix MUST be < 0x0100 (retail's indoor/outdoor LandCell
|
||||||
|
// convention — CellTransit.BuildShadowCellSet branches on it) and its
|
||||||
|
// block index must be (0,0) so that the flood's landblock-local grid math
|
||||||
|
// (CellTransit.AddAllOutsideCells, an 8x8 24-m-cell grid over the 192-m
|
||||||
|
// landblock) treats this harness's coordinates as directly landblock-local
|
||||||
|
// — CellGraph.TryGetTerrainOrigin has no registered terrain for this
|
||||||
|
// synthetic landblock so it Zero-falls-back, meaning raw "world" position
|
||||||
|
// IS landblock-local position (documented anchor-frame convention, same
|
||||||
|
// one Ts4SteepRoofWedgeCaptureTests/DoorBugTrajectoryReplayTests rely on).
|
||||||
|
// The harness's very first run registered the shadow object at the REAL
|
||||||
|
// captured world coordinates (X≈256) under this convention — 256 is
|
||||||
|
// outside the valid [0,192) landblock-local range, so the flood produced
|
||||||
|
// an empty cell set and the object was silently never registered at all
|
||||||
|
// (zero collisions the whole replay). Fix: re-anchor the entire synthetic
|
||||||
|
// scene (triangle + approach trajectory) at the roof centroid so every
|
||||||
|
// coordinate here is small and landblock-local (see the research doc's
|
||||||
|
// harness-commissioning note).
|
||||||
|
// Suffix 0x0001 is the canonical (gridX=0, gridY=0) outdoor LandCell — the
|
||||||
|
// grid cell whose local origin is (0,0) — matching this harness's
|
||||||
|
// re-anchored roof centroid at world (0,0,0) (see the note above). An
|
||||||
|
// earlier attempt used suffix 0x0011; CellTransit.AddAllOutsideCells'
|
||||||
|
// LandDefs.AdjustToOutside re-derives the (lx,ly) grid cell from the
|
||||||
|
// sphere's ACTUAL position and silently corrects a mismatched seed, so the
|
||||||
|
// registration landed in cell 0x00000001 regardless of the literal seed
|
||||||
|
// passed — GetObjectsInCell(0x00000011) found nothing (see the research
|
||||||
|
// doc's harness-commissioning note).
|
||||||
|
private const uint CellId = 0x00000001u;
|
||||||
|
private const uint LandblockId = 0x00000000u;
|
||||||
|
private const uint SyntheticGfxId = 0x265BEEF1u;
|
||||||
|
|
||||||
|
private const int TicksPerSecond = 30;
|
||||||
|
private const float Gravity = -9.8f;
|
||||||
|
private const float SphereRadius = 0.48f; // production human Setup 0x02000001
|
||||||
|
private const float SphereHeight = 1.835f; // production human Setup 0x02000001
|
||||||
|
|
||||||
|
// Real captured state, record index 3415 (session2, tick 3415) — 18 ticks
|
||||||
|
// before the landing/freeze at record 3433/3434. vx/vy are constant across
|
||||||
|
// this whole approach (pure ballistic fall, no further horizontal drive).
|
||||||
|
// Re-anchored: subtract RoofCentroid from the real captured world position
|
||||||
|
// (see the landblock-local note above) — the RELATIVE approach geometry
|
||||||
|
// (distance, direction, velocity) is preserved exactly.
|
||||||
|
private static readonly Vector3 ApproachStartPosReal = new(244.59f, -0.81f, 92.79f);
|
||||||
|
private static readonly Vector3 ApproachStartVel = new(11.1509495f, 14.129979f, -16.72f);
|
||||||
|
|
||||||
|
// ShadowObjects.Register's broad-phase culls candidates by distance from
|
||||||
|
// `worldPos` within `radius` — registering at Vector3.Zero with the real
|
||||||
|
// (far-from-origin) captured world coordinates put the polygon ~264 units
|
||||||
|
// from the query point, well outside any sane radius, so the very first
|
||||||
|
// run of this harness found ZERO collisions at all (see the research doc's
|
||||||
|
// "harness commissioning" note). Fix: register the entity at the
|
||||||
|
// triangle's centroid and express the polygon in LOCAL coordinates
|
||||||
|
// relative to that centroid (identity rotation, scale 1 — world = local +
|
||||||
|
// worldPos reconstructs the exact real-world triangle).
|
||||||
|
private static readonly Vector3 RoofCentroid = (RoofV0 + RoofV1 + RoofV2) / 3f;
|
||||||
|
|
||||||
|
private static PhysicsEngine MakeRoofEngine()
|
||||||
|
{
|
||||||
|
var resolved = new Dictionary<ushort, ResolvedPolygon>();
|
||||||
|
var verts = new[] { RoofV0 - RoofCentroid, RoofV1 - RoofCentroid, RoofV2 - RoofCentroid };
|
||||||
|
var normal = Vector3.Normalize(Vector3.Cross(verts[1] - verts[0], verts[2] - verts[0]));
|
||||||
|
float d = -Vector3.Dot(normal, verts[0]);
|
||||||
|
resolved[1] = new ResolvedPolygon
|
||||||
|
{
|
||||||
|
Vertices = verts,
|
||||||
|
Plane = new Plane(normal, d),
|
||||||
|
NumPoints = 3,
|
||||||
|
SidesType = CullMode.None,
|
||||||
|
};
|
||||||
|
|
||||||
|
var leaf = new PhysicsBSPNode
|
||||||
|
{
|
||||||
|
Type = BSPNodeType.Leaf,
|
||||||
|
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 30f },
|
||||||
|
};
|
||||||
|
leaf.Polygons.Add(1);
|
||||||
|
|
||||||
|
var heights = new byte[81];
|
||||||
|
var heightTab = new float[256];
|
||||||
|
for (int i = 0; i < 256; i++) heightTab[i] = -1000f; // terrain never interferes
|
||||||
|
|
||||||
|
var engine = new PhysicsEngine();
|
||||||
|
engine.AddLandblock(
|
||||||
|
LandblockId,
|
||||||
|
new TerrainSurface(heights, heightTab),
|
||||||
|
System.Array.Empty<CellSurface>(),
|
||||||
|
System.Array.Empty<PortalPlane>(),
|
||||||
|
worldOffsetX: 0f, worldOffsetY: 0f);
|
||||||
|
|
||||||
|
var cache = new PhysicsDataCache();
|
||||||
|
var bspTree = new PhysicsBSPTree { Root = leaf };
|
||||||
|
var physics = new GfxObjPhysics
|
||||||
|
{
|
||||||
|
BSP = bspTree,
|
||||||
|
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
|
||||||
|
Vertices = new VertexArray(),
|
||||||
|
Resolved = resolved,
|
||||||
|
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 30f },
|
||||||
|
};
|
||||||
|
cache.RegisterGfxObjForTest(SyntheticGfxId, physics);
|
||||||
|
engine.DataCache = cache;
|
||||||
|
|
||||||
|
// ShadowObjectRegistry is the per-cell shadow-object index (BR-7/A6.P4):
|
||||||
|
// Register() FLOODS from a SEED CELL outward and registers the entity
|
||||||
|
// into the resulting cell set; the query side (GetObjectsInCell) looks
|
||||||
|
// up strictly by the mover's CURRENT cell id. Leaving seedCellId at its
|
||||||
|
// default (0u) makes Register() call DeriveOutdoorSeed(worldPos, ...),
|
||||||
|
// which computes its OWN outdoor landcell id from world position — for
|
||||||
|
// the real captured coordinates used here (worldPos.X=256, well outside
|
||||||
|
// landblock 0xAAB40000's own 192 m span) that derives to a DIFFERENT
|
||||||
|
// cell than the literal CellId this harness resolves against, so the
|
||||||
|
// very first run of this fixture found zero collisions (see the
|
||||||
|
// research doc's harness-commissioning note). Passing seedCellId
|
||||||
|
// explicitly bypasses the derivation and floods from the exact cell
|
||||||
|
// the replay loop queries.
|
||||||
|
engine.ShadowObjects.Register(
|
||||||
|
entityId: SyntheticGfxId,
|
||||||
|
gfxObjId: SyntheticGfxId,
|
||||||
|
worldPos: Vector3.Zero,
|
||||||
|
rotation: Quaternion.Identity,
|
||||||
|
radius: 30f,
|
||||||
|
worldOffsetX: 0f,
|
||||||
|
worldOffsetY: 0f,
|
||||||
|
landblockId: LandblockId,
|
||||||
|
collisionType: ShadowCollisionType.BSP,
|
||||||
|
scale: 1.0f,
|
||||||
|
seedCellId: CellId);
|
||||||
|
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record TickSample(
|
||||||
|
int Tick,
|
||||||
|
Vector3 Pos,
|
||||||
|
float Advance,
|
||||||
|
bool CollisionNormalValid,
|
||||||
|
Vector3 CollisionNormal,
|
||||||
|
bool OnGround,
|
||||||
|
int FrozenStreak);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replays the real captured ballistic approach + landing, then keeps
|
||||||
|
/// REQUESTING forward motion (simulating held input) for
|
||||||
|
/// <paramref name="postLandingTicks"/> additional ticks once grounded, to
|
||||||
|
/// see whether the engine allows continued advance across the roof surface
|
||||||
|
/// or wedges in place. Returns one <see cref="TickSample"/> per tick.
|
||||||
|
/// </summary>
|
||||||
|
public static List<TickSample> ReplayRealRoofLanding(int postLandingTicks = 60)
|
||||||
|
{
|
||||||
|
var engine = MakeRoofEngine();
|
||||||
|
const float dt = 1f / TicksPerSecond;
|
||||||
|
|
||||||
|
var body = new PhysicsBody { TransientState = TransientStateFlags.Active };
|
||||||
|
|
||||||
|
Vector3 pos = ApproachStartPosReal - RoofCentroid;
|
||||||
|
Vector3 vel = ApproachStartVel;
|
||||||
|
uint cell = CellId;
|
||||||
|
bool grounded = false;
|
||||||
|
int frozenStreak = 0;
|
||||||
|
int ticksSinceGrounded = -1;
|
||||||
|
|
||||||
|
var samples = new List<TickSample>();
|
||||||
|
|
||||||
|
// Budget: enough ticks to cover the ~18-tick ballistic approach plus the
|
||||||
|
// requested post-landing continuation window.
|
||||||
|
int maxTicks = 18 + postLandingTicks + 20;
|
||||||
|
|
||||||
|
for (int tick = 0; tick < maxTicks; tick++)
|
||||||
|
{
|
||||||
|
if (!grounded)
|
||||||
|
vel = new Vector3(vel.X, vel.Y, vel.Z + Gravity * dt);
|
||||||
|
// Once grounded, keep requesting the SAME horizontal advance each
|
||||||
|
// tick (simulating held forward input) — this is the "does a
|
||||||
|
// slide continue" probe. Vertical requested delta is zero (resting
|
||||||
|
// against the surface, not still falling).
|
||||||
|
Vector3 requestedVel = grounded ? new Vector3(vel.X, vel.Y, 0f) : vel;
|
||||||
|
Vector3 target = pos + requestedVel * dt;
|
||||||
|
|
||||||
|
var result = engine.ResolveWithTransition(
|
||||||
|
currentPos: pos,
|
||||||
|
targetPos: target,
|
||||||
|
cellId: cell,
|
||||||
|
sphereRadius: SphereRadius,
|
||||||
|
sphereHeight: SphereHeight,
|
||||||
|
stepUpHeight: 0.6f,
|
||||||
|
stepDownHeight: 1.5f,
|
||||||
|
isOnGround: grounded,
|
||||||
|
body: body,
|
||||||
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||||
|
movingEntityId: 0x01000000u);
|
||||||
|
|
||||||
|
float advance = Vector3.Distance(result.Position, pos);
|
||||||
|
if (advance < 0.001f)
|
||||||
|
frozenStreak++;
|
||||||
|
else
|
||||||
|
frozenStreak = 0;
|
||||||
|
|
||||||
|
samples.Add(new TickSample(
|
||||||
|
tick, result.Position, advance,
|
||||||
|
result.CollisionNormalValid, result.CollisionNormal,
|
||||||
|
result.IsOnGround, frozenStreak));
|
||||||
|
|
||||||
|
pos = result.Position;
|
||||||
|
cell = result.CellId;
|
||||||
|
body.Position = pos;
|
||||||
|
|
||||||
|
if (!grounded && result.IsOnGround)
|
||||||
|
{
|
||||||
|
grounded = true;
|
||||||
|
ticksSinceGrounded = 0;
|
||||||
|
}
|
||||||
|
else if (grounded)
|
||||||
|
{
|
||||||
|
ticksSinceGrounded++;
|
||||||
|
if (ticksSinceGrounded >= postLandingTicks)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Characterization test: dumps the full per-tick trajectory so the A/B
|
||||||
|
/// bisect (this file's class doc) can diff HEAD vs the S1-reverted local
|
||||||
|
/// edit. Always passes — this is a diagnostic capture, matching the
|
||||||
|
/// project's existing <c>LiveCompare_FirstCap_DiagnosticDump</c>-style
|
||||||
|
/// tests. The actual pass/fail verdict is recorded in
|
||||||
|
/// <c>docs/research/2026-07-30-265-capture-bisect.md</c>, not as a
|
||||||
|
/// hardcoded assertion here, because the correct fix shape (and therefore
|
||||||
|
/// the correct future regression assertion) is still being decided.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void RealCapturedRoofLanding_CharacterizeCurrentBehavior()
|
||||||
|
{
|
||||||
|
PhysicsDiagnostics.ResetForTest();
|
||||||
|
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
|
||||||
|
PhysicsDiagnostics.ProbeBuildingEnabled = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var samples = ReplayRealRoofLanding();
|
||||||
|
|
||||||
|
int maxFrozen = 0;
|
||||||
|
int landedAtTick = -1;
|
||||||
|
foreach (var s in samples)
|
||||||
|
{
|
||||||
|
maxFrozen = System.Math.Max(maxFrozen, s.FrozenStreak);
|
||||||
|
if (landedAtTick < 0 && s.OnGround) landedAtTick = s.Tick;
|
||||||
|
_out.WriteLine(string.Format(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
"t{0,3}: pos=({1:F3},{2:F3},{3:F3}) adv={4:F4} cnv={5} n=({6:F3},{7:F3},{8:F3}) onGround={9} frozen={10}",
|
||||||
|
s.Tick, s.Pos.X, s.Pos.Y, s.Pos.Z, s.Advance,
|
||||||
|
s.CollisionNormalValid, s.CollisionNormal.X, s.CollisionNormal.Y, s.CollisionNormal.Z,
|
||||||
|
s.OnGround, s.FrozenStreak));
|
||||||
|
}
|
||||||
|
|
||||||
|
_out.WriteLine($"=== landedAtTick={landedAtTick} maxFrozenStreak={maxFrozen} totalTicks={samples.Count} ===");
|
||||||
|
|
||||||
|
// Sanity-only assertion: the replay must actually reach the roof
|
||||||
|
// (land) within the ballistic approach window — if this fails the
|
||||||
|
// synthetic fixture itself is wrong, not a physics-engine finding.
|
||||||
|
Assert.True(landedAtTick is >= 0 and < 30,
|
||||||
|
$"Replay never reached the synthetic roof polygon (landedAtTick={landedAtTick}); " +
|
||||||
|
"fixture geometry or approach trajectory needs adjustment before this is a valid oracle.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
PhysicsDiagnostics.ResetForTest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
234
tools/analyze_265_steep_slope_capture.py
Normal file
234
tools/analyze_265_steep_slope_capture.py
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Issue #265 segment miner: steep-slope response family (uphill-jump bounce,
|
||||||
|
lost roof slide, edge wedge) from ACDREAM_CAPTURE_RESOLVE JSONL captures.
|
||||||
|
|
||||||
|
Companion to tools/analyze_resolve_capture.py (the #182 OK/partial/stuck
|
||||||
|
classifier). This script targets the #265 symptom set specifically:
|
||||||
|
|
||||||
|
(a) uphill-jump bounce — jumping INTO an upward slope reflects velocity
|
||||||
|
upward instead of sliding (retail does not bounce here).
|
||||||
|
(b) lost roof slide — a body resting on a steep (non-walkable) roof
|
||||||
|
surface stops advancing instead of gliding/sliding off.
|
||||||
|
(c) edge wedge — the body oscillates near-motionless at a
|
||||||
|
collision point for many consecutive ticks.
|
||||||
|
|
||||||
|
Each JSONL record (PhysicsResolveCapture.ResolveCaptureRecord) has:
|
||||||
|
input.{currentPos,targetPos,cellId,...}
|
||||||
|
bodyBefore/bodyAfter (PhysicsBodySnapshot incl. velocity, transientState —
|
||||||
|
bit 0 = Contact, bit 1 = OnWalkable)
|
||||||
|
result.{position,cellId,isOnGround,collisionNormalValid,collisionNormal}
|
||||||
|
|
||||||
|
IMPORTANT ordering fact (verified against source, 2026-07-30): capture happens
|
||||||
|
INSIDE PhysicsEngine.ResolveWithTransition (PhysicsEngine.cs:1489), so
|
||||||
|
bodyAfter reflects state at the end of the resolve call — BEFORE
|
||||||
|
PhysicsObjUpdate.HandleAllCollisions runs (that happens later in
|
||||||
|
PlayerMovementController, using resolveResult.CollisionNormal directly). So a
|
||||||
|
velocity REFLECTION from HandleAllCollisions shows up as a jump in the NEXT
|
||||||
|
record's bodyBefore.velocity, not in the current record's bodyAfter.velocity.
|
||||||
|
This script's bounce heuristic accounts for that one-tick lag.
|
||||||
|
|
||||||
|
Usage: py tools/analyze_265_steep_slope_capture.py capture1.jsonl [capture2.jsonl ...]
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
|
||||||
|
FLOOR_Z = 0.6642 # PhysicsGlobals.FloorZ — walkable/steep boundary
|
||||||
|
CONTACT_BIT = 0x1 # TransientStateFlags.Contact
|
||||||
|
WALKABLE_BIT = 0x2 # TransientStateFlags.OnWalkable
|
||||||
|
|
||||||
|
STEEP_NORMAL_MIN = 0.02 # exclude near-vertical walls (normal.z ~ 0)
|
||||||
|
STEEP_NORMAL_MAX = FLOOR_Z # exclude walkable/floor-like surfaces
|
||||||
|
|
||||||
|
BOUNCE_VZ_JUMP = 0.5 # m/s — next-tick upward Z-velocity jump considered a "bounce"
|
||||||
|
STALL_EPS = 0.01 # m — position barely advanced
|
||||||
|
STALL_MIN_RUN = 6 # consecutive stalled ticks to call it a "lost slide" / wedge
|
||||||
|
|
||||||
|
|
||||||
|
def vlen(v):
|
||||||
|
return math.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2)
|
||||||
|
|
||||||
|
|
||||||
|
def vsub(a, b):
|
||||||
|
return {"x": a["x"] - b["x"], "y": a["y"] - b["y"], "z": a["z"] - b["z"]}
|
||||||
|
|
||||||
|
|
||||||
|
def dist(a, b):
|
||||||
|
return vlen(vsub(a, b))
|
||||||
|
|
||||||
|
|
||||||
|
def load(path):
|
||||||
|
records = []
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
records.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def is_airborne(bb):
|
||||||
|
return (bb.get("transientState", 0) & CONTACT_BIT) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def is_contact_not_walkable(bb):
|
||||||
|
ts = bb.get("transientState", 0)
|
||||||
|
return (ts & CONTACT_BIT) != 0 and (ts & WALKABLE_BIT) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def is_steep_normal(n):
|
||||||
|
z = n["z"]
|
||||||
|
return STEEP_NORMAL_MIN < z < STEEP_NORMAL_MAX
|
||||||
|
|
||||||
|
|
||||||
|
def find_uphill_bounce_candidates(records, path_label):
|
||||||
|
"""Signature A: airborne mover hits a steep (non-floor, non-wall) normal,
|
||||||
|
then the NEXT record's bodyBefore.velocity.z jumps up noticeably while
|
||||||
|
still airborne — the fingerprint of an elastic reflection off a slope
|
||||||
|
(PhysicsObjUpdate.HandleAllCollisions, gated by CollisionNormalValid and
|
||||||
|
shouldReflect=true-while-airborne)."""
|
||||||
|
hits = []
|
||||||
|
for i in range(len(records) - 1):
|
||||||
|
rec = records[i]
|
||||||
|
bb = rec.get("bodyBefore") or {}
|
||||||
|
res = rec.get("result") or {}
|
||||||
|
if not is_airborne(bb):
|
||||||
|
continue
|
||||||
|
if not res.get("collisionNormalValid"):
|
||||||
|
continue
|
||||||
|
n = res.get("collisionNormal") or {"x": 0, "y": 0, "z": 0}
|
||||||
|
if not is_steep_normal(n):
|
||||||
|
continue
|
||||||
|
|
||||||
|
nxt = records[i + 1]
|
||||||
|
nbb = nxt.get("bodyBefore") or {}
|
||||||
|
vz_now = bb.get("velocity", {}).get("z", 0.0)
|
||||||
|
vz_next = nbb.get("velocity", {}).get("z", 0.0)
|
||||||
|
still_airborne_next = is_airborne(nbb)
|
||||||
|
|
||||||
|
if still_airborne_next and (vz_next - vz_now) > BOUNCE_VZ_JUMP:
|
||||||
|
hits.append({
|
||||||
|
"file": path_label,
|
||||||
|
"tick": rec.get("tick"),
|
||||||
|
"index": i,
|
||||||
|
"normal": n,
|
||||||
|
"vz_before": vz_now,
|
||||||
|
"vz_after_next_tick": vz_next,
|
||||||
|
"pos": rec.get("input", {}).get("currentPos"),
|
||||||
|
"cellId": rec.get("input", {}).get("cellId"),
|
||||||
|
})
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def find_lost_slide_runs(records, path_label):
|
||||||
|
"""Signature B: a run of >= STALL_MIN_RUN consecutive ticks where the
|
||||||
|
body is in Contact-but-not-OnWalkable (resting against a steep surface,
|
||||||
|
the retail-faithful state for a roof/slope per the R6/#182 digest), a
|
||||||
|
move was requested each tick, but net advance stays near zero — the
|
||||||
|
"lost roof slide" / edge-wedge fingerprint. Runs adjacent in tick order
|
||||||
|
are merged; only runs of qualifying length are reported."""
|
||||||
|
runs = []
|
||||||
|
i = 0
|
||||||
|
n = len(records)
|
||||||
|
while i < n:
|
||||||
|
rec = records[i]
|
||||||
|
bb = rec.get("bodyBefore") or {}
|
||||||
|
inp = rec.get("input") or {}
|
||||||
|
res = rec.get("result") or {}
|
||||||
|
|
||||||
|
requested = dist(inp.get("targetPos", inp.get("currentPos", {"x":0,"y":0,"z":0})),
|
||||||
|
inp.get("currentPos", {"x": 0, "y": 0, "z": 0}))
|
||||||
|
advanced = dist(res.get("position", inp.get("currentPos", {"x":0,"y":0,"z":0})),
|
||||||
|
inp.get("currentPos", {"x": 0, "y": 0, "z": 0}))
|
||||||
|
|
||||||
|
qualifies = (is_contact_not_walkable(bb)
|
||||||
|
and requested > STALL_EPS
|
||||||
|
and advanced <= STALL_EPS)
|
||||||
|
|
||||||
|
if not qualifies:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
start = i
|
||||||
|
while i < n:
|
||||||
|
rec2 = records[i]
|
||||||
|
bb2 = rec2.get("bodyBefore") or {}
|
||||||
|
inp2 = rec2.get("input") or {}
|
||||||
|
res2 = rec2.get("result") or {}
|
||||||
|
requested2 = dist(inp2.get("targetPos", inp2.get("currentPos", {"x":0,"y":0,"z":0})),
|
||||||
|
inp2.get("currentPos", {"x": 0, "y": 0, "z": 0}))
|
||||||
|
advanced2 = dist(res2.get("position", inp2.get("currentPos", {"x":0,"y":0,"z":0})),
|
||||||
|
inp2.get("currentPos", {"x": 0, "y": 0, "z": 0}))
|
||||||
|
ok2 = (is_contact_not_walkable(bb2) and requested2 > STALL_EPS and advanced2 <= STALL_EPS)
|
||||||
|
if not ok2:
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
end = i - 1
|
||||||
|
|
||||||
|
run_len = end - start + 1
|
||||||
|
if run_len >= STALL_MIN_RUN:
|
||||||
|
first = records[start]
|
||||||
|
last = records[end]
|
||||||
|
runs.append({
|
||||||
|
"file": path_label,
|
||||||
|
"start_tick": first.get("tick"),
|
||||||
|
"end_tick": last.get("tick"),
|
||||||
|
"start_index": start,
|
||||||
|
"end_index": end,
|
||||||
|
"length": run_len,
|
||||||
|
"cellId": first.get("input", {}).get("cellId"),
|
||||||
|
"pos_start": first.get("input", {}).get("currentPos"),
|
||||||
|
"pos_end": last.get("input", {}).get("currentPos"),
|
||||||
|
"contactPlaneNormal": (first.get("bodyBefore") or {}).get("contactPlane", {}).get("normal"),
|
||||||
|
})
|
||||||
|
return runs
|
||||||
|
|
||||||
|
|
||||||
|
def main(paths):
|
||||||
|
all_bounce = []
|
||||||
|
all_stall = []
|
||||||
|
for path in paths:
|
||||||
|
records = load(path)
|
||||||
|
label = path
|
||||||
|
bounce = find_uphill_bounce_candidates(records, label)
|
||||||
|
stall = find_lost_slide_runs(records, label)
|
||||||
|
all_bounce.extend(bounce)
|
||||||
|
all_stall.extend(stall)
|
||||||
|
|
||||||
|
print(f"=== {path}: {len(records)} records ===")
|
||||||
|
print(f" uphill-jump-bounce candidates: {len(bounce)}")
|
||||||
|
print(f" lost-slide/edge-wedge runs (>= {STALL_MIN_RUN} ticks): {len(stall)}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== Signature A: uphill-jump bounce (first 15) ===")
|
||||||
|
for h in all_bounce[:15]:
|
||||||
|
print(f" {h['file']} tick={h['tick']} idx={h['index']} cell=0x{h['cellId']:08X} "
|
||||||
|
f"normal=({h['normal']['x']:.3f},{h['normal']['y']:.3f},{h['normal']['z']:.3f}) "
|
||||||
|
f"vz {h['vz_before']:.3f} -> {h['vz_after_next_tick']:.3f} "
|
||||||
|
f"pos=({h['pos']['x']:.2f},{h['pos']['y']:.2f},{h['pos']['z']:.2f})")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== Signature B: lost-slide / edge-wedge runs (first 15, sorted by length desc) ===")
|
||||||
|
all_stall.sort(key=lambda r: -r["length"])
|
||||||
|
for r in all_stall[:15]:
|
||||||
|
cn = r["contactPlaneNormal"] or {"x": 0, "y": 0, "z": 0}
|
||||||
|
print(f" {r['file']} ticks=[{r['start_tick']}..{r['end_tick']}] "
|
||||||
|
f"idx=[{r['start_index']}..{r['end_index']}] len={r['length']} "
|
||||||
|
f"cell=0x{r['cellId']:08X} cpNormal=({cn['x']:.3f},{cn['y']:.3f},{cn['z']:.3f}) "
|
||||||
|
f"pos {r['pos_start']['x']:.2f},{r['pos_start']['y']:.2f},{r['pos_start']['z']:.2f} "
|
||||||
|
f"-> {r['pos_end']['x']:.2f},{r['pos_end']['y']:.2f},{r['pos_end']['z']:.2f}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"TOTAL: {len(all_bounce)} bounce candidates, {len(all_stall)} stall runs "
|
||||||
|
f"across {len(paths)} file(s).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
|
main(sys.argv[1:])
|
||||||
Loading…
Add table
Add a link
Reference in a new issue