docs: #265/#166 - ledger updates and capture-bisect as-fixed addendum

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>
This commit is contained in:
Erik 2026-07-30 18:45:43 +02:00
parent 9910838fa4
commit 7fcc7db1d1
3 changed files with 388 additions and 30 deletions

View file

@ -183,23 +183,98 @@ ACE's >= reading" warning.
## #265 — Steep-slope response set: uphill-jump bounce, roof slides lost, edge wedge (TS-4 removal fallout — REVERTED) ## #265 — Steep-slope response set: uphill-jump bounce, roof slides lost, edge wedge (TS-4 removal fallout — REVERTED)
**Status:** OPEN — the TS-4 removal is reverted (`2e27d066`+`a8a7d64b`); **Status:** FIX IMPLEMENTED 2026-07-30 (this session) — closure pends the
re-test confirms expected. The underlying oracle work is re-opened. user's visual-gate acceptance. The named culprit for symptoms (b) and (c)
was capture-bisected to a THIRD, pre-existing (frozen-phase, predates
Campaign P by ten days) mechanism — neither the S1 nor S2 suspects named
below — and is now ported. Symptom (a) is confirmed a SEPARATE,
pre-existing, already-closed retail-faithful mechanism (AD-25); see the
"as-fixed" addendum for the full trace.
**Severity:** HIGH (matrix live gate 2026-07-30, scenarios 4/5) **Severity:** HIGH (matrix live gate 2026-07-30, scenarios 4/5)
**Component:** physics — steep-poly response (BSPQuery Path-6 family) **Component:** physics — grounded residual-velocity ownership
(`PlayerMovementController.cs`), not BSPQuery/Path-6 (see below)
**Symptoms (user report, on the shortcut-removed build):** (a) bouncing **Symptoms (user report, on the shortcut-removed build):** (a) bouncing
when jumping INTO an uphill slope — retail does not; (b) house-roof when jumping INTO an uphill slope — retail does not; (b) house-roof
slides no longer happen ("as I used to"); (c) occasionally stuck sliding slides no longer happen ("as I used to"); (c) occasionally stuck sliding
on an edge — the historical wedge, live, refuting the oracle plan's on an edge — the historical wedge, live, refuting the oracle plan's
"pure-vertical degenerate only" convergence claim. The fixture-gated "pure-vertical degenerate only" convergence claim. The fixture-gated
removal under-modeled real trajectories. DO-NOT-RETRY the removal TS-4 removal under-modeled real trajectories, but a full capture-driven
without a live-capture-driven rework of the oracle plan §7 analysis bisect (`docs/research/2026-07-30-265-capture-bisect.md`) cleared BOTH
(capture a real roof-slide + uphill-jump trajectory with of the two named Campaign-P suspects for the two concrete mined freeze
ACDREAM_CAPTURE_RESOLVE and replay them as the gate). Note: the events:
downhill-landing sled remains #166 (pre-existing, unfixed — the cos(10°)
constant is necessary but not sufficient; the Sledding state never - **S1** (`db2889af`, BSPQuery Path-6 `hasSphere1`) — reverting it locally
engages). produced byte-identical replay output; its site is provably unreached
by either mined trajectory (`hit1` never true across an 80-tick
replay). Real, narrow, retail-faithful — NOT reverted.
- **S2** (`calc_friction`'s 0.25 threshold, AP-7) — proven inert by static
analysis before this session (zero production call sites at the time).
**Root cause (this session, capture-bisect + fix):**
`PlayerMovementController.cs`'s per-tick grounded block (the R6
"animation-root-motion-owned grounded movement" architecture, landed
`f961d700`, 2026-07-20 — ten days before Campaign P, so not a Campaign-P
regression) hand-zeroed `Velocity.X/Y` to EXACTLY zero every single tick
once `OnWalkable`, whenever animation root motion drives the walk (the
production graphical local-player path). This discarded any residual
horizontal momentum a fall/landing left on the body BEFORE
`calc_friction` (AP-7, already correctly ported) or
`PhysicsBody.UpdatePhysicsInternal`'s Euler integrator ever got a chance
to act on it — a mover that landed on a walkable roof/slope with residual
velocity had that velocity vanish the very next tick and never moved
again. A second, previously-unwired gap compounded this: `PhysicsBody.
GroundNormal` (what `calc_friction` dots the velocity against) had ZERO
production writers anywhere — it silently defaulted to `Vector3.UnitZ`
forever, so even without the zero, friction would have treated every
slope as flat ground.
**Fix:** (1) `src/AcDream.Core/Physics/PhysicsEngine.cs` now syncs
`body.GroundNormal` from the committed `ContactPlane.Normal` at the same
commit point that already publishes `ContactPlane` itself (Core-level,
so player/remote/ordinary/projectile all benefit uniformly — "the
mechanism is general," not roof-specific). (2)
`src/AcDream.Runtime/Gameplay/PlayerMovementController.cs`'s grounded
block no longer reconstructs `Velocity` at all for the animation-root-
motion case (only the headless/test-controller `get_state_velocity`
fallback still does, unchanged — that model has no separate root-motion
channel to compose with). Root motion still fully owns commanded
locomotion; this only stops DESTROYING whatever residual `Velocity`
already holds, letting it compose with root motion through the SAME
`ResolveWithTransition` sweep exactly as retail's
`CPhysicsObj::UpdatePositionInternal` composes both channels.
**Symptom (a) — NOT addressed, confirmed separate:** the "uphill bounce"
traces to `PhysicsObjUpdate.HandleAllCollisions`'s `shouldReflect`
gate (`!(prevOnWalkable && nowOnWalkable && !sledding)`), re-verified
BYTE-EXACT against the raw retail decomp (`handle_all_collisions`,
pc:282647-282760) this session. For any FRESH landing from airborne
(`prevOnWalkable=false`), retail itself reflects whenever the collision
normal shows "moving into the surface" (`dot &lt; 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. Per CLAUDE.md's "do not fix code that matches
retail" rule, this is out of scope for a fix. A synthetic 30°-uphill test
(`UphillLanding_Synthetic_ReflectionDecisionUnaffectedByResidualVelocityFix`,
`Issue265SteepSlopeCaptureBisectTests.cs`) confirms the residual-velocity
fix above changes NOTHING about this reflection decision (same input,
same output, with or without the fix) — it is orthogonal, not
introduced or worsened. If the user's live repro still shows an
unwanted bounce after this fix lands, it needs its own dedicated
capture + brainstorm against `HandleAllCollisions`/`BSPQuery`, not a
reopening of this root cause.
**Evidence:** `docs/research/2026-07-30-265-capture-bisect.md`'s
as-fixed addendum; `Issue265SteepSlopeCaptureBisectTests.cs`'s new
`ComposedRoofLanding_*` fixtures (freeze reproduced under the old model,
survives+advances under the new one, exponential decay demonstrated in a
synthetic dot&lt;0.25 case); `PlayerMovementControllerTests.cs`'s new
`Update_AnimationRootMotion_WalkSpeedUnaffectedByResidualVelocityFix`
(ordinary walking is a no-op under the fix) and
`Update_RunningJumpLandsOnFlatGround_ResidualVelocitySurvivesAndDecays_NotFrozen`
(a real running jump's residual velocity survives landing and decays on
the actual production `PlayerMovementController`, not just the Core-level
model).
--- ---
@ -4495,9 +4570,17 @@ wall from acdream, matching the retail-observer view side-by-side.
## #166 — Slope-landing glide + bounce absent (retail "sled" on downhill jumps) ## #166 — Slope-landing glide + bounce absent (retail "sled" on downhill jumps)
**Status:** OPEN (post-R6 polish — user: "we could polish later"); all **Status:** FIX IMPLEMENTED 2026-07-30 (this session) — closure pends the
four register-predicted composite deviations (AD-25, AP-7, AD-55, TS-4) user's visual-gate acceptance. The visual-matrix recheck this note asked
are now landed as of 2026-07-30 — pending only a visual-matrix recheck for DID happen (Campaign P matrix scenario 5) and found the glide/bounce
still missing even with all four register-predicted deviations
(AD-25/AP-7/AD-55/TS-4) landed — that negative result is exactly what
triggered the #265 capture bisect, which found a FIFTH, previously-
unnamed mechanism: `PlayerMovementController.cs`'s grounded block was
hand-zeroing residual `Velocity.X/Y` every tick, discarding any landing
momentum before AD-25/AP-7/AD-55's now-correct machinery ever got a
chance to act on it. See #265 for the full root cause and fix (same
commit); this issue is the "downhill sled" half of that same mechanism.
**Severity:** LOW (feel/polish) **Severity:** LOW (feel/polish)
**Filed:** 2026-07-03 (user observation during the R2-R4 visual pass) **Filed:** 2026-07-03 (user observation during the R2-R4 visual pass)
**Component:** physics, landing **Component:** physics, landing
@ -4570,27 +4653,57 @@ against `0x0050ee70`, not the previously-carried ACE-derived `0.99999536f`
completion the "sled deceleration differs" framing above was waiting on — completion the "sled deceleration differs" framing above was waiting on —
all four of AD-25, AP-7, AD-55, and TS-4 are now landed. all four of AD-25, AP-7, AD-55, and TS-4 are now landed.
**The fifth deviation found and fixed (2026-07-30, this session,
docs/research/2026-07-30-265-capture-bisect.md):** with AD-25/AP-7/
AD-55/TS-4 all landed, the matrix recheck STILL found no glide/bounce —
the composite framing above was correct as far as it went, but it
missed a pre-existing (2026-07-20, ten days before Campaign P) R6
architectural fact: `PlayerMovementController.cs`'s grounded quantum
block hand-zeroed `Velocity.X/Y` to exactly zero every tick once
`OnWalkable`, for the production animation-root-motion path. This ran
regardless of AD-25/AP-7/AD-55/TS-4's correctness — it simply erased the
residual velocity those fixes would otherwise have had something to act
on. A second gap compounded it: `PhysicsBody.GroundNormal` (the vector
`calc_friction` dots velocity against) had no production writer and
silently defaulted to `Vector3.UnitZ`, so slopes behaved like flat
ground even when velocity DID survive. Fixed by (1) syncing
`body.GroundNormal` from the committed `ContactPlane.Normal` in
`PhysicsEngine.cs`'s existing per-resolve commit block, and (2) no longer
reconstructing `Velocity` in the grounded block for the animation-root-
motion case (root motion still fully owns commanded locomotion; only the
residual-momentum zero is gone). A synthetic case with the real mined
roof polygon but a velocity/normal pairing under retail's 0.25 threshold
(`ComposedRoofLanding_NewFix_SyntheticGrazingApproach_DecaysViaCalcFriction`)
demonstrates genuine exponential decay via `calc_friction`; the real
captured landing's own velocity happens to fall in the "moving away fast
enough, no friction" band (dot ≥ 0.25), producing a constant-velocity
glide across the roof instead — both are correct per retail's ported
formula for their respective geometries.
Closure of #166 therefore pends only re-checking Campaign P's final Closure of #166 therefore pends only re-checking Campaign P's final
visual matrix item 5 ("Downhill jump landing: sled glide + bounce") visual matrix item 5 ("Downhill jump landing: sled glide + bounce")
against a fresh capture — if the glide/bounce still visibly mismatches against a fresh capture of THIS fix — if the glide/bounce still visibly
retail now that every register-predicted composite deviation is retired, mismatches retail, that capture, not a guess, is what should drive any
that capture, not a guess, is what should drive any further work here, further work here, and it should go through cdb against live retail
and it should go through cdb against live retail before any client-side before any client-side Sledding-state mechanism is written (recall: no
Sledding-state mechanism is written (recall: no client-side client-side `PhysicsState.Sledding` auto-toggle exists in retail per the
`PhysicsState.Sledding` auto-toggle exists in retail per the reattribution reattribution above — a data-authored toggle exists only server-side).
above — a data-authored toggle exists only server-side).
**Where:** `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs` **Where:** `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`
(remote reflect, AD-25 — DONE 2026-07-30), (remote reflect, AD-25 — DONE 2026-07-30),
`src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`, AP-7 and AD-55 `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`, AP-7 and AD-55
— both DONE 2026-07-30), `src/AcDream.Core/Physics/BSPQuery.cs` + — both DONE 2026-07-30), `src/AcDream.Core/Physics/BSPQuery.cs` +
`FlatBspQuery.cs` (Path 6 steep branches, TS-4 — DONE 2026-07-30). `FlatBspQuery.cs` (Path 6 steep branches, TS-4 — DONE 2026-07-30),
`src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (grounded
residual-velocity zero, the fifth deviation — DONE 2026-07-30),
`src/AcDream.Core/Physics/PhysicsEngine.cs` (`GroundNormal` wiring —
DONE 2026-07-30).
**Acceptance:** side-by-side downhill jump: acdream glides/bounces like **Acceptance:** side-by-side downhill jump: acdream glides/bounces like
retail; flat-ground landings unchanged; no micro-bounce death spiral retail; flat-ground landings unchanged; no micro-bounce death spiral
(the reason AD-25 existed) reintroduced. Every code-side composite (the reason AD-25 existed) reintroduced. Every code-side composite
deviation is now landed; only the visual-matrix recheck remains before deviation, including the fifth one found this session, is now landed;
this issue can close. only the visual-matrix recheck remains before this issue can close.
## #164 — UM action-replay dispatches drop the per-action Autonomous bit ## #164 — UM action-replay dispatches drop the per-action Autonomous bit

View file

@ -131,7 +131,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 | | AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 |
| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) | | AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) |
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 | | AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
| ~~AP-7~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the "state gate" was a BN decompiler artifact, not a locomotion exemption.** `calc_friction` now ports retail's confirmed 0.25f threshold (`if (angle >= 0.25f) return;`) unconditionally, no special-cased gate. The "state check at pc:276702" the old row cited is `PhysicsState.Sledding` (confirmed via ACE's `PhysicsObj.calc_friction`, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141, and `SLEDDING_PS=0x800000` in acclient.h:2838) — it gates the 1.5625/6.25/near-flat friction-value OVERRIDE, not the threshold return itself; acdream had no live Sledding setter then or now (see #166 research, docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3), so the branch was simply unreachable dead code, not an exemption for ordinary walking. The reverted 2026-04-30 L.3c attempt (naive 0.0→0.25 bump, forward locomotion 3→0.16 m/s in `PlayerMovementControllerTests`) does not reproduce on the production graphical local-player path post-R6: `PlayerMovementController` zeroes `Velocity.X/Y` to exactly zero every tick before `calc_friction` runs whenever animation root motion drives the walk, so friction has no horizontal velocity left to hammer (pinned at the PhysicsBody level by `GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`). The headless/`get_state_velocity` movement-controller path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`); `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` (AP-7 test block) | — | — | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70); ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141; `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1 | | ~~AP-7~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the "state gate" was a BN decompiler artifact, not a locomotion exemption.** `calc_friction` now ports retail's confirmed 0.25f threshold (`if (angle >= 0.25f) return;`) unconditionally, no special-cased gate. The "state check at pc:276702" the old row cited is `PhysicsState.Sledding` (confirmed via ACE's `PhysicsObj.calc_friction`, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141, and `SLEDDING_PS=0x800000` in acclient.h:2838) — it gates the 1.5625/6.25/near-flat friction-value OVERRIDE, not the threshold return itself; acdream had no live Sledding setter then or now (see #166 research, docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3), so the branch was simply unreachable dead code, not an exemption for ordinary walking. The reverted 2026-04-30 L.3c attempt (naive 0.0→0.25 bump, forward locomotion 3→0.16 m/s in `PlayerMovementControllerTests`) does not reproduce on the production graphical local-player path post-R6: `PlayerMovementController` zeroes `Velocity.X/Y` to exactly zero every tick before `calc_friction` runs whenever animation root motion drives the walk, so friction has no horizontal velocity left to hammer (pinned at the PhysicsBody level by `GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`). The headless/`get_state_velocity` movement-controller path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there. **CORRECTION (2026-07-30, same day, #265/#166 capture bisect):** the sentence above undersold the gap — `calc_friction` wasn't merely "no horizontal velocity to hammer," it was structurally UNREACHABLE with meaningful data on ANY grounded path: (a) the animation-root-motion path zeroed `Velocity.X/Y` outright every tick (the actual #265/#166 root cause, ten days pre-existing, not a Campaign-P regression), and (b) `PhysicsBody.GroundNormal` — the vector `calc_friction` dots velocity against — had ZERO production writers anywhere and silently defaulted to `Vector3.UnitZ` forever, so even surviving velocity would have been tested against a fake flat-ground normal on any real slope. Both gaps are now closed: `PlayerMovementController.cs`'s grounded block no longer reconstructs `Velocity` for the animation-root-motion case, and `PhysicsEngine.cs` syncs `body.GroundNormal` from the committed `ContactPlane.Normal` at the same commit point that already publishes `ContactPlane`. The 0.25f threshold port itself (this row's original subject) was always correct — it just had nothing real to operate on until this fix. See `docs/research/2026-07-30-265-capture-bisect.md`'s as-fixed addendum. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`GroundNormal` wiring); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (grounded-velocity fix); `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` (AP-7 test block); `tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`; `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs` | — | — | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70); ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141; `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1; `docs/research/2026-07-30-265-capture-bisect.md` |
| ~~AP-10~~ | **RETIRED 2026-07-30 (Campaign P Slice P4) — the retail 0.1 m dry-corner water sink-in is restored.** `TerrainSurface.SampleWaterDepth` (`src/AcDream.Core/Physics/TerrainSurface.cs`) now returns 0.1 for a partially-water cell's dry corner instead of the collapsed 0. The row's own "destabilizes the touch check" justification turned out to be structurally true of retail too (a skipped `SetContactPlane` reassertion is not a fall in ANY of retail/ACE/acdream, because `Contact`/`OnWalkable` are STICKY — `PhysicsEngine.ResolveWithTransition`'s `onGround` computation ORs the fresh per-call `ContactPlaneValid` with the seeded, persistent `PhysicsBody.TransientState.OnWalkable` bit) — traced and confirmed in this slice; see `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.2. `PhysicsEngine.SampleTerrainWalkable`'s `isWater = waterDepth >= 0.45f` threshold means the restore does not flip the dry corner's water classification (0.1 still < 0.45) only the sink-in depth changes. Full Release suite green (no regression) proves the sticky-bit argument held in practice, not just in theory. | `src/AcDream.Core/Physics/TerrainSurface.cs` (`SampleWaterDepth`) | | | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port); `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.1-5.2 | | ~~AP-10~~ | **RETIRED 2026-07-30 (Campaign P Slice P4) — the retail 0.1 m dry-corner water sink-in is restored.** `TerrainSurface.SampleWaterDepth` (`src/AcDream.Core/Physics/TerrainSurface.cs`) now returns 0.1 for a partially-water cell's dry corner instead of the collapsed 0. The row's own "destabilizes the touch check" justification turned out to be structurally true of retail too (a skipped `SetContactPlane` reassertion is not a fall in ANY of retail/ACE/acdream, because `Contact`/`OnWalkable` are STICKY — `PhysicsEngine.ResolveWithTransition`'s `onGround` computation ORs the fresh per-call `ContactPlaneValid` with the seeded, persistent `PhysicsBody.TransientState.OnWalkable` bit) — traced and confirmed in this slice; see `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.2. `PhysicsEngine.SampleTerrainWalkable`'s `isWater = waterDepth >= 0.45f` threshold means the restore does not flip the dry corner's water classification (0.1 still < 0.45) only the sink-in depth changes. Full Release suite green (no regression) proves the sticky-bit argument held in practice, not just in theory. | `src/AcDream.Core/Physics/TerrainSurface.cs` (`SampleWaterDepth`) | | | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port); `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.1-5.2 |
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 | | AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 |
| AP-12 | Enchantment family-stacking tiebreak by largest SpellId; retail picks highest Generation, tie-broken by latest cast | `src/AcDream.Core/Spells/EnchantmentMath.cs:89` | `ActiveEnchantmentRecord` doesn't carry Generation; SpellId correlates with generation level in practice | Where spell ids don't track power within a family (or same-generation re-cast), the wrong buff wins — vital-max / stat values diverge from retail | `CEnchantmentRegistry::EnchantAttribute` 0x00594570 (pc:416110) | | AP-12 | Enchantment family-stacking tiebreak by largest SpellId; retail picks highest Generation, tie-broken by latest cast | `src/AcDream.Core/Spells/EnchantmentMath.cs:89` | `ActiveEnchantmentRecord` doesn't carry Generation; SpellId correlates with generation level in practice | Where spell ids don't track power within a family (or same-generation re-cast), the wrong buff wins — vital-max / stat values diverge from retail | `CEnchantmentRegistry::EnchantAttribute` 0x00594570 (pc:416110) |

View file

@ -1,13 +1,20 @@
# #265 capture-driven bisection — steep-slope response family # #265 capture-driven bisection — steep-slope response family
**Status: verdict reached, S1 and S2 both CLEARED for the two concrete **Status: FIX IMPLEMENTED 2026-07-30 (same day, §9 as-fixed addendum) —
mined events; real mechanism identified as a pre-existing (frozen-phase) closure pends the user's visual-gate acceptance.** S1 and S2 both CLEARED
architecture, not a Campaign P regression.** This is a research/bisection for the two concrete mined events; the real mechanism was a pre-existing
pass; no production code was changed. The harness (committed, (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`) `tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`)
and mining tool (`tools/analyze_265_steep_slope_capture.py`) are permanent; 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 the A/B code toggles described in §3 were applied and reverted locally
never committed. and never committed (they remain historical — the actual fix is
unrelated to S1/S2, see §9).
## 0. Scope recap ## 0. Scope recap
@ -435,3 +442,241 @@ explicitly NOT an S1/S2 code change — it is new work against
`PlayerMovementController.cs`'s grounded-movement block and `PlayerMovementController.cs`'s grounded-movement block and
`PhysicsBody.calc_friction`'s wiring, gated on a design conversation, not a `PhysicsBody.calc_friction`'s wiring, gated on a design conversation, not a
revert. 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.