The A6.P4 port, fused into one installment per the BR-2 half-port lesson
(registration and query are co-dependent: flood-registering shells under
the old radial query would re-open #98 through the vestibule).
REGISTRATION (ShadowObjectRegistry rewritten):
- Register/RegisterMultiPart/UpdatePosition compute the cell set via
CellTransit.BuildShadowCellSet (the C2 find_cell_list flood) seeded by
the entity's m_position cell id; the private 24m XY-grid rectangle and
its single-landblock clamp are deleted. Flood spheres follow retail's
CylSphere rule (base point + cyl radius, cap 10; BSP bounding-sphere
fallback - Ghidra 0x0052b9f0). Statics flood with the do_not_load
prune; dynamics (server spawns, isStatic:false) without.
- Keep-when-empty (SetPositionInternal num_cells gate, pc:283540): a
failed flood leaves the previous registration in place.
- RefloodLandblock: streaming-race hook re-runs the flood when a
landblock's cells hydrate (retail init_objects -> recalc_cross_cells,
Ghidra 0x0052b420/0x00515a30); wired at GameWindow's hydration tail.
- GameWindow sites pass the server position's full cell id as the seed
(spawn + UpdatePosition); the five static sites pass ParentCellId.
BUILDING CHANNEL (CSortCell.building shape):
- Building SHELLS are not shadow objects in retail (only caller of
find_building_collisions is CSortCell::find_collisions 0x005340aa;
one building per origin landcell, init_buildings 0x0052fd80 verified
verbatim + ACE cross-ref). IsBuildingShell entities skip the registry;
Transition.FindBuildingCollisions runs the shell part-0 BSP off
cache.GetBuilding(cellId) with bldg_check set around it
(find_building_collisions 0x006b5300), CollidedWithEnvironment on
non-Contact non-OK. BuildingPhysics.ModelId = pre-resolved part-0
GfxObj (0x02 Setups resolved at the CacheBuilding site).
- Placement/ethereal weakening: BSPQuery Path 1 passes center_solid=0
when BldgCheck && HitsInteriorCell (BSPTREE::find_collisions 0x0053a82e
+ placement_insert 0x005399d8) so doorway crossings don't hard-fail
against shell solids. SpherePath gains both retail fields;
HitsInteriorCell is rebuilt at every cell-array build
(build_cell_array reset 0x00509ef2 + find_cell_list/check_building_
transit set sites).
QUERY (retail per-cell order, transitional_insert 0x0050b6f0):
- TransitionalInsert per attempt: env -> building (LandCell only) ->
objects on the PRIMARY cell, then on OK the check_other_cells pass
(env -> building -> objects per OTHER overlapped cell) + the
carried-cell advance - the advance now happens AFTER all per-cell
object passes (the WF1 ordering divergence), with Adjusted/Slid
feeding the retry exactly like retail's OK_TS case.
- FindObjCollisionsInCell = CObjCell::find_obj_collisions (0x0052b750):
iterate ONLY the asked cell's list. DELETED: the radial 9-landblock
sweep, the +5m query pad, the b3ce505 indoor-primary gate, and the
isViewer exemption (the camera is bounded by interior cell-BSP env
collision - retail's own channel; CameraCornerSealReplayTests pins it
against real dat, and the new building-channel camera test pins the
outdoor stop).
TESTS: Core 1416/0/2 (was 1398 + 4 pre-existing #99-era fails + 1 skip),
App 225, UI 420, Net 294 - all green.
- 3 of the 4 #99-era reds flipped green as designed: the door apparatus
(Apparatus_Grounded_50cmOffCenter_FrontApproach_Blocks) and tick-13558
(indoor walkthrough) now assert the door BLOCKS; tick-22760 pins the
outdoor blocking invariant.
- The 4th (BSPStepUp D4) + 22760's lateral-slide delta are NOT cell-set
problems (probes prove the door is found + BSP-only dispatched;
BR-7 left both byte-identical) - filed as issue #116 (slide-response
family), D4 skipped with the issue reference.
- FindEnvCollisionsMultiCellTests migrated to the public entry (the A4
multi-cell halt now lives at the retail call site).
- New registry pins: per-cell query surface, outdoor-footprint-never-
indoor (#98 architectural), door-outdoor-cell membership, reflood.
- CameraCollisionIndoorTests rewritten against the building channel
(the isViewer-exemption pins died with the exemption).
Closes #99 (doors block both ways via registration-time cell membership
+ the straddle-spanning player cell array). #97 likely closed (the +5m
radial pad that produced phantom-collision candidates is gone) - verify
at T5. #98 stays closed ARCHITECTURALLY (outdoor footprints structurally
cannot reach interior cells; the cellar harness stays green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
663 lines
29 KiB
C#
663 lines
29 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using DatReaderWriter.Types;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// Conformance tests for BSP step-up (Path 5) and rooftop landing (Path 6) in
|
|
/// <see cref="BSPQuery.FindCollisions"/>.
|
|
///
|
|
/// <para>
|
|
/// Tests are organised in three groups corresponding to the three commits:
|
|
/// </para>
|
|
/// <list type="bullet">
|
|
/// <item><b>Group A — Baselines</b>: behaviours that should pass both before
|
|
/// and after the implementation (no-hit returns OK, fixture geometry checks).</item>
|
|
/// <item><b>Group B — Phase L.2.1 (Path 5 step-up)</b>: tests that are RED
|
|
/// because Path 5 wall-slides instead of stepping up. L.2.1 flips these
|
|
/// GREEN.</item>
|
|
/// <item><b>Group C — Phase L.2.2 (Path 6 SetCollide)</b>: tests that are RED
|
|
/// because Path 6 wall-slides instead of setting the Collide flag. L.2.2
|
|
/// flips these GREEN.</item>
|
|
/// </list>
|
|
///
|
|
/// <para>
|
|
/// Retail references:
|
|
/// BSPTREE::find_collisions Path 5 — acclient_2013_pseudo_c.txt:323849 /
|
|
/// ACE BSPTree.cs:192-196.
|
|
/// CTransition::step_up — acclient_2013_pseudo_c.txt:273099-273133 /
|
|
/// ACE Transition.cs:746-777.
|
|
/// BSPTREE::find_collisions Path 6 / SPHEREPATH::set_collide —
|
|
/// acclient_2013_pseudo_c.txt:323819 / ACE BSPTree.cs:210-219.
|
|
/// SPHEREPATH::set_collide — acclient_2013_pseudo_c.txt:321594-321607 /
|
|
/// ACE SpherePath.cs:279-286.
|
|
/// CTransition::transitional_insert Collide branch —
|
|
/// acclient_2013_pseudo_c.txt:273193-273239 / ACE Transition.cs:891-930.
|
|
/// </para>
|
|
/// </summary>
|
|
public class BSPStepUpTests
|
|
{
|
|
// =========================================================================
|
|
// Group A — Baselines (pass before AND after the implementation)
|
|
// =========================================================================
|
|
|
|
/// <summary>
|
|
/// No BSP geometry → FindCollisions returns OK with no state changes.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A1_NullRoot_ReturnsOK()
|
|
{
|
|
var from = new Vector3(0f, 0f, BSPStepUpFixtures.SphereRadius);
|
|
var to = new Vector3(0.1f, 0f, BSPStepUpFixtures.SphereRadius);
|
|
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
|
|
|
|
var localSphere = new DatReaderWriter.Types.Sphere
|
|
{
|
|
Origin = to,
|
|
Radius = BSPStepUpFixtures.SphereRadius,
|
|
};
|
|
|
|
var result = BSPQuery.FindCollisions(
|
|
null,
|
|
new Dictionary<ushort, ResolvedPolygon>(),
|
|
t, localSphere, null,
|
|
from, Vector3.UnitZ, 1.0f);
|
|
|
|
Assert.Equal(TransitionState.OK, result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Grounded mover far from the wall → no collision → OK.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A2_GroundedMover_NoWallNear_ReturnsOK()
|
|
{
|
|
var (root, resolved) = BSPStepUpFixtures.LowStep();
|
|
|
|
// Moving in -X, away from the wall at x=0.5.
|
|
var from = new Vector3(-1f, 0f, BSPStepUpFixtures.SphereRadius);
|
|
var to = new Vector3(-1.5f, 0f, BSPStepUpFixtures.SphereRadius);
|
|
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
|
|
|
|
var localSphere = new DatReaderWriter.Types.Sphere { Origin = to, Radius = BSPStepUpFixtures.SphereRadius };
|
|
|
|
var result = BSPQuery.FindCollisions(
|
|
root, resolved, t, localSphere, null,
|
|
from, Vector3.UnitZ, 1.0f);
|
|
|
|
Assert.Equal(TransitionState.OK, result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Airborne mover well above the roof → no collision → OK.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A3_AirborneMover_AboveRoof_ReturnsOK()
|
|
{
|
|
var (root, resolved) = BSPStepUpFixtures.FlatRoof();
|
|
|
|
// Mover at z=6 (well above the roof at z=3) with tiny downward step.
|
|
float highZ = 6f;
|
|
var from = new Vector3(0f, 0f, highZ + BSPStepUpFixtures.SphereRadius);
|
|
var to = new Vector3(0f, 0f, highZ + BSPStepUpFixtures.SphereRadius - 0.01f);
|
|
var t = BSPStepUpFixtures.MakeAirborneTransition(from, to);
|
|
|
|
var localSphere = new DatReaderWriter.Types.Sphere { Origin = to, Radius = BSPStepUpFixtures.SphereRadius };
|
|
|
|
var result = BSPQuery.FindCollisions(
|
|
root, resolved, t, localSphere, null,
|
|
from, Vector3.UnitZ, 1.0f);
|
|
|
|
Assert.Equal(TransitionState.OK, result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The slope fixture's polygon must have normal.Z below FloorZ (confirms
|
|
/// the fixture geometry is set up correctly as a non-walkable surface).
|
|
/// </summary>
|
|
[Fact]
|
|
public void A4_SlopedFixture_NormalBelowFloorZ()
|
|
{
|
|
var (_, resolved) = BSPStepUpFixtures.SlopedUnwalkable();
|
|
var slope = resolved[BSPStepUpFixtures.SlopedUnwalkable_SlopeId];
|
|
|
|
Assert.True(slope.Plane.Normal.Z < PhysicsGlobals.FloorZ,
|
|
$"Slope normal.Z ({slope.Plane.Normal.Z:F4}) must be < FloorZ ({PhysicsGlobals.FloorZ:F4})");
|
|
Assert.True(slope.Plane.Normal.Z > 0f,
|
|
$"Slope normal.Z ({slope.Plane.Normal.Z:F4}) must be > 0 (upward-facing)");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Low-step upper-floor polygon has normal.Z >= FloorZ (it IS walkable).
|
|
/// </summary>
|
|
[Fact]
|
|
public void A5_LowStepUpperFloor_NormalAboveFloorZ()
|
|
{
|
|
var (_, resolved) = BSPStepUpFixtures.LowStep();
|
|
var upper = resolved[BSPStepUpFixtures.LowStep_UpperFloorId];
|
|
|
|
Assert.True(upper.Plane.Normal.Z >= PhysicsGlobals.FloorZ,
|
|
$"Upper floor normal.Z ({upper.Plane.Normal.Z:F4}) must be >= FloorZ ({PhysicsGlobals.FloorZ:F4})");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Roof polygon has normal.Z >= LandingZ (it can be landed on).
|
|
/// </summary>
|
|
[Fact]
|
|
public void A6_FlatRoofPolygon_NormalAboveLandingZ()
|
|
{
|
|
var (_, resolved) = BSPStepUpFixtures.FlatRoof();
|
|
var roof = resolved[BSPStepUpFixtures.FlatRoof_RoofId];
|
|
|
|
Assert.True(roof.Plane.Normal.Z >= PhysicsGlobals.LandingZ,
|
|
$"Roof normal.Z ({roof.Plane.Normal.Z:F4}) must be >= LandingZ ({PhysicsGlobals.LandingZ:F4})");
|
|
}
|
|
|
|
// =========================================================================
|
|
// Group B — Phase L.2.1 (Path 5 step-up)
|
|
//
|
|
// RED before L.2.1, GREEN after.
|
|
// Each test documents the CURRENT wrong behaviour and EXPECTED correct one.
|
|
// =========================================================================
|
|
|
|
/// <summary>
|
|
/// Grounded mover (Contact + OnWalkable) walking toward the low step (25 cm):
|
|
/// should step up onto the upper floor, not slide sideways.
|
|
///
|
|
/// <para>
|
|
/// Current (wrong): Path 5 applies wall-slide → CurPos.X stays left of wall;
|
|
/// Z stays at floor level.
|
|
/// </para>
|
|
/// <para>
|
|
/// Expected after L.2.1: Path 5 calls StepUp → DoStepDown finds upper floor
|
|
/// → sphere lifts to z ≥ 0.25 + SphereRadius and X advances past the wall.
|
|
/// </para>
|
|
///
|
|
/// <para>Retail: BSPTREE::step_sphere_up / CTransition::step_up
|
|
/// acclient_2013_pseudo_c.txt:323849, 273099.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void B1_GroundedMover_LowStep_StepsUp()
|
|
{
|
|
var (root, resolved) = BSPStepUpFixtures.LowStep();
|
|
const float stepUpHeight = 0.30f; // larger than step (0.25), so step-up succeeds
|
|
|
|
// CurPos (foot position) starts at z=0 (on the terrain / BSP floor at z=0).
|
|
// The sphere center is at CurPos + (0, 0, SphereRadius) = (x, 0, 0.2).
|
|
// lowPoint = sphere_center - (0,0,r) = (x, 0, 0) → on terrain → contact.
|
|
var from = new Vector3(0.1f, 0f, 0f);
|
|
// to.X = 0.6 → offset = (0.5, 0, 0), 3 sub-steps of 0.1667 each.
|
|
// Step 2: CurPos ≈ (0.433, 0, 0), sphere center x ≈ 0.433.
|
|
// Wall: dist = 0.5 - 0.433 = 0.067 < rad = 0.198 → HIT Path 5 ✓
|
|
var to = new Vector3(0.6f, 0f, 0f); // foot stays at z=0, crosses wall at x=0.5
|
|
|
|
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight);
|
|
// terrainZ=0f: terrain at z=0 keeps the step-down probe grounded between
|
|
// steps, preserving Contact/OnWalkable across the sub-step boundary.
|
|
var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
|
|
|
|
bool ok = t.FindTransitionalPosition(engine);
|
|
|
|
// After step-up, the character's foot (CurPos.Z) must be at or above the
|
|
// upper floor (z=0.25). CurPos stores the foot origin; the sphere center is
|
|
// CurPos.Z + SphereRadius. The lower bound is the upper-floor Z minus a
|
|
// small epsilon to tolerate floating-point rounding in AdjustSphereToPlane.
|
|
float expectedMinZ = 0.25f - PhysicsGlobals.EPSILON * 10f;
|
|
Assert.True(t.SpherePath.CurPos.Z >= expectedMinZ,
|
|
$"Expected Z >= {expectedMinZ:F4} (stepped up to upper floor at z=0.25), " +
|
|
$"got CurPos.Z = {t.SpherePath.CurPos.Z:F4}. " +
|
|
"Path 5 must call StepUp (L.2.1) instead of wall-sliding.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Grounded mover walking into the too-tall wall (5 m) should NOT step up —
|
|
/// the wall is taller than StepUpHeight.
|
|
///
|
|
/// <para>
|
|
/// Expected: StepUp is called, DoStepDown finds no walkable surface within
|
|
/// 0.04 m (no upper floor exists), StepUpSlide applies → mover stays
|
|
/// left of the wall.
|
|
/// </para>
|
|
///
|
|
/// <para>Retail: SPHEREPATH::step_up_slide
|
|
/// ACE SpherePath.cs:309-316.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void B2_GroundedMover_TallWall_BlockedOrSlides()
|
|
{
|
|
var (root, resolved) = BSPStepUpFixtures.TallWall();
|
|
const float stepUpHeight = 0.04f; // default — cannot scale 5 m wall
|
|
|
|
// Foot at z=0 (on terrain). Same reasoning as B1.
|
|
var from = new Vector3(0.1f, 0f, 0f);
|
|
var to = new Vector3(0.6f, 0f, 0f);
|
|
|
|
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight);
|
|
// terrainZ=0f: keep grounded between steps (same as B1).
|
|
var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
|
|
|
|
t.FindTransitionalPosition(engine);
|
|
|
|
// The mover should NOT have crossed the wall at x=0.5.
|
|
float wallFace = 0.5f - BSPStepUpFixtures.SphereRadius;
|
|
Assert.True(t.SpherePath.CurPos.X <= wallFace + PhysicsGlobals.EPSILON * 20f,
|
|
$"Expected mover blocked before wall (x <= {wallFace:F3}), " +
|
|
$"got CurPos.X = {t.SpherePath.CurPos.X:F4}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Direct Path 5 invocation: Contact mover sphere just overlapping the low
|
|
/// wall should NOT return Slid after L.2.1.
|
|
///
|
|
/// <para>
|
|
/// Current: returns Slid (wall-slide).
|
|
/// Expected after L.2.1: returns OK (step-up succeeded) with Z lifted.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void B3_Path5_DirectCall_ContactHitsLowWall_NotSlid()
|
|
{
|
|
var (root, resolved) = BSPStepUpFixtures.LowStep();
|
|
|
|
// Sphere center overlaps the wall (x=0.5) by half-radius.
|
|
float r = BSPStepUpFixtures.SphereRadius;
|
|
var checkPos = new Vector3(0.5f - r * 0.5f, 0f, r);
|
|
var currPos = new Vector3(0.1f, 0f, r);
|
|
|
|
var t = new Transition();
|
|
t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r);
|
|
t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u);
|
|
t.ObjectInfo.State = ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
|
|
t.ObjectInfo.StepUpHeight = 0.30f;
|
|
t.ObjectInfo.StepDownHeight = 0.04f;
|
|
t.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f);
|
|
t.CollisionInfo.LastKnownContactPlaneValid = true;
|
|
|
|
var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r };
|
|
|
|
// Pass engine so Path 5 can call DoStepUp → DoStepDown (L.2.1).
|
|
// Without engine the fallback wall-slide would return Slid.
|
|
var engine = MakeTestEngine(root, resolved);
|
|
|
|
var result = BSPQuery.FindCollisions(
|
|
root, resolved, t, localSphere, null,
|
|
currPos, Vector3.UnitZ, 1.0f, Quaternion.Identity, engine);
|
|
|
|
// After L.2.1 this assertion flips from failing (Slid) to passing.
|
|
Assert.NotEqual(TransitionState.Slid, result);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Group C — Phase L.2.2 (Path 6 SetCollide)
|
|
//
|
|
// RED before L.2.2, GREEN after.
|
|
// =========================================================================
|
|
|
|
/// <summary>
|
|
/// Airborne mover hitting the flat roof from above should set Collide flag
|
|
/// and return Adjusted (not Slid with wall-slide offset).
|
|
///
|
|
/// <para>
|
|
/// Current (wrong): Path 6 computes a wall-slide offset and returns Slid.
|
|
/// </para>
|
|
/// <para>
|
|
/// Expected after L.2.2: Path 6 calls path.SetCollide(worldNormal), sets
|
|
/// WalkableAllowance = LandingZ, returns Adjusted.
|
|
/// </para>
|
|
///
|
|
/// <para>Retail: SPHEREPATH::set_collide
|
|
/// acclient_2013_pseudo_c.txt:321594 / ACE BSPTree.cs:210-219.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void C1_Path6_AirborneMoverHitsRoof_SetsCollideFlagAndAdjusted()
|
|
{
|
|
var (root, resolved) = BSPStepUpFixtures.FlatRoof();
|
|
|
|
// Sphere center just penetrating the roof polygon (z=3) from above.
|
|
float r = BSPStepUpFixtures.SphereRadius;
|
|
var checkPos = new Vector3(0f, 0f, 3f + r * 0.5f); // half-radius above roof
|
|
var currPos = new Vector3(0f, 0f, 3f + r + 0.1f); // clearly above
|
|
|
|
var t = new Transition();
|
|
t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r);
|
|
t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u);
|
|
t.ObjectInfo.State = ObjectInfoState.None; // airborne — no Contact
|
|
|
|
var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r };
|
|
|
|
var result = BSPQuery.FindCollisions(
|
|
root, resolved, t, localSphere, null,
|
|
currPos, Vector3.UnitZ, 1.0f);
|
|
|
|
// After L.2.2: result = Adjusted, Collide = true, WalkableAllowance = LandingZ.
|
|
// Currently: result = Slid (wall-slide path).
|
|
Assert.Equal(TransitionState.Adjusted, result);
|
|
Assert.True(t.SpherePath.Collide,
|
|
"Expected SpherePath.Collide = true after Path 6 hit (L.2.2)");
|
|
Assert.Equal(PhysicsGlobals.LandingZ, t.SpherePath.WalkableAllowance,
|
|
precision: 5);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Full integration: airborne mover drops onto the 3 m flat roof.
|
|
///
|
|
/// <para>
|
|
/// After L.2.2: TransitionalInsert sees Collide flag, re-tests as Placement,
|
|
/// finds walkable polygon at z=3, sets ContactPlane with normal.Z ≈ 1.
|
|
/// </para>
|
|
/// <para>
|
|
/// Current: mover slides sideways off the roof (never lands).
|
|
/// Expected after L.2.2: ContactPlane is set with Normal.Z >= LandingZ.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void C2_AirborneMover_LandsOnFlatRoof_ContactPlaneSet()
|
|
{
|
|
var (root, resolved) = BSPStepUpFixtures.FlatRoof();
|
|
|
|
float roofZ = 3f;
|
|
float r = BSPStepUpFixtures.SphereRadius;
|
|
// CurPos = foot position. Sphere center = CurPos + (0,0,r).
|
|
// from: foot at z = roofZ - r + 0.3f → sphere center at roofZ + 0.3 = 3.3 (above roof)
|
|
// to: foot at z = roofZ - r - 0.05f → sphere center at roofZ - 0.05 = 2.95 (into roof by 0.05)
|
|
// Roof polygon at z=roofZ, normal=+Z: dist = sphere_center.z - roofZ.
|
|
// At to: dist = -0.05; |dist| = 0.05 < rad=0.198 → roof hit ✓
|
|
var from = new Vector3(0f, 0f, roofZ - r + 0.3f);
|
|
var to = new Vector3(0f, 0f, roofZ - r - 0.05f); // sphere bottom at z ≈ 2.95 (into roof)
|
|
|
|
var t = BSPStepUpFixtures.MakeAirborneTransition(from, to);
|
|
// terrainZ=-50f: airborne mover — terrain must not interfere with roof landing.
|
|
var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
|
|
|
|
t.FindTransitionalPosition(engine);
|
|
|
|
// After L.2.2: at least one of ContactPlane / LastKnownContactPlane is set.
|
|
bool planeSet = t.CollisionInfo.ContactPlaneValid
|
|
|| t.CollisionInfo.LastKnownContactPlaneValid;
|
|
|
|
Assert.True(planeSet,
|
|
"Expected a contact plane after landing on roof (L.2.2). " +
|
|
"Currently Path 6 wall-slides and never sets ContactPlane.");
|
|
|
|
if (planeSet)
|
|
{
|
|
var plane = t.CollisionInfo.ContactPlaneValid
|
|
? t.CollisionInfo.ContactPlane
|
|
: t.CollisionInfo.LastKnownContactPlane;
|
|
|
|
Assert.True(plane.Normal.Z >= PhysicsGlobals.LandingZ,
|
|
$"Contact plane normal.Z ({plane.Normal.Z:F4}) must be >= LandingZ ({PhysicsGlobals.LandingZ:F4})");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Airborne mover descending toward a steep slope (normal.Z < FloorZ):
|
|
/// Path 6 returns <see cref="TransitionState.Slid"/> and does NOT set
|
|
/// the Collide flag — the steep-normal slide-tangent branch (L.4,
|
|
/// commit b1af56e, 2026-04-30) intercepts the hit before SetCollide is
|
|
/// called and projects the move along the steep face instead, keeping the
|
|
/// body airborne with the falling animation.
|
|
///
|
|
/// <para>This is a documented intentional deviation from retail (retail calls
|
|
/// set_collide unconditionally; our interim port uses slide-tangent while
|
|
/// the retail step_up_slide / cliff_slide chain port is completed).</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void C3_Path6_AirborneMoverHitsSteepSlope_ReturnsSlid()
|
|
{
|
|
var (root, resolved) = BSPStepUpFixtures.SlopedUnwalkable();
|
|
|
|
float r = BSPStepUpFixtures.SphereRadius;
|
|
// Approach the slope mid-face from above.
|
|
var checkPos = new Vector3(0.5f, 0f, 1.0f + r * 0.5f);
|
|
var currPos = new Vector3(0.5f, 0f, 1.0f + r + 0.1f);
|
|
|
|
var t = new Transition();
|
|
t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r);
|
|
t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u);
|
|
t.ObjectInfo.State = ObjectInfoState.None; // airborne
|
|
|
|
var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r };
|
|
|
|
var result = BSPQuery.FindCollisions(
|
|
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");
|
|
}
|
|
|
|
// =========================================================================
|
|
// Group D — Phase L.2.3 regression tests
|
|
//
|
|
// Bugs caught by live testing 2026-04-29:
|
|
// D1 — walking into a too-tall wall must NOT clear ContactPlane (animation
|
|
// flickers to "falling" when contact is lost mid-step against a wall).
|
|
// D2 — Path 5 step-up must NOT recurse infinitely against a tall wall
|
|
// (retail guards step_sphere_up with `if (sp.step_up == 0)` per
|
|
// acclient_2013_pseudo_c.txt:272954). Without the guard, DoStepUp
|
|
// invokes DoStepDown which TransitionalInsert(5)'s into FindObjCollisions
|
|
// which hits the same wall AGAIN → recursive DoStepUp.
|
|
// =========================================================================
|
|
|
|
/// <summary>
|
|
/// L.2.3c regression: a grounded mover walking into a too-tall wall must
|
|
/// retain its ground contact across the failed step-up. Before the fix,
|
|
/// <c>DoStepUp</c> cleared <see cref="CollisionInfo.ContactPlaneValid"/>
|
|
/// unconditionally; on failure, RestoreCheckPos restored the position but
|
|
/// the contact plane stayed cleared, causing OnWalkable to drop and the
|
|
/// animation system to interpret the stuck-against-wall state as "airborne".
|
|
/// </summary>
|
|
[Fact]
|
|
public void D1_GroundedMover_TooTallWall_PreservesContactPlane()
|
|
{
|
|
var (root, resolved) = BSPStepUpFixtures.TallWall();
|
|
|
|
// Foot at z=0, walking into the wall.
|
|
var from = new Vector3(0.1f, 0f, 0f);
|
|
var to = new Vector3(0.6f, 0f, 0f);
|
|
|
|
// StepUpHeight 0.04m — too small to climb the 5m wall.
|
|
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight: 0.04f);
|
|
var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
|
|
|
|
t.FindTransitionalPosition(engine);
|
|
|
|
// After failed step-up + slide, the mover should still be considered
|
|
// grounded — either via the live contact plane, the last-known one,
|
|
// or the OnWalkable flag preserved by terrain re-detection.
|
|
bool stillGrounded = t.CollisionInfo.ContactPlaneValid
|
|
|| t.CollisionInfo.LastKnownContactPlaneValid
|
|
|| t.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
|
|
Assert.True(stillGrounded,
|
|
"Expected mover to still be grounded after walking into a too-tall " +
|
|
"wall (failed step-up should preserve LastKnownContactPlane).");
|
|
}
|
|
|
|
/// <summary>
|
|
/// L.2.3b regression: Path 5 dispatch must be guarded against re-entry while
|
|
/// a step-up is already in progress. Test runs <c>FindTransitionalPosition</c>
|
|
/// with a tight time budget and verifies it terminates cleanly. Without the
|
|
/// guard the recursive DoStepUp churns the contact plane until numAttempts
|
|
/// runs out — finishing in an inconsistent state.
|
|
/// </summary>
|
|
[Fact]
|
|
public void D2_GroundedMover_TallWall_DoesNotRecurseInfinitely()
|
|
{
|
|
var (root, resolved) = BSPStepUpFixtures.TallWall();
|
|
|
|
var from = new Vector3(0.1f, 0f, 0f);
|
|
var to = new Vector3(0.6f, 0f, 0f);
|
|
|
|
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight: 0.04f);
|
|
var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
|
|
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
t.FindTransitionalPosition(engine);
|
|
sw.Stop();
|
|
|
|
// Bounded execution: even with recursion, this is a 4-step movement.
|
|
// 100ms is generous; without the guard, recursion adds noticeable cost.
|
|
Assert.True(sw.ElapsedMilliseconds < 100,
|
|
$"Step-up against tall wall took {sw.ElapsedMilliseconds}ms — " +
|
|
"indicates Path 5 recursing through DoStepUp without guard.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// L.2c regression: an airborne mover jumping/falling into a vertical wall
|
|
/// must keep its vertical displacement. With no live or last-known contact
|
|
/// plane, SlideSphere must remove only the component into the wall; inventing
|
|
/// a flat UnitZ plane projects the displacement onto the wall/floor crease
|
|
/// and leaves the character stuck in falling animation against the wall.
|
|
/// </summary>
|
|
[Fact]
|
|
public void D3_AirborneMover_TallWall_PreservesVerticalMotion()
|
|
{
|
|
var (root, resolved) = BSPStepUpFixtures.TallWall();
|
|
|
|
var from = new Vector3(0.1f, 0f, 2.0f);
|
|
var to = new Vector3(0.6f, 0f, 1.5f);
|
|
|
|
var t = BSPStepUpFixtures.MakeAirborneTransition(from, to);
|
|
var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
|
|
|
|
t.FindTransitionalPosition(engine);
|
|
|
|
Assert.True(t.SpherePath.CurPos.Z < from.Z - 0.1f,
|
|
$"Expected airborne wall-slide to preserve downward motion; " +
|
|
$"from.Z={from.Z:F3}, CurPos.Z={t.SpherePath.CurPos.Z:F3}");
|
|
Assert.True(t.SpherePath.CurPos.X <= 0.5f - BSPStepUpFixtures.SphereRadius + PhysicsGlobals.EPSILON * 20f,
|
|
$"Expected wall to block X penetration; got CurPos.X={t.SpherePath.CurPos.X:F3}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// L.2c regression: if an airborne wall collision happens in a one-substep
|
|
/// frame, the collision normal has to survive into the next frame. Retail
|
|
/// does this with transient_state bit 2 + InitSlidingNormal. Without that,
|
|
/// 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 — slide-response divergence family (P1-era " +
|
|
"slide_sphere work made the first airborne wall frame slide in-frame " +
|
|
"to Z=1.92 instead of the L.2c-pinned hard stop at Z=2.0; the cached " +
|
|
"sliding-normal mechanism retail seeds via get_object_info " +
|
|
"(pc:279992, transient bit 4 → init_sliding_normal) only governs the " +
|
|
"NEXT frame, so which first-frame response is retail-faithful needs " +
|
|
"its own oracle read. NOT a cell-set problem — BR-7/A6.P4 left this " +
|
|
"byte-identical. See docs/ISSUES.md #116.")]
|
|
public void D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames()
|
|
{
|
|
var (root, resolved) = BSPStepUpFixtures.TallWall();
|
|
var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
|
|
var body = new PhysicsBody
|
|
{
|
|
Position = new Vector3(0.25f, 0f, 2.0f),
|
|
TransientState = TransientStateFlags.Active,
|
|
};
|
|
|
|
var frame1 = engine.ResolveWithTransition(
|
|
currentPos: body.Position,
|
|
targetPos: new Vector3(0.36f, 0f, 1.92f),
|
|
cellId: 0xA9B40001u,
|
|
sphereRadius: BSPStepUpFixtures.SphereRadius,
|
|
sphereHeight: 0f,
|
|
stepUpHeight: 0.04f,
|
|
stepDownHeight: 0.04f,
|
|
isOnGround: false,
|
|
body: body);
|
|
|
|
body.Position = frame1.Position;
|
|
|
|
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
|
"First airborne wall hit should cache SlidingNormal for the next frame.");
|
|
Assert.Equal(2.0f, frame1.Position.Z, precision: 3);
|
|
|
|
var frame2 = engine.ResolveWithTransition(
|
|
currentPos: body.Position,
|
|
targetPos: body.Position + new Vector3(0.11f, 0f, -0.08f),
|
|
cellId: 0xA9B40001u,
|
|
sphereRadius: BSPStepUpFixtures.SphereRadius,
|
|
sphereHeight: 0f,
|
|
stepUpHeight: 0.04f,
|
|
stepDownHeight: 0.04f,
|
|
isOnGround: false,
|
|
body: body);
|
|
|
|
Assert.True(frame2.Position.Z < frame1.Position.Z - 0.05f,
|
|
$"Expected cached wall-slide normal to allow falling on frame 2; " +
|
|
$"frame1.Z={frame1.Position.Z:F3}, frame2.Z={frame2.Position.Z:F3}");
|
|
Assert.InRange(frame2.Position.X, 0.24f, 0.31f);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Helpers
|
|
// =========================================================================
|
|
|
|
/// <summary>
|
|
/// Build a <see cref="PhysicsEngine"/> that serves one synthetic BSP object.
|
|
/// <paramref name="terrainZ"/> sets every terrain sample to the given height.
|
|
/// Use 0f for grounded tests (terrain flush with the BSP floor at z=0, so the
|
|
/// step-down probe finds ground and keeps Contact/OnWalkable set between steps).
|
|
/// Use -50f for tests where terrain must never interfere (airborne / roof landing).
|
|
/// </summary>
|
|
private static PhysicsEngine MakeTestEngine(
|
|
PhysicsBSPNode root,
|
|
Dictionary<ushort, ResolvedPolygon> resolved,
|
|
Vector3? objectPosition = null,
|
|
float terrainZ = 0f)
|
|
{
|
|
const uint LandblockId = 0xA9B4FFFFu;
|
|
const uint SyntheticGfxId = 0xDEADBEEFu;
|
|
|
|
var heights = new byte[81]; // all zero → uses index 0 from heightTable
|
|
var heightTab = new float[256];
|
|
for (int i = 0; i < 256; i++) heightTab[i] = terrainZ;
|
|
|
|
var engine = new PhysicsEngine();
|
|
engine.AddLandblock(
|
|
LandblockId,
|
|
new TerrainSurface(heights, heightTab),
|
|
Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f, worldOffsetY: 0f);
|
|
|
|
// Register the BSP physics into the data cache.
|
|
var cache = new PhysicsDataCache();
|
|
var bspTree = new DatReaderWriter.Types.PhysicsBSPTree { Root = root };
|
|
var physics = new GfxObjPhysics
|
|
{
|
|
BSP = bspTree,
|
|
PhysicsPolygons = new Dictionary<ushort, DatReaderWriter.Types.Polygon>(),
|
|
Vertices = new DatReaderWriter.Types.VertexArray(),
|
|
Resolved = resolved,
|
|
BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = Vector3.Zero, Radius = 15f },
|
|
};
|
|
cache.RegisterGfxObjForTest(SyntheticGfxId, physics);
|
|
engine.DataCache = cache;
|
|
|
|
// Register the object in the shadow registry so FindObjCollisions picks it up.
|
|
Vector3 pos = objectPosition ?? Vector3.Zero;
|
|
engine.ShadowObjects.Register(
|
|
entityId: SyntheticGfxId,
|
|
gfxObjId: SyntheticGfxId,
|
|
worldPos: pos,
|
|
rotation: Quaternion.Identity,
|
|
radius: 15f,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: LandblockId,
|
|
collisionType: ShadowCollisionType.BSP,
|
|
scale: 1.0f);
|
|
|
|
return engine;
|
|
}
|
|
}
|