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>
39 KiB
#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.csPath-6'shasSphere1(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 unconditionalSetCollisionNormal+return Collided. - S2 — the AP-7
calc_frictionthreshold rewrite (merge26e0334a):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 (
bodyBeforeContact bit clear) withresult.collisionNormalValid=trueand a "steep, non-floor, non-wall" normal (0.02 < normal.Z < FloorZ=0.6642), followed by a next-tick upward jump inbodyBefore.velocity.zwhile 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)withvzaccumulating 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).Zfalls 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 theUnitZdegenerate 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 ABOVEPhysicsGlobals.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.velocityis UNCHANGED(11.15,14.13,‑23.14)— confirms (seePhysicsEngine.cs:1489, capture fires insideResolveWithTransition, before any caller-side velocity response) that neithercalc_frictionnorHandleAllCollisionsran 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, sametransientState=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)
ShadowObjectRegistry.Register's broad-phase culls by distance fromworldPos. 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.CellTransit.BuildShadowCellSet's outdoor flood (CellTransit.AddAllOutsideCells) treats world position as landblock-local (an anchor-frame convention shared withTs4SteepRoofWedgeCaptureTests/DoorBugTrajectoryReplayTests, active wheneverCellGraph.TryGetTerrainOriginhas 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.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 literalseedCellIdpassed toRegister— an arbitrary chosen cell id (0x00000011) registered successfully (TotalRegistered=1) butGetObjectsInCell(0x00000011)came back empty; the entity had actually landed in cell0x00000001(the canonical grid-(0,0) cell, matching the re-anchored centroid). Switching the harness's cell id to0x00000001fixed 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:
- The landing tick (
PlayerMovementController.cs, theif (resolveResult.IsOnGround && _body.Velocity.Z <= 0f)block): Contact+OnWalkable are set, and — becauseVelocity.Z < 0— ONLY the Z component is hand-zeroed: velocity becomes(11.15, 14.13, 0).PhysicsObjUpdate.HandleAllCollisionsthen runs withshouldReflect = true(the mover was airborne the frame before:prevOnWalkable=falsemakesshouldReflectunconditionally true regardless of the new grounded state — seePhysicsObjUpdate.cs:163-164). Butdot(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 theif (dot < 0f)reflection guard (PhysicsObjUpdate.cs:177) never fires. Velocity survives this tick as(11.15, 14.13, 0). - The very next tick (
PlayerMovementController.cs:1868-1882, added 2026-07-20 byf961d700, "port retail complete object frame pipeline" — R6, well before Campaign P):if (_body.OnWalkable) { float savedWorldVz = _body.Velocity.Z; if (hasAnimationRootMotion) { _body.Velocity = new Vector3(0f, 0f, savedWorldVz); } ... }OnWalkableis now true (set last tick), so this runs UNCONDITIONALLY, EVERY tick, for as long as the mover stays grounded: it zerosVelocity.X/Yto exactly zero (savedWorldVzis 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.OriginstaysVector3.Zeroforever, and the mover never advances again. This reproducesbodyBefore.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, ACEBSPTree.cs:221-230) are an exact structural match — not a BN misdecompile, not a citation error. This was independently re-verified by reading the currentBSPQuery.cs:2259-2302against 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 (
Contactset), 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 theif (hit0 || hitPoly0 is not null)branch abovehasSphere1's check fires first and RETURNS beforehasSphere1's block is ever entered (BSPQuery.cs:2188gates the wholehasSphere1block behind falling through that firstif).hit1=Truenever 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)
- 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.
- The tick-63 edge freeze in this pass's own harness (§6, closing
parenthetical) — a
CollisionNormalunrelated 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 (possiblySpherePath.PrecipiceSlide's edge-crossing test, or staleLastKnownContactPlanepersistence — NOT yet confirmed, do not guess further). - 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 38collisionNormalValid=truerecords in session2 showed the "airborne, then a large upwardVelocity.Zjump next tick" signature this pass's Signature-A scanner looked for. A fresh capture specifically of a jump-into-an-upward-slope repro (ideally withACDREAM_PROBE_RESOLVE=1orACDREAM_CAPTURE_RESOLVEactive 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:
-
src/AcDream.Core/Physics/PhysicsEngine.cs—body.GroundNormal(the vectorcalc_frictiondots velocity against, per its own doc comment "angle = dot(velocity, contactPlane.N)") had zero production writers anywhere before this fix; it silently defaulted toVector3.UnitZforever (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 syncingbody.GroundNormal = ci.ContactPlane.Normal(orci.LastKnownContactPlane.Normal) at the exact block (PhysicsEngine.cs~:1297-1320) that already publishesbody.ContactPlane/ContactPlaneValidafter 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 +UpdatePhysicsInternalcleanly, with no destructive zero — this fix brings the player path in line with its own siblings, not a novel invention). -
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 reconstructsVelocityAT ALL for thehasAnimationRootMotioncase (production graphical local-player path). The condition is nowif (_body.OnWalkable && !hasAnimationRootMotion), so ONLY the headless/test-controllerget_state_velocityfallback (unchanged) still writes velocity here. Root motion continues to fully own commanded locomotion (walking displacement still comes frompmDelta.Origin, never fromVelocity) — this does not reintroduce command- or packet-cadence- derived grounded translation (the DO-NOT-RETRY rule inclaude-memory/project_physics_collision_digest.md); it only stops DESTROYING whateverVelocityalready holds. The existingpreIntegratePos/postIntegratePosbracketing (root-motion apply, thencalc_acceleration()+UpdatePhysicsInternal(tickDt), thenResolveWithTransition(preIntegratePos, postIntegratePos, ...)) was ALREADY structurally correct for composing both channels — retail'sCPhysicsObj::UpdatePositionInternalcomposition 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(togglefalse): reproduces the exact mined signature — velocity forced to(0,0,0)the tick after landing, frozen solid (FrozenStreakgrows unbounded) for the rest of the replay.ComposedRoofLanding_NewFix_VelocitySurvivesAndPositionKeepsAdvancing(toggletrue): the SAME captured landing (velocity(11.15, 14.13, -23.14)onto the real(2,3,6)/7roof normal) now survives the Z-only hand-zero with its full horizontal speed, and the position advances every single tick (adv=0.5149per 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, seeMakeRoofEngine's newscaleparameter — 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 sodot(velocity, GroundNormal) < 0.25after landing) proves genuine exponential decay: speed at landing ≈ 6.0 m/s decays tick-by-tick down to theSmallVelocitySquaredhard-zero floor by roughly tick 33 after landing — retail'scalc_frictionformula 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 andBodyVelocitystays 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 existingGroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTestsCore-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—GroundNormalsync.src/AcDream.Runtime/Gameplay/PlayerMovementController.cs— grounded block no longer zerosVelocityfor the animation-root-motion case.tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs—MakeRoofEngine's newscaleparameter,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 theGroundNormal-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.