From 7fcc7db1d181bad4e4d32157d46322d068908c93 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Jul 2026 18:45:43 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 159 +++++++++-- .../retail-divergence-register.md | 2 +- .../research/2026-07-30-265-capture-bisect.md | 257 +++++++++++++++++- 3 files changed, 388 insertions(+), 30 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 585a0bf9..66154abc 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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) -**Status:** OPEN — the TS-4 removal is reverted (`2e27d066`+`a8a7d64b`); -re-test confirms expected. The underlying oracle work is re-opened. +**Status:** FIX IMPLEMENTED 2026-07-30 (this session) — closure pends the +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) -**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 when jumping INTO an uphill slope — retail does not; (b) house-roof slides no longer happen ("as I used to"); (c) occasionally stuck sliding on an edge — the historical wedge, live, refuting the oracle plan's "pure-vertical degenerate only" convergence claim. The fixture-gated -removal under-modeled real trajectories. DO-NOT-RETRY the removal -without a live-capture-driven rework of the oracle plan §7 analysis -(capture a real roof-slide + uphill-jump trajectory with -ACDREAM_CAPTURE_RESOLVE and replay them as the gate). Note: the -downhill-landing sled remains #166 (pre-existing, unfixed — the cos(10°) -constant is necessary but not sufficient; the Sledding state never -engages). +TS-4 removal under-modeled real trajectories, but a full capture-driven +bisect (`docs/research/2026-07-30-265-capture-bisect.md`) cleared BOTH +of the two named Campaign-P suspects for the two concrete mined freeze +events: + +- **S1** (`db2889af`, BSPQuery Path-6 `hasSphere1`) — reverting it locally + 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 < 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<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) -**Status:** OPEN (post-R6 polish — user: "we could polish later"); all -four register-predicted composite deviations (AD-25, AP-7, AD-55, TS-4) -are now landed as of 2026-07-30 — pending only a visual-matrix recheck +**Status:** FIX IMPLEMENTED 2026-07-30 (this session) — closure pends the +user's visual-gate acceptance. The visual-matrix recheck this note asked +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) **Filed:** 2026-07-03 (user observation during the R2-R4 visual pass) **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 — 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 visual matrix item 5 ("Downhill jump landing: sled glide + bounce") -against a fresh capture — if the glide/bounce still visibly mismatches -retail now that every register-predicted composite deviation is retired, -that capture, not a guess, is what should drive any further work here, -and it should go through cdb against live retail before any client-side -Sledding-state mechanism is written (recall: no client-side -`PhysicsState.Sledding` auto-toggle exists in retail per the reattribution -above — a data-authored toggle exists only server-side). +against a fresh capture of THIS fix — if the glide/bounce still visibly +mismatches retail, that capture, not a guess, is what should drive any +further work here, and it should go through cdb against live retail +before any client-side Sledding-state mechanism is written (recall: no +client-side `PhysicsState.Sledding` auto-toggle exists in retail per the +reattribution above — a data-authored toggle exists only server-side). **Where:** `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs` (remote reflect, AD-25 — DONE 2026-07-30), `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`, AP-7 and AD-55 — 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 retail; flat-ground landings unchanged; no micro-bounce death spiral (the reason AD-25 existed) reintroduced. Every code-side composite -deviation is now landed; only the visual-matrix recheck remains before -this issue can close. +deviation, including the fifth one found this session, is now landed; +only the visual-matrix recheck remains before this issue can close. ## #164 — UM action-replay dispatches drop the per-action Autonomous bit diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index aee27523..44793f2e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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-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-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-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80–350 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) | diff --git a/docs/research/2026-07-30-265-capture-bisect.md b/docs/research/2026-07-30-265-capture-bisect.md index 819e7a1a..86a45038 100644 --- a/docs/research/2026-07-30-265-capture-bisect.md +++ b/docs/research/2026-07-30-265-capture-bisect.md @@ -1,13 +1,20 @@ # #265 capture-driven bisection — steep-slope response family -**Status: verdict reached, S1 and S2 both CLEARED for the two concrete -mined events; real mechanism identified as a pre-existing (frozen-phase) -architecture, not a Campaign P regression.** This is a research/bisection -pass; no production code was changed. The harness (committed, +**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 below were applied and reverted locally and -never committed. +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 @@ -435,3 +442,241 @@ 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.