fix(physics): restore retail path-6 collision response
This commit is contained in:
parent
acec33eca8
commit
75b6f6b6c9
8 changed files with 523 additions and 223 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -396,18 +396,12 @@ public class BSPStepUpTests
|
|||
|
||||
/// <summary>
|
||||
/// Airborne mover descending toward a steep slope (normal.Z < FloorZ):
|
||||
/// Path 6 returns <see cref="TransitionState.Slid"/> 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.
|
||||
///
|
||||
/// <para>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).</para>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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.
|
||||
/// </summary>
|
||||
[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(
|
||||
|
|
|
|||
|
|
@ -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<TraceFrame>(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));
|
||||
|
||||
|
|
|
|||
321
tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs
Normal file
321
tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Exact site tests for retail <c>BSPTREE::find_collisions</c> Path 6
|
||||
/// (<c>0x0053A793..0x0053A7DC</c>). Primary-sphere hits defer through
|
||||
/// SetCollide; secondary-only hits hard-stop. Neither BSP branch owns the
|
||||
/// persistent sliding normal.
|
||||
/// </summary>
|
||||
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<ushort, ResolvedPolygon> 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<ushort, ResolvedPolygon> 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<ushort, ResolvedPolygon> 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<ushort, ResolvedPolygon>
|
||||
{
|
||||
[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<ushort, ResolvedPolygon> Resolved) Normalize(
|
||||
(PhysicsBSPNode Root, Dictionary<ushort, ResolvedPolygon> Resolved) fixture)
|
||||
{
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>(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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<c>docs/research/2026-07-30-response-layer-edge-family-pseudocode.md</c>
|
||||
/// §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
|
||||
/// <c>docs/research/2026-04-30-*</c> and the L.4 commit `b1af56e`), so this
|
||||
/// test builds a dat-free multi-frame replay from the existing
|
||||
/// <see cref="BSPStepUpFixtures.SlopedUnwalkable"/> geometry (a 63.4°
|
||||
/// slope, normal.Z ≈ 0.447 — below <c>PhysicsGlobals.FloorZ</c> ≈ 0.6642 but
|
||||
/// above <c>PhysicsGlobals.LandingZ</c> ≈ 0.0871, i.e. exactly the band the
|
||||
/// L.4 commit's own steep-poly shortcut targets) using the same
|
||||
/// <c>PhysicsEngine.ResolveWithTransition</c> multi-frame replay idiom as
|
||||
/// <c>Issue185OutdoorStairsSeamReplayTests</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// A body falls from directly above the slope's mid-face, integrating
|
||||
/// gravity between resolves exactly as <c>PhysicsBody.UpdatePhysicsInternal</c>
|
||||
/// 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 ≈
|
||||
/// <see cref="BSPStepUpFixtures.SphereRadius"/>) 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
|
||||
/// <c>LandingZ</c> threshold (matching <c>CTransition::check_walkable</c>,
|
||||
/// pc:273202, <c>0.0871556997f</c>) and then downhill-drifting off it via
|
||||
/// the already-ported TS-1 CliffSlide chain.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// 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 <c>SetSlidingNormal</c> 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.
|
||||
/// </para>
|
||||
/// 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
|
||||
/// <see cref="RetailEdgeResponseOrderingTests"/> covers vertical, inward,
|
||||
/// tangential, uphill, downhill, wall, roof, and ledge histories.
|
||||
/// </summary>
|
||||
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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<Vector3>(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}).");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue