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})."); } }