docs/ISSUES.md: #265 and #166 updated with the root cause and fix from the prior two commits; closure of both pends the user's visual-gate acceptance. #265 also records the confirmed-separate uphill-bounce finding (AD-25, byte-exact retail, out of scope). #166 records that the Campaign P visual-matrix recheck it was waiting on DID happen and found the glide/bounce still missing even with AD-25/AP-7/AD-55/TS-4 all landed - that negative result is what triggered the #265 capture bisect and this fix. docs/architecture/retail-divergence-register.md: AP-7's retirement note corrected. The row's original claim ("no horizontal velocity to hammer") undersold the gap - calc_friction was structurally unreachable with meaningful data on any grounded path, not just inert on the root-motion path. No new row filed: this change ports retail's mechanism faithfully and does not introduce a new deviation. docs/research/2026-07-30-265-capture-bisect.md: full "as-fixed" addendum (new section 9) recording the implementation - the fix mechanism, fixture results (freeze reproduced under the old model, slide+decay proven under the new one), the downhill-direction derivation for the synthetic decay case, the two separate mechanisms found while building the Runtime tests (LeaveGround's edge-timing recompute, AP-77's no-sink fallback), the uphill-bounce orthogonality proof, and final test totals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
682 lines
39 KiB
Markdown
682 lines
39 KiB
Markdown
# #265 capture-driven bisection — steep-slope response family
|
||
|
||
**Status: FIX IMPLEMENTED 2026-07-30 (same day, §9 as-fixed addendum) —
|
||
closure pends the user's visual-gate acceptance.** S1 and S2 both CLEARED
|
||
for the two concrete mined events; the real mechanism was a pre-existing
|
||
(frozen-phase) architecture, not a Campaign P regression — §1-§8 below are
|
||
the original bisection pass (research-only, no production code changed
|
||
at that point). §9 records what was actually implemented against that
|
||
verdict: `PlayerMovementController.cs`'s grounded residual-velocity zero is
|
||
removed for the animation-root-motion path, and `PhysicsEngine.cs` now
|
||
wires `PhysicsBody.GroundNormal` from the committed contact plane. 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 in §3 were applied and reverted locally
|
||
and never committed (they remain historical — the actual fix is
|
||
unrelated to S1/S2, see §9).
|
||
|
||
## 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.
|
||
|
||
## 9. As-fixed addendum (2026-07-30, same day — implementation session)
|
||
|
||
The user chose the retail-faithful direction (§8's first option): port the
|
||
genuine physics-driven momentum carry, wiring `calc_friction` for real
|
||
rather than adding a narrower coast-distance patch. Implementation
|
||
landed the same day as this bisect.
|
||
|
||
### 9.1 The fix
|
||
|
||
Two changes, both minimal and at the exact commit points already
|
||
responsible for the adjacent state:
|
||
|
||
1. **`src/AcDream.Core/Physics/PhysicsEngine.cs`** — `body.GroundNormal`
|
||
(the vector `calc_friction` dots velocity against, per its own doc
|
||
comment "`angle = dot(velocity, contactPlane.N)`") had **zero
|
||
production writers anywhere** before this fix; it silently defaulted
|
||
to `Vector3.UnitZ` forever (`grep -rn "GroundNormal\s*=" src/` found
|
||
only the property's own default and calc_friction's internal reads/
|
||
writes). This is a SEPARATE gap from the one §4 found — even if
|
||
Velocity had survived the grounded-tick zero, friction would have
|
||
dotted it against a fake flat-ground normal on any real slope,
|
||
producing wrong physics. Fixed by syncing
|
||
`body.GroundNormal = ci.ContactPlane.Normal` (or
|
||
`ci.LastKnownContactPlane.Normal`) at the exact block
|
||
(`PhysicsEngine.cs` ~:1297-1320) that already publishes
|
||
`body.ContactPlane`/`ContactPlaneValid` after every resolve — Core-level,
|
||
so player, remote, ordinary, and projectile movers all get a real
|
||
slope normal for free (matching the task's "the mechanism is general"
|
||
requirement; the ordinary/remote physics updaters
|
||
(`RuntimeOrdinaryPhysicsUpdater.cs`, `RuntimeRemotePhysicsUpdater.cs`)
|
||
already compose root motion + `UpdatePhysicsInternal` cleanly, with no
|
||
destructive zero — this fix brings the player path in line with its
|
||
own siblings, not a novel invention).
|
||
|
||
2. **`src/AcDream.Runtime/Gameplay/PlayerMovementController.cs`** — the
|
||
grounded-tick block §4 identified (`if (_body.OnWalkable) { ... if
|
||
(hasAnimationRootMotion) _body.Velocity = new Vector3(0f, 0f,
|
||
savedWorldVz); ... }`) no longer reconstructs `Velocity` AT ALL for the
|
||
`hasAnimationRootMotion` case (production graphical local-player
|
||
path). The condition is now `if (_body.OnWalkable &&
|
||
!hasAnimationRootMotion)`, so ONLY the headless/test-controller
|
||
`get_state_velocity` fallback (unchanged) still writes velocity here.
|
||
Root motion continues to fully own commanded locomotion (walking
|
||
displacement still comes from `pmDelta.Origin`, never from
|
||
`Velocity`) — this does not reintroduce command- or packet-cadence-
|
||
derived grounded translation (the DO-NOT-RETRY rule in
|
||
`claude-memory/project_physics_collision_digest.md`); it only stops
|
||
DESTROYING whatever `Velocity` already holds. The existing
|
||
`preIntegratePos`/`postIntegratePos` bracketing (root-motion apply,
|
||
then `calc_acceleration()` + `UpdatePhysicsInternal(tickDt)`, then
|
||
`ResolveWithTransition(preIntegratePos, postIntegratePos, ...)`) was
|
||
ALREADY structurally correct for composing both channels — retail's
|
||
`CPhysicsObj::UpdatePositionInternal` composition model — so no
|
||
further restructuring was needed once the destructive zero was
|
||
removed.
|
||
|
||
### 9.2 Fixture results (freeze → slide, proven)
|
||
|
||
`Issue265SteepSlopeCaptureBisectTests.cs` gained a `ComposedTickSample`
|
||
harness (`ReplayRealRoofLandingComposed`) that mirrors
|
||
`PlayerMovementController.cs`'s per-tick composition line-for-line using
|
||
only Core types (`PhysicsBody`, `PhysicsObjUpdate.HandleAllCollisions`,
|
||
`PhysicsEngine`), parameterized by a
|
||
`preserveResidualVelocityOnGroundedTick` toggle representing the old vs.
|
||
new shape:
|
||
|
||
- **`ComposedRoofLanding_OldZeroingModel_ReproducesTheMinedFreeze`**
|
||
(toggle `false`): reproduces the exact mined signature — velocity forced
|
||
to `(0,0,0)` the tick after landing, frozen solid (`FrozenStreak` grows
|
||
unbounded) for the rest of the replay.
|
||
- **`ComposedRoofLanding_NewFix_VelocitySurvivesAndPositionKeepsAdvancing`**
|
||
(toggle `true`): the SAME captured landing (velocity `(11.15, 14.13,
|
||
-23.14)` onto the real `(2,3,6)/7` roof normal) now survives the Z-only
|
||
hand-zero with its full horizontal speed, and the position advances
|
||
every single tick (`adv=0.5149` per tick, `onWalk=true`, `frozen=0`)
|
||
for the entire post-landing window — a genuine sustained glide, not a
|
||
freeze. (The original small real-captured triangle had to be enlarged
|
||
6x about its centroid — same plane, same normal, same landing point/tick,
|
||
see `MakeRoofEngine`'s new `scale` parameter — because the real glide
|
||
travels ~50 m over the test window and would otherwise run off the
|
||
tiny real triangle's edge into the SEPARATE small-triangle-boundary
|
||
artifact §7 item 2 already flagged; that artifact is confirmed
|
||
real and unrelated to this fix, see §9.4.)
|
||
- **`ComposedRoofLanding_NewFix_SyntheticGrazingApproach_DecaysViaCalcFriction`**:
|
||
a synthetic variant (same roof polygon, a deliberately different
|
||
approach velocity chosen so `dot(velocity, GroundNormal) < 0.25` after
|
||
landing) proves genuine exponential decay: speed at landing ≈ 6.0 m/s
|
||
decays tick-by-tick down to the `SmallVelocitySquared` hard-zero floor
|
||
by roughly tick 33 after landing — retail's `calc_friction` formula
|
||
working exactly as ported.
|
||
|
||
**Important nuance:** the REAL captured landing (record 3433's velocity
|
||
and normal) happens to fall in retail's "moving away fast enough, no
|
||
friction" band (`dot(velocity, GroundNormal) ≈ +9.25 ≥ 0.25`) — so it
|
||
glides at CONSTANT velocity across the roof rather than visibly decaying.
|
||
This is not a bug; retail's own `calc_friction` early-returns in exactly
|
||
this case (the velocity's horizontal projection points "downhill," same
|
||
direction as the normal's horizontal projection — see the derivation in
|
||
§9.3). The task's framing ("decays over subsequent ticks") is
|
||
demonstrated by the separate synthetic case above, which deliberately
|
||
selects a velocity/normal pairing where retail's own formula calls for
|
||
decay; the real mined case demonstrates the OTHER correct retail outcome
|
||
(sustained glide) for its own geometry. Both are "survives and slides,"
|
||
never "freezes" — the actual acceptance bar.
|
||
|
||
### 9.3 Downhill direction derivation (for the synthetic decay case)
|
||
|
||
For a planar triangle with outward normal N and any point P on the
|
||
plane, `dot(N, P - centroid) = 0` (coplanarity). For a slope where Z
|
||
increases as you move "uphill," the outward normal's horizontal
|
||
projection points toward LOWER Z (downhill) — e.g. plane `z = m·x`
|
||
(uphill as x increases) has normal `∝ (-m, 0, 1)`, whose horizontal
|
||
component `-m` points toward decreasing x (downhill). The real captured
|
||
roof normal `(0.2857, 0.4286, 0.8571)` has horizontal projection
|
||
`(0.2857, 0.4286)` pointing downhill; the captured velocity's horizontal
|
||
component `(11.15, 14.13)` points in nearly the same direction (both
|
||
positive, roughly proportional) — i.e. the mover is genuinely sliding
|
||
DOWN and AWAY from the impact point, which is exactly why
|
||
`dot(velocity, normal)` comes out strongly positive and friction
|
||
correctly declines to engage.
|
||
|
||
### 9.4 Runtime-level regression tests + a second, unrelated mechanism found
|
||
|
||
`tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs`
|
||
gained two tests exercising the REAL `PlayerMovementController` (not just
|
||
the Core-level model):
|
||
|
||
- **`Update_AnimationRootMotion_WalkSpeedUnaffectedByResidualVelocityFix`**:
|
||
ordinary root-motion walking (no fall/collision in flight) advances by
|
||
exactly the authored per-tick delta for 30 ticks and `BodyVelocity`
|
||
stays exactly zero throughout — confirming the fix is a complete no-op
|
||
for the common "just walking around" case, pinning the L.3c hazard
|
||
(`claude-memory/project_physics_collision_digest.md`'s DO-NOT-RETRY
|
||
table) at the Runtime level in addition to the existing
|
||
`GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`
|
||
Core-level pin (unmodified, still green).
|
||
- **`Update_RunningJumpLandsOnFlatGround_ResidualVelocitySurvivesAndDecays_NotFrozen`**:
|
||
a real charged running jump (forward + jump, full production dispatch)
|
||
lands on flat ground and its residual horizontal speed survives the
|
||
first post-landing tick, then measurably decays (flat ground:
|
||
`dot(velocity, (0,0,1)) ≈ 0 < 0.25`, so friction DOES engage here,
|
||
unlike the real roof capture above).
|
||
|
||
**Building this test surfaced a second, genuinely separate,
|
||
already-registered mechanism** (temporary `Console.WriteLine`
|
||
instrumentation was added and fully removed per CLAUDE.md's diagnostic-
|
||
logging discipline): `MotionInterpreter.LeaveGround()`
|
||
(`CMotionInterp::LeaveGround` 0x00528b00, R3-W4/J7/J8, unrelated to
|
||
#265/#166) recomputes and OVERWRITES `PhysicsObj.Velocity` from
|
||
`GetLeaveGroundVelocity()` on the grounded→airborne edge, using whatever
|
||
forward command is interpreted AT THAT EXACT TICK — a real, intentional,
|
||
already-ported retail behavior. Releasing the forward key in the SAME
|
||
tick this edge fires (an early test-construction mistake, not a
|
||
production concern) clobbers the just-launched velocity. Separately,
|
||
`MotionInterpreter.ApplyCurrentMovementInterpreted`'s AP-77
|
||
"animation-less/headless movement fallback" (register row AP-77,
|
||
already correctly scoped: "When `MotionInterpreter.DefaultSink` or the
|
||
local PartArray callback is absent...") ALSO rewrites grounded velocity
|
||
from `get_state_velocity()` on every `HitGround`/`LeaveGround` re-apply
|
||
when no `DefaultSink` is wired — which is exactly the state of a
|
||
`PlayerMovementController` built directly in a unit test without wiring
|
||
one. Production (`GameWindow`) always wires a real `DefaultSink`, so
|
||
neither mechanism is live there; the fixed test (1) holds Forward for one
|
||
extra tick so `LeaveGround`'s one-time recompute captures the real
|
||
launch velocity before releasing it, and (2) wires a minimal
|
||
`FakeAnimationDispatchSink` as `controller.Motion.DefaultSink` so
|
||
`ApplyCurrentMovementInterpreted` takes its real dispatch branch instead
|
||
of the AP-77 fallback — making the test representative of the production
|
||
graphical path rather than the headless one. **Neither mechanism
|
||
required any production code change or register update** — AP-77's row
|
||
already accurately describes its scope, and `LeaveGround`'s behavior is
|
||
intentional retail-ported behavior, not a bug this task touches.
|
||
|
||
### 9.5 Symptom (a), the uphill bounce — confirmed separate, unaffected
|
||
|
||
Re-derived `PhysicsObjUpdate.HandleAllCollisions`'s `shouldReflect` gate
|
||
byte-for-byte against the raw retail decomp
|
||
(`acclient_2013_pseudo_c.txt:282647-282760`,
|
||
`CPhysicsObj::handle_all_collisions`) this session:
|
||
`var_10_1` (== `shouldReflect`) ends up `!(arg4 && (transient_state & 2)
|
||
!= 0 && !sledding)` where `arg4` is `prevContact`/`prevOnWalkable`
|
||
captured at `SetPositionInternal` entry (before this call's own commits)
|
||
and `transient_state & 2` is read live inside `handle_all_collisions`
|
||
itself — i.e. AFTER `set_on_walkable` has already committed the
|
||
DESTINATION's OnWalkable bit. This is **exactly** `PhysicsObjUpdate.
|
||
HandleAllCollisions`'s existing `shouldReflect = !(prevOnWalkable &&
|
||
nowOnWalkable && !sledding)` — a byte-exact port, not a translation bug.
|
||
For ANY fresh landing from airborne (`prevOnWalkable=false`), retail
|
||
itself reflects whenever the collision normal shows "moving into the
|
||
surface" (`dot < 0`), REGARDLESS of whether the destination is walkable.
|
||
This is the SAME mechanism AD-25 closed (2026-07-30, Campaign P Slice
|
||
P3, docs/ISSUES.md #166) for both local and remote movers — confirmed
|
||
pre-existing and out of scope for this task, matching CLAUDE.md's "do
|
||
not fix code that matches retail" rule.
|
||
|
||
`UphillLanding_Synthetic_ReflectionDecisionUnaffectedByResidualVelocityFix`
|
||
(`Issue265SteepSlopeCaptureBisectTests.cs`) constructs a synthetic
|
||
30°-uphill walkable slope, a falling-forward approach with `dot(velocity,
|
||
normal) < 0` by construction, and runs `HandleAllCollisions` with and
|
||
without the residual-velocity-preserving toggle applied AFTERWARD. The
|
||
reflection decision (and its resulting velocity) is identical either way
|
||
— proving the #265/#166 fix is orthogonal to whatever
|
||
`HandleAllCollisions` decides, not a cause of or a fix for the bounce.
|
||
The test's own log line documents the specific synthetic case DOES
|
||
reflect (`Vz` goes from `0` to `+2.27` on this exact input), consistent
|
||
with retail's byte-exact algorithm — evidence for a future dedicated pass
|
||
if the user's live repro still shows an unwanted bounce, not a verdict
|
||
this task renders.
|
||
|
||
### 9.6 Test/file summary
|
||
|
||
- `src/AcDream.Core/Physics/PhysicsEngine.cs` — `GroundNormal` sync.
|
||
- `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` — grounded
|
||
block no longer zeros `Velocity` for the animation-root-motion case.
|
||
- `tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs` —
|
||
`MakeRoofEngine`'s new `scale` parameter, `ComposedTickSample` +
|
||
`ReplayRealRoofLandingComposed`, and four new `[Fact]`s (old-model
|
||
freeze pin, new-fix slide proof, synthetic decay proof, uphill-bounce
|
||
orthogonality proof).
|
||
- `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs` —
|
||
`FakeAnimationDispatchSink` + two new `[Fact]`s (walk-speed no-op pin,
|
||
real running-jump landing survival+decay pin).
|
||
- `docs/ISSUES.md` — #265 and #166 updated (fix implemented, closure
|
||
pends the user's visual-gate acceptance).
|
||
- `docs/architecture/retail-divergence-register.md` — AP-7's retirement
|
||
note corrected (the 0.25f threshold port was always right; it had
|
||
nothing real to operate on until this fix closed both the grounded-
|
||
velocity-zero and the `GroundNormal`-wiring gaps). No new row filed —
|
||
this change ports retail's mechanism faithfully; it does not introduce
|
||
a new deviation.
|
||
|
||
### 9.7 Verification
|
||
|
||
`dotnet test` (Release): 4074 Core tests / 2 skips, 434 Runtime tests / 0
|
||
skips, 3971 App tests / 3 skips — all green, no regressions. Complete
|
||
solution suite (9 projects): 9993 total, 9988 passed, 5 skipped, 0
|
||
failed.
|