fix(physics): restore retail path-6 collision response

This commit is contained in:
Erik 2026-07-31 13:44:55 +02:00
parent acec33eca8
commit 75b6f6b6c9
8 changed files with 523 additions and 223 deletions

View file

@ -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` |

View file

@ -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.
---

View file

@ -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.

View file

@ -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;
}
}

View file

@ -396,18 +396,12 @@ public class BSPStepUpTests
/// <summary>
/// Airborne mover descending toward a steep slope (normal.Z &lt; 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(

View file

@ -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));

View 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);
}

View file

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