From 1fd5da67b4baf92780a8a22f423241e258ba3b87 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 13:13:09 +0200 Subject: [PATCH] fix(physics): restore retail step-down placement validation --- .../retail-divergence-register.md | 4 +- ...0-response-layer-edge-family-pseudocode.md | 42 ++- src/AcDream.Core/Physics/TransitionTypes.cs | 54 ++-- .../Physics/RetailStepDownPlacementTests.cs | 271 ++++++++++++++++++ 4 files changed, 333 insertions(+), 38 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 0b588c86..37f3b26b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -118,7 +118,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 88 active rows (AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 87 active rows (AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -130,7 +130,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | | ~~AP-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `EdgeSlideAfterStepDownFailed` now evaluates retail Branch 1 (`!OnWalkable || !EdgeSlide` → restore + `OK_TS`) before the steep-contact `CliffSlide` branch. The former compensation is removed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` 0x0050B3D0, named-retail pseudo-C pc:273001–273090 | -| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 | +| ~~AP-5~~ | **RETIRED 2026-07-31 (Campaign P Slice 2A).** `DoStepDown` no longer accepts a caller-controlled `runPlacement` bypass. After the transitional support probe and retail `check_walkables` gate succeed, ordinary contact maintenance, edge-slide back-probes, and StepUp all switch to `PLACEMENT_INSERT`, reset `walk_interp` to 1, run the final insertion, restore the prior insert type, and accept only `OK_TS`. The former wall-slide justification is addressed at the actual placement dispatcher boundary: its retail epsilon-shaved overlap test permits exact wall tangency but rejects real penetration; no StepDown path skips validation. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`DoStepDown`); `src/AcDream.Core/Physics/BSPQuery.cs` / `FlatBspQuery.cs` (Placement dispatcher); `tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs` | — | — | `CTransition::step_down` 0x0050B2A0 pc:272946–272998; `BSPTREE::find_collisions` Placement branch 0x0053A440 pc:323742; `CSphere::intersects_sphere` 0x00537A80; `CCylSphere::intersects_sphere` 0x0053B440 | | ~~AP-7~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the "state gate" was a BN decompiler artifact, not a locomotion exemption.** `calc_friction` now ports retail's confirmed 0.25f threshold (`if (angle >= 0.25f) return;`) unconditionally, no special-cased gate. The "state check at pc:276702" the old row cited is `PhysicsState.Sledding` (confirmed via ACE's `PhysicsObj.calc_friction`, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141, and `SLEDDING_PS=0x800000` in acclient.h:2838) — it gates the 1.5625/6.25/near-flat friction-value OVERRIDE, not the threshold return itself; acdream had no live Sledding setter then or now (see #166 research, docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3), so the branch was simply unreachable dead code, not an exemption for ordinary walking. The reverted 2026-04-30 L.3c attempt (naive 0.0→0.25 bump, forward locomotion 3→0.16 m/s in `PlayerMovementControllerTests`) does not reproduce on the production graphical local-player path post-R6: `PlayerMovementController` zeroes `Velocity.X/Y` to exactly zero every tick before `calc_friction` runs whenever animation root motion drives the walk, so friction has no horizontal velocity left to hammer (pinned at the PhysicsBody level by `GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`). The headless/`get_state_velocity` movement-controller path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there. **CORRECTION (2026-07-30, same day, #265/#166 capture bisect):** the sentence above undersold the gap — `calc_friction` wasn't merely "no horizontal velocity to hammer," it was structurally UNREACHABLE with meaningful data on ANY grounded path: (a) the animation-root-motion path zeroed `Velocity.X/Y` outright every tick (the actual #265/#166 root cause, ten days pre-existing, not a Campaign-P regression), and (b) `PhysicsBody.GroundNormal` — the vector `calc_friction` dots velocity against — had ZERO production writers anywhere and silently defaulted to `Vector3.UnitZ` forever, so even surviving velocity would have been tested against a fake flat-ground normal on any real slope. Both gaps are now closed: `PlayerMovementController.cs`'s grounded block no longer reconstructs `Velocity` for the animation-root-motion case, and `PhysicsEngine.cs` syncs `body.GroundNormal` from the committed `ContactPlane.Normal` at the same commit point that already publishes `ContactPlane`. The 0.25f threshold port itself (this row's original subject) was always correct — it just had nothing real to operate on until this fix. See `docs/research/2026-07-30-265-capture-bisect.md`'s as-fixed addendum. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`GroundNormal` wiring); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (grounded-velocity fix); `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` (AP-7 test block); `tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`; `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs` | — | — | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70); ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141; `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1; `docs/research/2026-07-30-265-capture-bisect.md` | | ~~AP-10~~ | **RETIRED 2026-07-30 (Campaign P Slice P4) — the retail 0.1 m dry-corner water sink-in is restored.** `TerrainSurface.SampleWaterDepth` (`src/AcDream.Core/Physics/TerrainSurface.cs`) now returns 0.1 for a partially-water cell's dry corner instead of the collapsed 0. The row's own "destabilizes the touch check" justification turned out to be structurally true of retail too (a skipped `SetContactPlane` reassertion is not a fall in ANY of retail/ACE/acdream, because `Contact`/`OnWalkable` are STICKY — `PhysicsEngine.ResolveWithTransition`'s `onGround` computation ORs the fresh per-call `ContactPlaneValid` with the seeded, persistent `PhysicsBody.TransientState.OnWalkable` bit) — traced and confirmed in this slice; see `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.2. `PhysicsEngine.SampleTerrainWalkable`'s `isWater = waterDepth >= 0.45f` threshold means the restore does not flip the dry corner's water classification (0.1 still < 0.45) — only the sink-in depth changes. Full Release suite green (no regression) proves the sticky-bit argument held in practice, not just in theory. | `src/AcDream.Core/Physics/TerrainSurface.cs` (`SampleWaterDepth`) | — | — | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port); `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.1-5.2 | | AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80–350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 | diff --git a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md index 2e4a3939..21ee508d 100644 --- a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md +++ b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md @@ -1096,9 +1096,9 @@ excerpt summaries: guard. `TransitionTypes.cs` now follows that order exactly. AP-3, AP-4, AD-53, and -AD-54 are retired together. The implementation deliberately preserves the -existing ordinary-tail `runPlacement: false` choice and does not alter TS-4's -Path-6 steep-polygon shortcut. +AD-54 are retired together. Slice 1B deliberately left the ordinary-tail +`runPlacement: false` choice for the following AP-5-specific slice and did not +alter TS-4's Path-6 steep-polygon shortcut. Slice 2A below closes AP-5. `RetailEdgeResponseOrderingTests` pins every distinguishing branch: valid steep-contact early return, the one/two-sphere probe schedule, @@ -1133,4 +1133,40 @@ graph and prepared-flat runs compare every `ResolveResult` field and every persistent `PhysicsBody` field by raw float/double bits, including the ordered walkable vertex payload. +## 9. Campaign P Slice 2A closeout — mandatory StepDown placement (2026-07-31) + +The complete `CTransition::step_down` body at `0x0050B2A0` +(pseudo-C:272946–272998) has no caller-controlled validation choice. It resets +`walk_interp` to 1, performs the transitional downward/support probe, applies +the `EdgeSlide && !StepUp` walkable-support gate, and then always: + +1. saves `sphere_path.insert_type`; +2. installs `PLACEMENT_INSERT`; +3. calls `CTransition::transitional_insert(this, 1)`; +4. restores the saved insert type; and +5. returns true only for `OK_TS`. + +This is the same tail for normal grounded contact maintenance, the +`edge_slide` current-position back-probe, and StepUp. The former acdream +`runPlacement: false` argument on the first two callers was therefore a real +behavioral divergence, not a caller-specific retail mode. + +The historical justification for the bypass was a wall-slide candidate that +Placement rejected. The correct boundary is the Placement dispatcher, not +StepDown. Retail's BSP, sphere, and cylinder Placement branches are pure +occupancy tests and shave `PhysicsGlobals.EPSILON` from their effective reach. +The current graph and prepared-flat dispatchers already implement that rule: +an exactly tangent sphere remains valid while penetration beyond the retail +epsilon is rejected. With the response-order fixes from §8 in place, restoring +the mandatory placement tail does not reproduce the old wall stall. + +`RetailStepDownPlacementTests` pins the mechanism end to end for one- and +two-sphere movers, ordinary contact maintenance, StepUp, and a supported +candidate that overlaps only during final Placement. It also compares graph +and prepared-flat Placement at exact wall tangency and beyond-epsilon overlap. +The existing multi-frame roof/edge fixtures continue to compare the complete +`ResolveResult` and persistent `PhysicsBody` state by raw bits, and the #273, +#271, #185, StepUp, transition-retry, and TS-4 controls remain unchanged. AP-5 +is retired; TS-4 is intentionally untouched. + --- diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index c943be88..f38ff1ec 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -2050,18 +2050,13 @@ public sealed class Transition stepDownHeight); stepDownHeight = probeHeight; - // L.2.3h (2026-04-29): pass runPlacement=false. This - // branch's job is to maintain ground contact during normal - // movement (e.g., walking over small bumps or near walls). - // The Placement check inside DoStepDown is too strict for - // this use — minor wall overlap from a prior wall-slide - // would fail Placement and trigger the L.2.3e edge-block, - // leaving the player stuck near walls. DoStepUp still runs - // Placement for the step-UP-through-walls protection. + // Retail always finishes a successful step-down with a + // PLACEMENT_INSERT. That final pass validates that the + // supported candidate can actually contain the mover. bool steppedDown = false; for (int probe = 0; probe < probeCount; probe++) { - if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)) + if (DoStepDown(stepDownHeight, zVal, engine)) { steppedDown = true; break; @@ -2326,7 +2321,7 @@ public sealed class Transition Vector3 backToCurrent = sp.GlobalCurrCenter[0].Origin - sp.GlobalSphere[0].Origin; sp.AddOffsetToCheckPos(backToCurrent); - _ = DoStepDown(stepDownHeight, zVal, engine, runPlacement: false); + _ = DoStepDown(stepDownHeight, zVal, engine); ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; @@ -5140,8 +5135,7 @@ public sealed class Transition /// Ported from pseudocode section 5 (StepDown). /// ACE: Transition.StepDown(float stepDownHeight, float zVal). /// - private bool DoStepDown(float stepDownHeight, float walkableZ, PhysicsEngine engine, - bool runPlacement = true) + private bool DoStepDown(float stepDownHeight, float walkableZ, PhysicsEngine engine) { var sp = SpherePath; @@ -5156,7 +5150,7 @@ public sealed class Transition PhysicsDiagnostics.LogStepWalk( "stepdown-enter", -1, 0, sp, CollisionInfo, ObjectInfo, Vector3.Zero, Vector3.Zero, - detail: $"height={stepDownHeight:F4} walkableZ={walkableZ:F4} runPlacement={runPlacement}"); + detail: $"height={stepDownHeight:F4} walkableZ={walkableZ:F4}"); } // If NOT in step-up mode, apply the downward offset. @@ -5170,7 +5164,7 @@ public sealed class Transition PhysicsDiagnostics.LogStepWalk( "stepdown-after-offset", -1, 0, sp, CollisionInfo, ObjectInfo, downOffset, downOffset, - detail: $"height={stepDownHeight:F4} walkableZ={walkableZ:F4} runPlacement={runPlacement}"); + detail: $"height={stepDownHeight:F4} walkableZ={walkableZ:F4}"); } } @@ -5183,7 +5177,7 @@ public sealed class Transition "stepdown-after-insert", -1, 0, sp, CollisionInfo, ObjectInfo, Vector3.Zero, Vector3.Zero, transitState, - $"height={stepDownHeight:F4} walkableZ={walkableZ:F4} runPlacement={runPlacement}"); + $"height={stepDownHeight:F4} walkableZ={walkableZ:F4}"); } sp.StepDown = false; @@ -5234,23 +5228,11 @@ public sealed class Transition return false; } - // L.2.3h (2026-04-29): Placement validation is for the - // DoStepUp use case (prevents climbing through walls by - // stepping up onto ground beyond a tall wall). For the - // "maintain contact during normal movement" use case (called - // from TransitionalInsert's contact-recovery branch), the - // Placement check is over-strict — slight wall overlap from - // a prior wall-slide makes Placement reject, then the caller - // returns Collided (L.2.3e) and the player gets stuck near - // walls without ever touching them. - // - // ACE Transition.cs:731-741 runs Placement here unconditionally, - // but ACE's pre-step-down state is cleaner — we have residual - // wall-slide artifacts that make Placement misfire. - if (!runPlacement) - return true; - - // Placement validation: can we actually stand here? + // Retail CTransition::step_down (0x0050B2A0) always finishes a + // successful transitional support probe with PLACEMENT_INSERT. + // This rejects candidates that found support while still + // overlapping solid geometry, for both ordinary contact + // maintenance and StepUp. // // A6.P3 slice 4 (2026-05-22) — reset WalkInterp to 1.0 before // the placement_insert. The prior TransitionalInsert(5) probe @@ -5324,12 +5306,18 @@ public sealed class Transition "stepdown-reject", -1, 0, sp, CollisionInfo, ObjectInfo, Vector3.Zero, Vector3.Zero, transitState, - $"height={stepDownHeight:F4} walkableZ={walkableZ:F4} runPlacement={runPlacement}"); + $"height={stepDownHeight:F4} walkableZ={walkableZ:F4}"); } return false; } + internal bool DoStepDownForTest( + float stepDownHeight, + float walkableZ, + PhysicsEngine engine) + => DoStepDown(stepDownHeight, walkableZ, engine); + // ----------------------------------------------------------------------- // Step-up // ----------------------------------------------------------------------- diff --git a/tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs b/tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs new file mode 100644 index 00000000..ac0412a2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs @@ -0,0 +1,271 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Pins retail CTransition::step_down (0x0050B2A0): every +/// successfully supported candidate is re-tested with +/// , regardless of whether the caller is +/// ordinary contact maintenance or StepUp. +/// +public sealed class RetailStepDownPlacementTests +{ + private const uint Cell = 0xA9B40001u; + private const float Radius = 0.48f; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OrdinaryContactMaintenance_AlwaysRunsFinalPlacement(bool twoSpheres) + { + Transition transition = MakeGroundedTransition(twoSpheres); + int supportPasses = 0; + int placementPasses = 0; + var engine = new PhysicsEngine + { + TransitionCellCollisionTestHook = (candidate, phase, _, actual) => + { + if (phase != TransitionCellCollisionPhase.Environment) + return actual; + + if (candidate.SpherePath.StepDown) + { + supportPasses++; + candidate.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), Cell); + } + else if (candidate.SpherePath.InsertType == InsertType.Placement) + { + placementPasses++; + } + + return actual; + }, + }; + + TransitionState result = transition.TransitionalInsertForTest(1, engine); + + Assert.Equal(TransitionState.OK, result); + Assert.Equal(1, supportPasses); + Assert.Equal(1, placementPasses); + Assert.Equal(InsertType.Transition, transition.SpherePath.InsertType); + Assert.False(transition.SpherePath.StepDown); + } + + [Fact] + public void SupportedCandidate_OverlappingDuringPlacement_IsRejected() + { + Transition transition = MakeGroundedTransition(twoSpheres: true); + int placementPasses = 0; + var engine = new PhysicsEngine + { + TransitionCellCollisionTestHook = (candidate, phase, _, actual) => + { + if (phase != TransitionCellCollisionPhase.Environment) + return actual; + + if (candidate.SpherePath.StepDown) + { + candidate.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), Cell); + return actual; + } + + if (candidate.SpherePath.InsertType == InsertType.Placement) + { + placementPasses++; + return TransitionState.Collided; + } + + return actual; + }, + }; + + bool accepted = transition.DoStepDownForTest( + stepDownHeight: 0.04f, + walkableZ: PhysicsGlobals.FloorZ, + engine); + + Assert.False(accepted); + Assert.Equal(1, placementPasses); + Assert.Equal(InsertType.Transition, transition.SpherePath.InsertType); + Assert.False(transition.SpherePath.StepDown); + } + + [Fact] + public void StepUp_UsesTheSameFinalPlacementPass() + { + Transition transition = MakeGroundedTransition(twoSpheres: true); + int placementPasses = 0; + var engine = new PhysicsEngine + { + TransitionCellCollisionTestHook = (candidate, phase, _, actual) => + { + if (phase != TransitionCellCollisionPhase.Environment) + return actual; + + if (candidate.SpherePath.StepDown) + { + candidate.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), Cell); + } + else if (candidate.SpherePath.InsertType == InsertType.Placement) + { + placementPasses++; + } + + return actual; + }, + }; + + bool accepted = transition.DoStepUp(Vector3.UnitX, engine); + + Assert.True(accepted); + Assert.Equal(1, placementPasses); + Assert.Equal(InsertType.Transition, transition.SpherePath.InsertType); + Assert.False(transition.SpherePath.StepUp); + Assert.False(transition.SpherePath.StepDown); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PlacementDispatcher_AllowsExactWallTangency_AndRejectsOverlap( + bool twoSpheres) + { + (PhysicsBSPNode root, Dictionary resolved) = + BuildWall(); + FlatPhysicsBsp flat = + FlatCollisionAssetBuilder.FlattenPhysicsBsp(root, resolved); + + float tangentY = Radius; + TransitionState graphTangent = RunPlacement( + root, resolved, flat: null, tangentY, twoSpheres); + TransitionState flatTangent = RunPlacement( + root: null, resolved, flat, tangentY, twoSpheres); + TransitionState graphOverlap = RunPlacement( + root, resolved, flat: null, + Radius - PhysicsGlobals.EPSILON * 2f, + twoSpheres); + TransitionState flatOverlap = RunPlacement( + root: null, resolved, flat, + Radius - PhysicsGlobals.EPSILON * 2f, + twoSpheres); + + Assert.Equal(TransitionState.OK, graphTangent); + Assert.Equal(graphTangent, flatTangent); + Assert.Equal(TransitionState.Collided, graphOverlap); + Assert.Equal(graphOverlap, flatOverlap); + } + + private static TransitionState RunPlacement( + PhysicsBSPNode? root, + Dictionary resolved, + FlatPhysicsBsp? flat, + float centerY, + bool twoSpheres) + { + var foot = new Sphere + { + Origin = new Vector3(0f, centerY, 0f), + Radius = Radius, + }; + Sphere? head = twoSpheres + ? new Sphere + { + Origin = new Vector3(0f, centerY, 0.875f), + Radius = Radius, + } + : null; + var transition = new Transition(); + transition.SpherePath.InitPath( + begin: Vector3.Zero, + end: Vector3.Zero, + Cell, + Radius, + sphereHeight: twoSpheres ? 1.355f : 0f); + transition.SpherePath.InsertType = InsertType.Placement; + + return flat is null + ? BSPQuery.FindCollisions( + root, + resolved, + transition, + foot, + head, + foot.Origin, + Vector3.UnitZ, + 1f) + : FlatBspQuery.FindCollisions( + flat, + transition, + foot, + head, + foot.Origin, + Vector3.UnitZ, + 1f); + } + + private static Transition MakeGroundedTransition(bool twoSpheres) + { + Vector3 current = new(2f, 3f, 4f); + Vector3 target = current + new Vector3(0.1f, 0f, 0f); + var transition = new Transition(); + transition.SpherePath.InitPath( + current, + target, + Cell, + Radius, + sphereHeight: twoSpheres ? 1.835f : 0f); + transition.SpherePath.SetCheckPos(target, Cell); + transition.ObjectInfo.State = + ObjectInfoState.Contact | ObjectInfoState.OnWalkable; + transition.ObjectInfo.StepDown = true; + transition.ObjectInfo.StepDownHeight = 0.04f; + transition.ObjectInfo.StepUpHeight = 0.60f; + transition.CollisionInfo.LastKnownContactPlane = + new Plane(Vector3.UnitZ, 0f); + transition.CollisionInfo.LastKnownContactPlaneValid = true; + return transition; + } + + private static ( + PhysicsBSPNode Root, + Dictionary Resolved) BuildWall() + { + Vector3[] vertices = + [ + new(-2f, 0f, -2f), + new(-2f, 0f, 2f), + new( 2f, 0f, 2f), + new( 2f, 0f, -2f), + ]; + var root = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere + { + Origin = Vector3.Zero, + Radius = 4f, + }, + }; + root.Polygons.Add(1); + var resolved = new Dictionary + { + [1] = new ResolvedPolygon + { + Id = 1, + Vertices = vertices, + Plane = new Plane(Vector3.UnitY, 0f), + NumPoints = vertices.Length, + SidesType = CullMode.None, + }, + }; + return (root, resolved); + } +}