fix(physics): restore retail step-down placement validation
This commit is contained in:
parent
4fbd93ecdb
commit
1fd5da67b4
4 changed files with 333 additions and 38 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
/// </summary>
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
271
tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs
Normal file
271
tests/AcDream.Core.Tests/Physics/RetailStepDownPlacementTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Pins retail <c>CTransition::step_down</c> (0x0050B2A0): every
|
||||
/// successfully supported candidate is re-tested with
|
||||
/// <see cref="InsertType.Placement"/>, regardless of whether the caller is
|
||||
/// ordinary contact maintenance or StepUp.
|
||||
/// </summary>
|
||||
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<ushort, ResolvedPolygon> 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<ushort, ResolvedPolygon> 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<ushort, ResolvedPolygon> 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<ushort, ResolvedPolygon>
|
||||
{
|
||||
[1] = new ResolvedPolygon
|
||||
{
|
||||
Id = 1,
|
||||
Vertices = vertices,
|
||||
Plane = new Plane(Vector3.UnitY, 0f),
|
||||
NumPoints = vertices.Length,
|
||||
SidesType = CullMode.None,
|
||||
},
|
||||
};
|
||||
return (root, resolved);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue