diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index b595dc1c..f845dff9 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -236,12 +236,12 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~AP-127~~ | **RETIRED 2026-07-31 (#268).** `PlayerSkillMath` now owns retail `CACQualities::InqSkill` ordering for both panel values and Runtime run/jump prediction: intrinsic + positive 0x16D all-skills + the exact +10 category switch, then `EnchantSkill`, then 0x146 Jack of All Trades +5 and specialized-only `2 × 0x158`. Live player PropertyInt changes refresh the immutable Runtime augmentation snapshot. The separately described current-stamina local-copy nuance was re-audited: the query reads current stamina, but ordinary max-vital buffs target the max-secondary key and do not create stamina when current is zero; no independently observable residual remains. | `src/AcDream.Core/Player/PlayerSkillMath.cs`; `src/AcDream.Core/Player/LocalPlayerState.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs`; `src/AcDream.Runtime/Session/LiveSessionEventRouter.cs` | — | — | `CACQualities::InqSkill @ 0x00592660`; `CACQualities::InqRunRate @ 0x00592800`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0` |
| AP-128 | **PK-timer jump-cost clock basis unconfirmed** (filed at the P3 Opus review, 2026-07-30): `PlayerWeenie.JumpStaminaCost` evaluates retail's 20-second PK-recency window (`LastPkAttackTimestamp` PropertyFloat 0x91 + 20.0 >= now) against `Environment.TickCount64` process-uptime seconds. The magnitude argument is sound (a 32-bit float cannot hold a Unix epoch with sub-second precision — a conformance test caught the ±128 s swallow), but the wire timestamp's own basis is the SERVER's, so a cross-base compare is latent. INERT today: ACE models neither property, so `_lastPkAttackTimestamp` is never pushed and the branch never fires. | `src/AcDream.Core/Physics/PlayerWeenie.cs` (`JumpStaminaCost` remarks) | Branch unreachable against every ACE-family server; non-PK cost is bit-identical to pre-P3. The basis question is cdb-answerable (`Timer::cur_time` epoch) if a PK server is ever targeted. | Against a hypothetical server that sends PropertyFloat 0x91, the PK cost bump fires arbitrarily (always/never) instead of on the 20-second window. | `CACQualities::JumpStaminaCost 0x00591b90` pc 412934-412968; `Timer::cur_time`; stat-coupled pseudocode doc §12b |
-## 4. Temporary stopgap (TS) — 34 active rows (TS-8 retired 2026-07-31 — live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately; Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-4/TS-5/TS-23/TS-46 retired by ports — ZERO goal-enumerated physics stopgaps remain; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port, see the AD-53/AD-54 rows for the two compensating branches it left registered; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
+## 4. Temporary stopgap (TS) — 34 active rows (TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| ~~TS-1~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the row was stale, not the code.** The cited `:1254` line is unrelated stepping-loop code; the file moved substantially since the row was written. Retail's `EdgeSlide → PrecipiceSlide / CliffSlide` chain is already a real, tested port: `SpherePath.PrecipiceSlide` (`TransitionTypes.cs:943-970`, retail `SPHEREPATH::precipice_slide` pc:274316), `Transition.CliffSlide` (`:2080-2164`, retail `CTransition::cliff_slide` pc:272397, return-value mapping verified against `acclient.h:6100-6108`), and `Transition.EdgeSlideAfterStepDownFailed` (`:1907-2078`, mirrors `CTransition::edge_slide` pc:273001-273090). The one real gap (back-probe fallback skipping retail's `walkable_check_pos`/`localspace_sphere` recache, pc:274318-274326) needed no code change: acdream's `WalkableVertices`/`GlobalSphere` are populated in unified world space at assignment time (`SetWalkable`/`SetWalkableTransformed`, `SetCheckPos`/`RestoreCheckPos`), so both operands `BSPQuery.FindCrossedEdge` compares are already commensurable — retail's per-cell local-frame reprojection is a no-op correction here. Documented in-code at the back-probe site and pinned by `EdgeSlideBackProbePrecipiceSlideTests`. The chain's two acdream-only compensating branches (CliffSlide's three-source reference-normal fallback; the walkable-steepness reroute to CliffSlide before PrecipiceSlide) are real, non-retail additions — filed as AD-53 / AD-54 rather than folded into this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SpherePath.PrecipiceSlide`, `Transition.CliffSlide`, `Transition.EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/EdgeSlideBackProbePrecipiceSlideTests.cs` | — | — | `SPHEREPATH::precipice_slide` pc:274316 (0050cc80); `CTransition::cliff_slide` pc:272397 (0050a6d0); `CTransition::edge_slide` pc:273001-273090 (0050b3d0); `SPHEREPATH::get_walkable_pos`/`cache_localspace_sphere`/`set_walkable_check_pos` pc:274318-274326 (0050a8f0/0050c9d0/00509ce0); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2, §6 Step 1 |
-| TS-4 | **RE-OPENED 2026-07-30 after the matrix live gate**: the fixture-gated removal shipped and the user immediately hit the wedge live ("stuck sliding on an edge") plus a non-retail uphill-jump bounce and lost roof slides — the horizontal-velocity convergence claim under-modeled real trajectories. Removal reverted; the oracle plan §7 degenerate analysis needs live-capture-driven rework before any retry. Original row: Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
+| ~~TS-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 2B).** The graph and prepared-flat Path-6 implementations now match retail's exact two-sphere split: every primary/foot polygon hit calls `SetCollide`, sets `WalkableAllowance=LandingZ`, and returns `Adjusted`; only a secondary/head hit writes `CollisionNormal` and returns `Collided`. The steep tangent shortcut and both BSP-layer `SetSlidingNormal` writes are deleted. Exact site tests pin all changed and preserved fields plus raw-bit graph/flat parity. Production-shaped multi-frame vertical/inward/tangential/uphill/downhill roof/wall/ledge traces carry accepted body state between frames, match graph/flat by raw result/body bits, reject penetration and uphill launch/bounce, and pass the complete historical collision matrix without compensation. | `src/AcDream.Core/Physics/BSPQuery.cs`; `src/AcDream.Core/Physics/FlatBspQuery.cs`; `tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs` | — | — | `BSPTREE::find_collisions` 0x0053A440: head `0x0053A793..0x0053A7A4`, foot `0x0053A7B3..0x0053A7DC`; research §10 |
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
| ~~TS-8~~ | **RETIRED 2026-07-31 (#268 stat-chain closeout).** `EnchantmentWireReader` parses the complete 0x02C2 payload and `GameEventWiring` publishes its StatMod type/key/value and bucket through the same `ActiveEnchantmentRecord` used at login. An end-to-end dispatch test proves a mid-session skill modifier changes `LocalPlayerState.GetEffectiveSkill` immediately. | `src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` | — | — | `CEnchantmentRegistry::EnchantAttribute @ 0x00594570`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0`; holtburger `messages/magic/types.rs` |
diff --git a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md
index fe44a7d1..cbd10b33 100644
--- a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md
+++ b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md
@@ -1,14 +1,17 @@
# P2 — Collision response-layer edge family: port-ready pseudocode
-**Status: RESEARCH PASS COMPLETE (2026-07-30); RETAIL RESPONSE ORDER PORTED
-(2026-07-31).** Originally a research-only doc for Campaign P Slice P2
-(`docs/plans/2026-07-29-physics-parity-campaign.md` §P2); a same-day
-implementation session landed TS-1's retirement and AP-7's fix, attempted
-TS-4 per this doc's own §6 Step 3 fixture-first order, reproduced the
-historical wedge, and stopped — see §7 item 6 for the full capture and
-root-cause diagnosis. TS-4 is NOT retired; its shortcut stays in place.
+**Status: RETAIL RESPONSE ORDER COMPLETE (2026-07-31).** Originally a
+research-only doc for Campaign P Slice P2
+(`docs/plans/2026-07-29-physics-parity-campaign.md` §P2). The first TS-4
+attempt reproduced a steep-roof fixed point because its fixture discarded
+the accepted contact state between frames; §7 item 6 preserves that useful
+failure analysis. Slice 1B then completed the exact nested edge/StepDown
+dispatcher, Slice 2A restored StepDown's mandatory Placement tail, and Slice
+2B repeated the removal with production-shaped state carry and the complete
+direction matrix. TS-4 is now retired; see §10.
#166 got a reattribution note in ISSUES.md rather than new code (per §3).
-#116 remains untouched (oracle-first, out of implementation scope).
+#116's former skipped wall control is now active with the retail Path-6
+first-frame hard stop and next-frame slide chronology.
**Headline findings that change the plan's assumptions:** TS-1 was already
substantially ported (the register row and plan phrasing were stale — see
§2); the one real gap needed no code change (acdream's unified world-space
@@ -17,17 +20,11 @@ substantially ported (the register row and plan phrasing were stale — see
NOT about a literal `PhysicsState.Sledding` auto-toggle at all (see §3);
AP-7's L.3c regression does not reproduce on the production graphical
root-motion path post-R6, and now ports retail's confirmed 0.25f threshold
-(see §1); TS-4's shortcut removal is coupled to TS-1's completion and
-reproduces a wedge even after TS-1 lands — see the §7 item 6 update for the
-precise mechanism (Phase 3 of `TransitionalInsert` is structurally
-unreachable from Path 6's unconditional `SetCollide`, which returns
-`Adjusted` without repositioning the sphere) and what a future attempt
-needs to check first; #116 remains a genuine oracle-first research item
-needing live cdb/Ghidra, not an implementation item (see §5). Read §6 (port
-order) before starting further implementation on this family. Campaign P
-Slice 1B subsequently performed that fresh `transitional_insert` read and
-removed AP-3, AP-4, AD-53, and AD-54; the exact closeout and controls are in
-§8. TS-4 remains deliberately unchanged.
+(see §1). The apparent TS-4 wedge was a harness-state defect, not a reason to
+retain a product compensation: production-shaped vertical, inward,
+tangential, uphill, and downhill histories now pass graph/flat raw-bit
+parity without the shortcut. Campaign P Slice 1B removed AP-3, AP-4, AD-53,
+and AD-54 (§8); Slice 2A retired AP-5 (§9); Slice 2B retired TS-4 (§10).
Every claim below is tagged **FACT** (grep/read-verified against the
named-retail decomp, the register, ISSUES.md, or current acdream source
@@ -599,12 +596,13 @@ manufacture a caller that sets it.
## 4. TS-4 — Path-6 steep-poly shortcut removal
-### The current shortcut (FACT, `src/AcDream.Core/Physics/BSPQuery.cs:2149-2266`)
+### The former shortcut (HISTORICAL FACT; removed by Slice 2B)
-Path-6 (the default `sphere_intersects_poly → collide_with_pt / SetCollide`
-dispatch) tests each hit polygon's world-space normal. For BOTH sphere0
-(feet) and sphere1 (head), if `worldNormal.Z < PhysicsGlobals.FloorZ`
-(steeper than ~49° from horizontal), acdream takes a SPECIAL BRANCH:
+Before Slice 2B, Path-6 (the default
+`sphere_intersects_poly → collide_with_pt / SetCollide` dispatch) tested each
+hit polygon's world-space normal. For BOTH sphere0 (feet) and sphere1 (head),
+if `worldNormal.Z < PhysicsGlobals.FloorZ` (steeper than ~49° from
+horizontal), acdream took a SPECIAL BRANCH:
projects the move along the steep face, writes
`collisions.SetCollisionNormal(worldNormal)` **and**
`collisions.SetSlidingNormal(worldNormal)`, and returns
@@ -653,7 +651,7 @@ and — when that surface turns out too steep to be walkable —
`EdgeSlideAfterStepDownFailed` → `CliffSlide`/`PrecipiceSlide` (TS-1's
domain, §2 above).
-### TS-4 port shape (FACT-grounded, mechanically simple)
+### TS-4 port shape (COMPLETED 2026-07-31)
Delete both `if (worldNormal{0,1}.Z < PhysicsGlobals.FloorZ) { ... return
TransitionState.Slid; }` blocks (`BSPQuery.cs:2200-2215` and
@@ -666,7 +664,7 @@ retires both `SetSlidingNormal` write sites (satisfying DO-NOT-RETRY §0
item 1 permanently — deleted, not just avoided) with no replacement logic
needed at this layer.
-### ⚠️ Port-order coupling with TS-1 (INFERENCE, but directly evidenced by the shortcut's own commit history)
+### Port-order coupling with TS-1 (historical guard, now satisfied)
**This is the single most important sequencing fact in this whole
document.** The shortcut's comment proves TS-1's retail-faithful chain
@@ -1182,3 +1180,42 @@ The #273, #271, #185, StepUp, transition-retry, and TS-4 controls remain
unchanged. AP-5 is retired; TS-4 is intentionally untouched.
---
+
+## 10. Campaign P Slice 2B closeout — TS-4 retired (2026-07-31)
+
+A fresh read of `BSPTREE::find_collisions` (`0x0053A440`) confirms the exact
+Path-6 split. A primary/foot-sphere polygon hit transforms the polygon normal,
+calls `SPHEREPATH::set_collide`, writes `LandingZ`, and returns `ADJUSTED_TS`
+(`0x0053A7B3..0x0053A7DC`) regardless of steepness. Only when that sphere is
+clear does a secondary/head-sphere hit write `collision_normal` and return
+`COLLIDED_TS` (`0x0053A793..0x0053A7A4`). Neither branch writes
+`sliding_normal`.
+
+Both parsed-graph and prepared-flat Path-6 implementations now follow that
+split exactly. The steepness branches, in-place tangent projection, and BSP-
+layer `SetSlidingNormal` writes are deleted. Exact site tests compare the two
+representations by raw bits and pin every mutated and preserved field:
+
+- foot: `SetCollide`, candidate backup, transformed `StepUpNormal`,
+ `WalkInterp=1`, `WalkableAllowance=LandingZ`, `Adjusted`;
+- head: `CollisionNormal`, `Collided`, with no `SetCollide` state mutation;
+- both: a pre-existing sliding normal is preserved byte-for-byte.
+
+The failed first removal was a test-harness lesson, not a retail exception.
+Its gravity-only replay always passed `isOnGround:false` and discarded each
+accepted result's Contact/OnWalkable bits, making the nested edge/StepDown
+chain impossible to exercise on the next frame. The replacement replay uses
+the same retained `PhysicsBody` chronology as production. Parsed graph and
+prepared flat now match by raw result/body bits for vertical roof descent,
+downhill input, uphill pressure (including a no-launch/no-bounce assertion),
+tangential roof travel, inward-plus-tangent wall travel, and flat-roof ledge
+rejection. Every trace asserts finite bounded motion and signed-plane non-
+penetration. The former #116 D4 control is active: its primary-sphere hit
+hard-stops frame one through SetCollide, then the accepted persistent normal
+permits the downward slide on frame two.
+
+The complete historical #273/#271/#269/#265/#185/#137/#116/cellar/roof
+matrix and the full Core Release suite pass without a replacement
+compensation. TS-4 is retired.
+
+---
diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs
index 2d08eb6e..ee73ebaf 100644
--- a/src/AcDream.Core/Physics/BSPQuery.cs
+++ b/src/AcDream.Core/Physics/BSPQuery.cs
@@ -2213,59 +2213,10 @@ public static class BSPQuery
}
var worldNormal0 = L2W(hitPoly0!.Plane.Normal);
-
- // L.4 slide-tangent for steep airborne hits (2026-04-30).
- //
- // For polygons too steep to walk on (worldNormal.Z < FloorZ),
- // skip the SetCollide → Path-4 → ContactPlane landing chain.
- // That chain commits the body to the steep surface, leading
- // to the "stuck in falling animation on the roof" bug — once
- // grounded with a steep ContactPlane, our step_up_slide /
- // cliff_slide / edge_slide chain can't produce smooth
- // descent and the body wedges or "falls a bit at a time"
- // when bumped.
- //
- // Instead: project the move along the steep face (remove
- // the into-wall displacement), set CollisionNormal +
- // SlidingNormal, return Slid. Same shape as Path 5's
- // step-up fallback (line 1545-1547) and CylinderCollision
- // (TransitionTypes.cs:1518-1522). Position is updated in-
- // place; on the next resolver iteration the sphere is
- // outside the poly, FindCollisions returns OK, and
- // ValidateTransition commits the new position. Body stays
- // airborne, falling animation continues, and gravity's
- // tangent component drifts the body downhill until it
- // slides off the slope's edge.
- //
- // This is a deliberate deviation from retail (retail uses
- // SetCollide unconditionally and lets find_walkable +
- // step_up_slide produce the slide). Validated against
- // retail debugger trace 2026-04-30: retail body did not
- // wedge; our retail-faithful port DID wedge because we're
- // missing implementation details of the step_up_slide /
- // cliff_slide chain on grounded-steep movement. The
- // slide-tangent here produces user-acceptable behavior
- // (slides off naturally) while the deeper chain port is
- // researched. Filed as L.5+ followup for retail-strict.
- if (worldNormal0.Z < PhysicsGlobals.FloorZ)
- {
- Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
- Vector3 endWorld = path.GlobalSphere[0].Origin;
- Vector3 gDelta = endWorld - currWorld;
- float diff = Vector3.Dot(worldNormal0, gDelta);
- if (diff < 0f)
- path.AddOffsetToCheckPos(-worldNormal0 * diff);
-
- collisions.SetCollisionNormal(worldNormal0);
- collisions.SetSlidingNormal(worldNormal0);
- // L.2d slice 1 (2026-05-13): diagnostic side-channel.
- if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
- PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
- return TransitionState.Slid;
- }
-
- // ─── SetCollide response (shallow / walkable) ───────────
- // Per retail (acclient_2013_pseudo_c.txt:323783-323821).
+ // Retail Path 6 always defers a primary/foot-sphere hit to
+ // the outer Collide handler, regardless of polygon steepness.
+ // The BSP layer neither projects a tangent nor writes the
+ // persistent sliding normal (0x0053A7B3-0x0053A7DC).
path.SetCollide(worldNormal0);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
diff --git a/src/AcDream.Core/Physics/FlatBspQuery.cs b/src/AcDream.Core/Physics/FlatBspQuery.cs
index dd39e821..658c50b5 100644
--- a/src/AcDream.Core/Physics/FlatBspQuery.cs
+++ b/src/AcDream.Core/Physics/FlatBspQuery.cs
@@ -2036,23 +2036,6 @@ internal static class FlatBspQuery
Vector3 worldNormal0 = LocalToWorld(
tree.PolygonTable.Polygons[defaultHitPolygonIndex0].Plane.Normal);
- if (worldNormal0.Z < PhysicsGlobals.FloorZ)
- {
- Vector3 currentWorld =
- path.GlobalCurrCenter[0].Origin;
- Vector3 endWorld =
- path.GlobalSphere[0].Origin;
- Vector3 globalDelta = endWorld - currentWorld;
- float difference = Vector3.Dot(worldNormal0, globalDelta);
- if (difference < 0f)
- path.AddOffsetToCheckPos(-worldNormal0 * difference);
-
- collisions.SetCollisionNormal(worldNormal0);
- collisions.SetSlidingNormal(worldNormal0);
- RecordDiagnosticHit(tree, defaultHitPolygonIndex0);
- return TransitionState.Slid;
- }
-
path.SetCollide(worldNormal0);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
RecordDiagnosticHit(tree, defaultHitPolygonIndex0);
@@ -2075,28 +2058,9 @@ internal static class FlatBspQuery
{
Vector3 worldNormal1 = LocalToWorld(
tree.PolygonTable.Polygons[defaultHitPolygonIndex1].Plane.Normal);
- if (worldNormal1.Z < PhysicsGlobals.FloorZ)
- {
- Vector3 currentWorld =
- path.GlobalCurrCenter[0].Origin;
- Vector3 endWorld =
- path.GlobalSphere[0].Origin;
- Vector3 globalDelta = endWorld - currentWorld;
- float difference =
- Vector3.Dot(worldNormal1, globalDelta);
- if (difference < 0f)
- path.AddOffsetToCheckPos(-worldNormal1 * difference);
-
- collisions.SetCollisionNormal(worldNormal1);
- collisions.SetSlidingNormal(worldNormal1);
- RecordDiagnosticHit(tree, defaultHitPolygonIndex1);
- return TransitionState.Slid;
- }
-
- path.SetCollide(worldNormal1);
- path.WalkableAllowance = PhysicsGlobals.LandingZ;
+ collisions.SetCollisionNormal(worldNormal1);
RecordDiagnosticHit(tree, defaultHitPolygonIndex1);
- return TransitionState.Adjusted;
+ return TransitionState.Collided;
}
}
diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs
index ce837e81..c0655a62 100644
--- a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs
@@ -396,18 +396,12 @@ public class BSPStepUpTests
///
/// Airborne mover descending toward a steep slope (normal.Z < FloorZ):
- /// Path 6 returns and does NOT set
- /// the Collide flag — the steep-normal slide-tangent branch (L.4,
- /// commit b1af56e, 2026-04-30) intercepts the hit before SetCollide is
- /// called and projects the move along the steep face instead, keeping the
- /// body airborne with the falling animation.
- ///
- /// This is a documented intentional deviation from retail (retail calls
- /// set_collide unconditionally; our interim port uses slide-tangent while
- /// the retail step_up_slide / cliff_slide chain port is completed).
+ /// retail Path 6 still calls SetCollide, installs LandingZ, and returns
+ /// Adjusted. Polygon steepness is handled by the outer transition chain,
+ /// not by a BSP-layer tangent shortcut.
///
[Fact]
- public void C3_Path6_AirborneMoverHitsSteepSlope_ReturnsSlid()
+ public void C3_Path6_AirborneMoverHitsSteepSlope_DefersThroughSetCollide()
{
var (root, resolved) = BSPStepUpFixtures.SlopedUnwalkable();
@@ -427,13 +421,11 @@ public class BSPStepUpTests
root, resolved, t, localSphere, null,
currPos, Vector3.UnitZ, 1.0f);
- // L.4 slide-tangent (b1af56e, 2026-04-30): steep polygon hit by
- // airborne sphere returns Slid (not Adjusted) and does NOT set
- // the Collide flag — the into-wall displacement is removed and
- // CollisionNormal/SlidingNormal are set instead.
- Assert.Equal(TransitionState.Slid, result);
- Assert.False(t.SpherePath.Collide,
- "Collide must NOT be set when the L.4 steep-slope slide-tangent fires");
+ Assert.Equal(TransitionState.Adjusted, result);
+ Assert.True(t.SpherePath.Collide);
+ Assert.Equal(PhysicsGlobals.LandingZ, t.SpherePath.WalkableAllowance);
+ Assert.False(t.CollisionInfo.CollisionNormalValid);
+ Assert.False(t.CollisionInfo.SlidingNormalValid);
}
// =========================================================================
@@ -594,17 +586,7 @@ public class BSPStepUpTests
/// every frame replays the same hard stop and the character hangs in falling
/// animation until another correction breaks the loop.
///
- [Fact(Skip = "Issue #116 shape-2 — the engine slides IN-FRAME to Z=1.92 " +
- "on the first airborne wall frame; this pin expects an L.2c hard stop " +
- "at Z=2.0. Ghidra (2026-06-12) confirms retail CSphere::slide_sphere " +
- "(0x00537440) applies the slide IN-FRAME (add_offset_to_check_pos → " +
- "SLID_TS), so our 1.92 is faithful TO slide_sphere and the Z=2.0 " +
- "expectation is the SUSPECT half — but whether retail's first " +
- "airborne frame REACHES slide_sphere (→1.92) or hard-stops upstream " +
- "(collide_with_environment dispatch / no last-known plane) needs a " +
- "cdb trace of an airborne wall hit before flipping the assertion. The " +
- "#116 threshold fix (EpsilonSq→F_EPSILON) did NOT change this — the D4 " +
- "offset is a real slide, not degenerate. See docs/ISSUES.md #116.")]
+ [Fact]
public void D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
@@ -630,6 +612,8 @@ public class BSPStepUpTests
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
"First airborne wall hit should cache SlidingNormal for the next frame.");
+ // Path 6's primary-sphere SetCollide hard-stops this first frame; the
+ // persisted normal then permits the downward tangent on frame two.
Assert.Equal(2.0f, frame1.Position.Z, precision: 3);
var frame2 = engine.ResolveWithTransition(
diff --git a/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs b/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs
index 410afc3e..89946344 100644
--- a/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs
@@ -231,16 +231,18 @@ public sealed class RetailEdgeResponseOrderingTests
}
[Fact]
- public void MultiFrameSteepRoof_GraphAndFlatTraversalRemainExactAndDoNotWedge()
+ public void MultiFrameSteepRoof_PureVertical_GraphAndFlatSlideDownhillWithoutWedge()
{
- TraceRun graph = RunSteepRoofTrace(preparedFlat: false);
- TraceRun flat = RunSteepRoofTrace(preparedFlat: true);
+ TraceRun graph = RunSteepRoofTrace(preparedFlat: false, Vector2.Zero);
+ TraceRun flat = RunSteepRoofTrace(preparedFlat: true, Vector2.Zero);
AssertTraceParity(graph, flat);
- Assert.Contains(graph.Frames, frame =>
- frame.Result.Position.X < 0f
- && frame.Result.Position.Z <= BSPStepUpFixtures.SphereRadius + 0.05f);
+ Assert.Contains(graph.Frames, frame => frame.Result.InContact);
AssertNoLongFrozenStreak(graph.Frames, maximumTicks: 15);
+ Assert.True(graph.Frames[^1].Result.Position.X
+ < graph.Frames[0].Result.Position.X - 0.20f,
+ $"The vertical trace did not descend the roof: " +
+ $"{graph.Frames[0].Result.Position} -> {graph.Frames[^1].Result.Position}.");
Plane slope = BSPStepUpFixtures.SlopedUnwalkable().Resolved[
BSPStepUpFixtures.SlopedUnwalkable_SlopeId].Plane;
@@ -272,6 +274,69 @@ public sealed class RetailEdgeResponseOrderingTests
}
}
+ [Theory]
+ [InlineData(-0.30f, 0f, "downhill")]
+ [InlineData( 0.30f, 0f, "uphill")]
+ [InlineData( 0f, 0.30f, "tangential")]
+ public void MultiFrameSteepRoof_DirectionalMotion_RemainsExactAndPreservesRetailResponse(
+ float velocityX,
+ float velocityY,
+ string direction)
+ {
+ Vector2 horizontalVelocity = new(velocityX, velocityY);
+ TraceRun graph = RunSteepRoofTrace(preparedFlat: false, horizontalVelocity);
+ TraceRun flat = RunSteepRoofTrace(preparedFlat: true, horizontalVelocity);
+
+ AssertTraceParity(graph, flat);
+ Assert.Contains(graph.Frames, frame => frame.Result.InContact);
+ AssertNoLongFrozenStreak(graph.Frames, maximumTicks: 15);
+
+ Vector3 first = graph.Frames[0].Result.Position;
+ Vector3 last = graph.Frames[^1].Result.Position;
+ Vector2 progress = new(last.X - first.X, last.Y - first.Y);
+ if (direction == "uphill")
+ {
+ int contactFrame = graph.Frames.FindIndex(frame => frame.Result.InContact);
+ Assert.True(contactFrame >= 0);
+ float peakAfterContact = graph.Frames
+ .GetRange(contactFrame, graph.Frames.Count - contactFrame)
+ .Max(frame => frame.Result.Position.Z);
+ Assert.True(peakAfterContact <= graph.Frames[contactFrame].Result.Position.Z + 0.001f,
+ $"The uphill trace launched/bounced from the roof: " +
+ $"contactZ={graph.Frames[contactFrame].Result.Position.Z}, peak={peakAfterContact}.");
+ }
+ else
+ {
+ Assert.True(Vector2.Dot(progress, Vector2.Normalize(horizontalVelocity)) > 0.10f,
+ $"The {direction} trace lost requested progress: {first} -> {last}.");
+ }
+
+ Plane slope = BSPStepUpFixtures.SlopedUnwalkable().Resolved[
+ BSPStepUpFixtures.SlopedUnwalkable_SlopeId].Plane;
+ float radius = BSPStepUpFixtures.SphereRadius;
+ for (int i = 0; i < graph.Frames.Count; i++)
+ {
+ Vector3 position = graph.Frames[i].Result.Position;
+ AssertFinite(position, $"steep-roof {direction} frame {i}");
+ if (i > 0)
+ {
+ float distance = Vector3.Distance(
+ graph.Frames[i - 1].Result.Position,
+ position);
+ Assert.InRange(distance, 0f, 1.1f);
+ }
+
+ if (position.X is >= 0f and <= 1f && MathF.Abs(position.Y) <= 1f)
+ {
+ Vector3 footCenter = position + new Vector3(0f, 0f, radius);
+ float signedDistance = Vector3.Dot(slope.Normal, footCenter) + slope.D;
+ Assert.True(signedDistance >= radius - 0.015f,
+ $"Steep-roof {direction} penetration at frame {i}: " +
+ $"distance={signedDistance}, radius={radius}, position={position}.");
+ }
+ }
+ }
+
[Fact]
public void MultiFrameFlatRoofLedge_GraphAndFlatTraversalRemainExactAndSlideAlongEdge()
{
@@ -325,7 +390,7 @@ public sealed class RetailEdgeResponseOrderingTests
[Theory]
[InlineData(false)]
[InlineData(true)]
- public void MultiFrameGroundedFloorWallSlide_FinalPlacementIsMandatoryAndGraphFlatExact(
+ public void MultiFrameGroundedFloorWallSlide_InwardTangentialMotionIsGraphFlatExact(
bool twoSpheres)
{
WallMaintenanceTrace graph = RunGroundedFloorWallSlide(
@@ -391,14 +456,22 @@ public sealed class RetailEdgeResponseOrderingTests
new(-2f, 2f, 0f),
];
- private static TraceRun RunSteepRoofTrace(bool preparedFlat)
+ private static TraceRun RunSteepRoofTrace(
+ bool preparedFlat,
+ Vector2 horizontalVelocity)
{
var fixture = BSPStepUpFixtures.SlopedUnwalkable();
PhysicsEngine engine = BuildCollisionEngine(fixture, preparedFlat, 0x0100E101u);
float radius = BSPStepUpFixtures.SphereRadius;
const float dt = 1f / 30f;
const float gravity = -9.8f;
- var body = new PhysicsBody { TransientState = TransientStateFlags.Active };
+ var body = new PhysicsBody
+ {
+ Position = new Vector3(0.5f, 0f, 3f),
+ Orientation = Quaternion.Identity,
+ State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
+ TransientState = TransientStateFlags.Active,
+ };
Vector3 position = new(0.5f, 0f, 3f);
float velocityZ = 0f;
var trace = new List(90);
@@ -406,15 +479,19 @@ public sealed class RetailEdgeResponseOrderingTests
for (int tick = 0; tick < 90; tick++)
{
velocityZ += gravity * dt;
+ body.Velocity = new Vector3(
+ horizontalVelocity.X,
+ horizontalVelocity.Y,
+ velocityZ);
ResolveResult result = engine.ResolveWithTransition(
position,
- position + new Vector3(0f, 0f, velocityZ * dt),
+ position + body.Velocity * dt,
Cell,
radius,
radius * 2f,
stepUpHeight: 0.30f,
stepDownHeight: 0.04f,
- isOnGround: false,
+ isOnGround: body.OnWalkable,
body,
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0x01000000u);
@@ -422,7 +499,13 @@ public sealed class RetailEdgeResponseOrderingTests
position = result.Position;
body.Position = position;
if (result.IsOnGround)
+ {
velocityZ = 0f;
+ body.Velocity = new Vector3(
+ horizontalVelocity.X,
+ horizontalVelocity.Y,
+ 0f);
+ }
ApplyContactResult(body, result);
trace.Add(CaptureFrame(result, body));
diff --git a/tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs b/tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs
new file mode 100644
index 00000000..ae5e1af5
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs
@@ -0,0 +1,321 @@
+using System.Collections.Generic;
+using System.Numerics;
+using System.Text;
+using AcDream.Core.Physics;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Exact site tests for retail BSPTREE::find_collisions Path 6
+/// (0x0053A793..0x0053A7DC). Primary-sphere hits defer through
+/// SetCollide; secondary-only hits hard-stop. Neither BSP branch owns the
+/// persistent sliding normal.
+///
+public sealed class Ts4Path6ConformanceTests
+{
+ private const uint Cell = 0xA9B40001u;
+ private const float Radius = BSPStepUpFixtures.SphereRadius;
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void PrimarySteepHit_GraphAndFlat_SetCollideWithExactState(
+ bool seedSlidingNormal)
+ {
+ var fixture = Normalize(BSPStepUpFixtures.SlopedUnwalkable());
+ FlatPhysicsBsp flat = FlatCollisionAssetBuilder.FlattenPhysicsBsp(
+ fixture.Root,
+ fixture.Resolved);
+ Vector3 currentBody = new(0.5f, 0f, 1.1f);
+ Vector3 targetBody = new(0.5f, 0f, 0.9f);
+ Vector3 targetCenter = targetBody + new Vector3(0f, 0f, Radius);
+ Vector3 expectedNormal = fixture.Resolved[
+ BSPStepUpFixtures.SlopedUnwalkable_SlopeId].Plane.Normal;
+ Vector3 seededSliding = Vector3.UnitY;
+
+ SiteOutcome graph = Run(
+ fixture.Root,
+ fixture.Resolved,
+ flat: null,
+ currentBody,
+ targetBody,
+ new Sphere { Origin = targetCenter, Radius = Radius },
+ head: null,
+ seedSlidingNormal,
+ seededSliding);
+ SiteOutcome prepared = Run(
+ root: null,
+ fixture.Resolved,
+ flat,
+ currentBody,
+ targetBody,
+ new Sphere { Origin = targetCenter, Radius = Radius },
+ head: null,
+ seedSlidingNormal,
+ seededSliding);
+
+ Assert.Equal(graph.Bits, prepared.Bits);
+ Assert.Equal(TransitionState.Adjusted, graph.State);
+ Assert.True(graph.Collide);
+ AssertVectorBits(targetBody, graph.CheckPos);
+ Assert.Equal(Cell, graph.CheckCellId);
+ AssertVectorBits(targetBody, graph.BackupCheckPos);
+ Assert.Equal(Cell, graph.BackupCheckCellId);
+ AssertVectorBits(expectedNormal, graph.StepUpNormal);
+ AssertFloatBits(1f, graph.WalkInterp);
+ AssertFloatBits(PhysicsGlobals.LandingZ, graph.WalkableAllowance);
+ Assert.False(graph.CollisionNormalValid);
+ Assert.Equal(seedSlidingNormal, graph.SlidingNormalValid);
+ AssertVectorBits(
+ seedSlidingNormal ? seededSliding : Vector3.Zero,
+ graph.SlidingNormal);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void SecondaryOnlyHit_GraphAndFlat_HardStopsWithoutSetCollide(
+ bool seedSlidingNormal)
+ {
+ (PhysicsBSPNode root, Dictionary resolved) =
+ BuildRaisedWall();
+ FlatPhysicsBsp flat = FlatCollisionAssetBuilder.FlattenPhysicsBsp(
+ root,
+ resolved);
+ Vector3 currentBody = new(0.1f, 0f, 0f);
+ Vector3 targetBody = new(0.35f, 0f, 0f);
+ var foot = new Sphere
+ {
+ Origin = targetBody + new Vector3(0f, 0f, Radius),
+ Radius = Radius,
+ };
+ var head = new Sphere
+ {
+ Origin = targetBody + new Vector3(0f, 0f, 0.8f),
+ Radius = Radius,
+ };
+ Vector3 seededSliding = Vector3.UnitY;
+
+ SiteOutcome graph = Run(
+ root,
+ resolved,
+ flat: null,
+ currentBody,
+ targetBody,
+ foot,
+ head,
+ seedSlidingNormal,
+ seededSliding);
+ SiteOutcome prepared = Run(
+ root: null,
+ resolved,
+ flat,
+ currentBody,
+ targetBody,
+ foot,
+ head,
+ seedSlidingNormal,
+ seededSliding);
+
+ Assert.Equal(graph.Bits, prepared.Bits);
+ Assert.Equal(TransitionState.Collided, graph.State);
+ Assert.False(graph.Collide);
+ AssertVectorBits(targetBody, graph.CheckPos);
+ Assert.Equal(Cell, graph.CheckCellId);
+ AssertVectorBits(new Vector3(91f, 92f, 93f), graph.BackupCheckPos);
+ Assert.Equal(0xA9B40077u, graph.BackupCheckCellId);
+ AssertVectorBits(Vector3.Zero, graph.StepUpNormal);
+ AssertFloatBits(0.625f, graph.WalkInterp);
+ AssertFloatBits(0.8125f, graph.WalkableAllowance);
+ Assert.True(graph.CollisionNormalValid);
+ AssertFloatBits(-1f, graph.CollisionNormal.X);
+ Assert.Equal(0f, graph.CollisionNormal.Y);
+ Assert.Equal(0f, graph.CollisionNormal.Z);
+ Assert.Equal(seedSlidingNormal, graph.SlidingNormalValid);
+ AssertVectorBits(
+ seedSlidingNormal ? seededSliding : Vector3.Zero,
+ graph.SlidingNormal);
+ }
+
+ private static SiteOutcome Run(
+ PhysicsBSPNode? root,
+ Dictionary resolved,
+ FlatPhysicsBsp? flat,
+ Vector3 currentBody,
+ Vector3 targetBody,
+ Sphere foot,
+ Sphere? head,
+ bool seedSlidingNormal,
+ Vector3 seededSliding)
+ {
+ var transition = new Transition();
+ transition.SpherePath.InitPath(
+ currentBody,
+ targetBody,
+ Cell,
+ Radius,
+ sphereHeight: head is null ? 0f : 1f);
+ transition.SpherePath.SetCheckPos(targetBody, Cell);
+ transition.SpherePath.BackupCheckPos = new Vector3(91f, 92f, 93f);
+ transition.SpherePath.BackupCheckCellId = 0xA9B40077u;
+ transition.SpherePath.WalkInterp = 0.625f;
+ transition.SpherePath.WalkableAllowance = 0.8125f;
+ if (seedSlidingNormal)
+ transition.CollisionInfo.SetSlidingNormal(seededSliding);
+
+ TransitionState state = flat is null
+ ? BSPQuery.FindCollisions(
+ root,
+ resolved,
+ transition,
+ foot,
+ head,
+ currentBody,
+ Vector3.UnitZ,
+ 1f)
+ : FlatBspQuery.FindCollisions(
+ flat,
+ transition,
+ foot,
+ head,
+ currentBody,
+ Vector3.UnitZ,
+ 1f);
+
+ SpherePath path = transition.SpherePath;
+ CollisionInfo collision = transition.CollisionInfo;
+ return new SiteOutcome(
+ state,
+ path.Collide,
+ path.CheckPos,
+ path.CheckCellId,
+ path.BackupCheckPos,
+ path.BackupCheckCellId,
+ path.StepUpNormal,
+ path.WalkInterp,
+ path.WalkableAllowance,
+ collision.CollisionNormalValid,
+ collision.CollisionNormal,
+ collision.SlidingNormalValid,
+ collision.SlidingNormal,
+ Signature(state, path, collision));
+ }
+
+ private static (
+ PhysicsBSPNode Root,
+ Dictionary Resolved) BuildRaisedWall()
+ {
+ Vector3[] vertices =
+ [
+ new(0.5f, -1f, 0.55f),
+ new(0.5f, -1f, 2.5f),
+ new(0.5f, 1f, 2.5f),
+ new(0.5f, 1f, 0.55f),
+ ];
+ var root = new PhysicsBSPNode
+ {
+ Type = BSPNodeType.Leaf,
+ BoundingSphere = new Sphere
+ {
+ Origin = new Vector3(0.5f, 0f, 1.5f),
+ Radius = 4f,
+ },
+ };
+ root.Polygons.Add(1);
+ var resolved = new Dictionary
+ {
+ [1] = new ResolvedPolygon
+ {
+ Id = 1,
+ Vertices = vertices,
+ Plane = new Plane(-Vector3.UnitX, 0.5f),
+ NumPoints = vertices.Length,
+ SidesType = CullMode.None,
+ },
+ };
+ return (root, resolved);
+ }
+
+ private static (
+ PhysicsBSPNode Root,
+ Dictionary Resolved) Normalize(
+ (PhysicsBSPNode Root, Dictionary Resolved) fixture)
+ {
+ var resolved = new Dictionary(fixture.Resolved.Count);
+ foreach ((ushort id, ResolvedPolygon polygon) in fixture.Resolved)
+ {
+ resolved.Add(id, new ResolvedPolygon
+ {
+ Id = id,
+ Vertices = polygon.Vertices,
+ Plane = polygon.Plane,
+ NumPoints = polygon.NumPoints,
+ SidesType = polygon.SidesType,
+ });
+ }
+
+ return (fixture.Root, resolved);
+ }
+
+ private static string Signature(
+ TransitionState state,
+ SpherePath path,
+ CollisionInfo collision)
+ {
+ var bits = new StringBuilder(256);
+ bits.Append((int)state).Append('|').Append(path.Collide ? 1 : 0).Append('|');
+ Append(bits, path.CheckPos);
+ bits.Append(path.CheckCellId.ToString("X8")).Append('|');
+ Append(bits, path.BackupCheckPos);
+ bits.Append(path.BackupCheckCellId.ToString("X8")).Append('|');
+ Append(bits, path.StepUpNormal);
+ Append(bits, path.WalkInterp);
+ Append(bits, path.WalkableAllowance);
+ bits.Append(collision.CollisionNormalValid ? 1 : 0).Append('|');
+ Append(bits, collision.CollisionNormal);
+ bits.Append(collision.SlidingNormalValid ? 1 : 0).Append('|');
+ Append(bits, collision.SlidingNormal);
+ return bits.ToString();
+ }
+
+ private static void Append(StringBuilder target, float value) =>
+ target.Append(BitConverter.SingleToUInt32Bits(value).ToString("X8")).Append('|');
+
+ private static void Append(StringBuilder target, Vector3 value)
+ {
+ Append(target, value.X);
+ Append(target, value.Y);
+ Append(target, value.Z);
+ }
+
+ private static void AssertFloatBits(float expected, float actual) =>
+ Assert.Equal(
+ BitConverter.SingleToUInt32Bits(expected),
+ BitConverter.SingleToUInt32Bits(actual));
+
+ private static void AssertVectorBits(Vector3 expected, Vector3 actual)
+ {
+ AssertFloatBits(expected.X, actual.X);
+ AssertFloatBits(expected.Y, actual.Y);
+ AssertFloatBits(expected.Z, actual.Z);
+ }
+
+ private sealed record SiteOutcome(
+ TransitionState State,
+ bool Collide,
+ Vector3 CheckPos,
+ uint CheckCellId,
+ Vector3 BackupCheckPos,
+ uint BackupCheckCellId,
+ Vector3 StepUpNormal,
+ float WalkInterp,
+ float WalkableAllowance,
+ bool CollisionNormalValid,
+ Vector3 CollisionNormal,
+ bool SlidingNormalValid,
+ Vector3 SlidingNormal,
+ string Bits);
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs b/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs
index 29f55a22..3dcf6c22 100644
--- a/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs
@@ -1,4 +1,3 @@
-using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
@@ -7,51 +6,13 @@ using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics;
///
-/// Campaign P Slice P2, TS-4 (Section 6 Step 3): the 2026-04-30 "L.4" fixture
-/// capture required before the Path-6 steep-poly slide-tangent shortcut may be
-/// removed (docs/research/2026-07-30-response-layer-edge-family-pseudocode.md
-/// §4, §6 Step 3). The original repro was a live-client jump onto a steep
-/// roof that got the body "stuck in falling animation" for many frames; no
-/// captured fixture from that live session survives in the repo (checked
-/// docs/research/2026-04-30-* and the L.4 commit `b1af56e`), so this
-/// test builds a dat-free multi-frame replay from the existing
-/// geometry (a 63.4°
-/// slope, normal.Z ≈ 0.447 — below PhysicsGlobals.FloorZ ≈ 0.6642 but
-/// above PhysicsGlobals.LandingZ ≈ 0.0871, i.e. exactly the band the
-/// L.4 commit's own steep-poly shortcut targets) using the same
-/// PhysicsEngine.ResolveWithTransition multi-frame replay idiom as
-/// Issue185OutdoorStairsSeamReplayTests.
-///
-///
-/// A body falls from directly above the slope's mid-face, integrating
-/// gravity between resolves exactly as PhysicsBody.UpdatePhysicsInternal
-/// would, for up to 3 simulated seconds (90 ticks at 30 Hz — retail's physics
-/// tick rate, #32 L.5). "Wedged" is defined precisely, matching the original
-/// bug report ("stuck in falling animation on the roof" for many consecutive
-/// frames): the body's position stops changing (within 1 mm) for more than
-/// 15 consecutive ticks (0.5 s) while never reaching the flat reference
-/// floor at x<0, z=0. A healthy resolution reaches the flat floor (Z ≈
-/// ) well before the 90-tick
-/// budget expires, whether it does so by retail's own COLLIDED-then-fall
-/// bounce (this file's own git history documents that as retail's actual
-/// behavior for a clean Path-6 steep hit with no pre-existing contact plane)
-/// or by committing to the steep "walkable" surface via the permissive
-/// LandingZ threshold (matching CTransition::check_walkable,
-/// pc:273202, 0.0871556997f) and then downhill-drifting off it via
-/// the already-ported TS-1 CliffSlide chain.
-///
-///
-///
-/// Run TWICE across this slice's git history: once with the Path-6 steep
-/// shortcut ACTIVE (pins today's baseline — always green, since the
-/// shortcut's own in-frame slide-tangent cannot wedge by construction), and
-/// once with it REMOVED (the retail-strict candidate). If both pass, TS-4's
-/// removal is evidenced safe and lands in the same commit that deletes the
-/// shortcut and its SetSlidingNormal writes. If the removed-shortcut
-/// run wedges, the shortcut stays and this file's result against ToT is the
-/// recorded evidence — see the commit message / research doc open questions
-/// for the outcome actually reached.
-///
+/// Campaign P Slice 2B's production-shaped steep-roof control. It carries
+/// contact state between 30 Hz resolves exactly as the live PhysicsBody path
+/// does, so the nested edge/StepDown dispatcher can turn a vertical landing
+/// into retail's downhill response instead of the old under-modeled fixed
+/// point. The paired graph/flat direction matrix in
+/// covers vertical, inward,
+/// tangential, uphill, downhill, wall, roof, and ledge histories.
///
public class Ts4SteepRoofWedgeCaptureTests
{
@@ -60,7 +21,7 @@ public class Ts4SteepRoofWedgeCaptureTests
private const uint CellId = 0xA9B40001u;
private const int TicksPerSecond = 30; // #32 L.5 retail physics tick rate
- private const int MaxTicks = 3 * TicksPerSecond;
+ private const int MaxTicks = 6 * TicksPerSecond;
private const int WedgeTickThreshold = 15; // 0.5 s of zero motion == wedged
private const float WedgeEpsilon = 0.001f; // 1 mm
@@ -112,12 +73,13 @@ public class Ts4SteepRoofWedgeCaptureTests
}
///
- /// Falls a player-flagged mover from directly above the 63.4° slope's
- /// mid-face and asserts it reaches the flat floor (or at minimum keeps
- /// making downward/downhill progress) without a >0.5s frozen stretch.
+ /// Falls a player-flagged mover from directly above the slope and carries
+ /// each frame's contact state into the next frame, matching the production
+ /// PhysicsBody path. The exact edge/step-down chain must move the body
+ /// downhill and onto the reference floor without a half-second wedge.
///
[Fact]
- public void FallOntoSteepSlope_NeverFreezesForOverHalfASecond_AndReachesFloor()
+ public void FallOntoSteepSlope_PureVertical_NeverWedgesAndReachesFloor()
{
var engine = MakeSlopeEngine();
float r = BSPStepUpFixtures.SphereRadius;
@@ -135,7 +97,6 @@ public class Ts4SteepRoofWedgeCaptureTests
float fallVelocityZ = 0f;
uint cell = CellId;
- var positions = new List(MaxTicks) { pos };
int frozenStreak = 0;
bool reachedFloor = false;
@@ -152,7 +113,7 @@ public class Ts4SteepRoofWedgeCaptureTests
sphereHeight: r * 2f,
stepUpHeight: 0.30f,
stepDownHeight: 0.04f,
- isOnGround: false,
+ isOnGround: body.OnWalkable,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0x01000000u);
@@ -170,11 +131,6 @@ public class Ts4SteepRoofWedgeCaptureTests
$"moved={moved:F4} onGround={result.IsOnGround} onWalkable={result.OnWalkable} " +
$"contact={result.InContact} vz={fallVelocityZ:F2} frozen={frozenStreak}");
- Assert.True(frozenStreak <= WedgeTickThreshold,
- $"Body frozen for {frozenStreak} consecutive ticks (>{WedgeTickThreshold} == " +
- $">0.5s) at tick {tick}, position ({newPos.X:F3},{newPos.Y:F3},{newPos.Z:F3}) — " +
- "this is the 'stuck in falling animation on the roof' wedge shape.");
-
pos = newPos;
cell = result.CellId;
body.Position = pos;
@@ -182,9 +138,16 @@ public class Ts4SteepRoofWedgeCaptureTests
if (result.IsOnGround)
fallVelocityZ = 0f;
- positions.Add(pos);
+ body.TransientState &=
+ ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
+ if (result.InContact)
+ body.TransientState |= TransientStateFlags.Contact;
+ if (result.OnWalkable)
+ body.TransientState |= TransientStateFlags.OnWalkable;
+
+ Assert.True(frozenStreak <= WedgeTickThreshold,
+ $"Body froze for {frozenStreak} ticks at {pos}.");
- // Reached the flat reference floor (x<0, z ~ r) — resolved cleanly.
if (pos.X < 0f && pos.Z <= r + 0.05f)
{
reachedFloor = true;
@@ -193,10 +156,7 @@ public class Ts4SteepRoofWedgeCaptureTests
}
Assert.True(reachedFloor,
- $"Body never reached the flat reference floor within {MaxTicks} ticks " +
- $"({MaxTicks / (float)TicksPerSecond:F1}s); final position " +
- $"({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) — this is the wedge the L.4 shortcut guards " +
- "against (never resolving off the steep surface at all), distinct from a bounded " +
- "per-tick freeze.");
+ $"The production-shaped vertical trace did not reach the floor within " +
+ $"{MaxTicks} ticks; final=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}).");
}
}