fix(physics): restore retail cell availability semantics

This commit is contained in:
Erik 2026-07-31 14:22:45 +02:00
parent d3c0d9ec0e
commit 7716c2ee89
14 changed files with 393 additions and 62 deletions

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Numerics;
using DatReaderWriter.Enums;
@ -74,6 +75,64 @@ public class BuildShadowCellSetTests
};
}
private static CellPhysics MakeNullRootCell(Matrix4x4 worldTransform)
{
Matrix4x4.Invert(worldTransform, out var inv);
return new CellPhysics
{
WorldTransform = worldTransform,
InverseWorldTransform = inv,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree { Root = null },
FlatContainmentBsp = new FlatCellContainmentBsp(
-1,
ImmutableArray<FlatCellBspNode>.Empty),
};
}
private static CellPhysics MakeNullRootCellWithExteriorPortal(
Matrix4x4 worldTransform)
{
Matrix4x4.Invert(worldTransform, out var inv);
var portalPlane = new Plane(new Vector3(1f, 0f, 0f), -2.5f);
return new CellPhysics
{
WorldTransform = worldTransform,
InverseWorldTransform = inv,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree { Root = null },
FlatContainmentBsp = new FlatCellContainmentBsp(
-1,
ImmutableArray<FlatCellBspNode>.Empty),
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>
{
[10] = new ResolvedPolygon
{
Vertices =
[
new Vector3(2.5f, -2.5f, 0f),
new Vector3(2.5f, 2.5f, 0f),
new Vector3(2.5f, 2.5f, 5f),
new Vector3(2.5f, -2.5f, 5f),
],
Plane = portalPlane,
NumPoints = 4,
SidesType = CullMode.None,
},
},
Portals =
[
new PortalInfo(otherCellId: 0xFFFF, polygonId: 10, flags: 0),
],
};
}
private static void RegisterFlatTerrain(PhysicsDataCache cache)
=> cache.CellGraph.RegisterTerrain(
0xA9B40000u,
new TerrainSurface(new byte[81], new float[256]),
Vector3.Zero);
// ── Seeds ──────────────────────────────────────────────────────────
[Fact]
@ -129,6 +188,42 @@ public class BuildShadowCellSetTests
Assert.Equal(new[] { IndoorSeed }, set);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void IndoorSeed_RefloodsAfterNullRootPayloadHydrates(
bool useFlat)
{
var cache = new PhysicsDataCache
{
CollisionTraversalMode = useFlat
? CollisionTraversalMode.Flat
: CollisionTraversalMode.Graph,
};
Sphere[] sphere = One(new Vector3(2.4f, 0f, 2.5f), 0.5f);
IReadOnlyList<uint> unavailable = CellTransit.BuildShadowCellSet(
cache,
IndoorSeed,
sphere,
1,
isStatic: false);
Assert.Equal(new[] { IndoorSeed }, unavailable);
cache.RegisterCellStructForTest(
IndoorSeed,
MakeNullRootCellWithExteriorPortal(Matrix4x4.Identity));
IReadOnlyList<uint> hydrated = CellTransit.BuildShadowCellSet(
cache,
IndoorSeed,
sphere,
1,
isStatic: false);
Assert.Contains(IndoorSeed, hydrated);
Assert.Contains(hydrated, id => (id & 0xFFFFu) < 0x0100u);
}
[Fact]
public void OutdoorSeed_FloodsOverlappedLandcells_BlockCrossingMath()
{
@ -155,6 +250,7 @@ public class BuildShadowCellSetTests
// vestibule's shadow_object_list at registration via
// CLandCell::find_transit_cells → ... → check_building_transit.
var cache = new PhysicsDataCache();
RegisterFlatTerrain(cache);
cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(Matrix4x4.Identity));
var sphere = One(new Vector3(12f, 12f, 0f), 0.5f);
@ -188,6 +284,7 @@ public class BuildShadowCellSetTests
// other_portal_id = -1 (wire 0xFFFF) never admits its interior cell
// (CEnvCell::check_building_transit, 0x0052c5dc).
var cache = new PhysicsDataCache();
RegisterFlatTerrain(cache);
cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(Matrix4x4.Identity));
var sphere = One(new Vector3(12f, 12f, 0f), 0.5f);
@ -211,6 +308,63 @@ public class BuildShadowCellSetTests
Assert.DoesNotContain(NeighborCell, set);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void UnavailableOutdoorSeed_AddsOutsideButSkipsTransit_UntilTerrainHydrates(
bool useFlat)
{
var cache = new PhysicsDataCache
{
CollisionTraversalMode = useFlat
? CollisionTraversalMode.Flat
: CollisionTraversalMode.Graph,
};
cache.RegisterCellStructForTest(
NeighborCell,
MakeNullRootCell(Matrix4x4.Identity));
var sphere = One(new Vector3(12f, 12f, 0f), 0.5f);
IReadOnlyList<uint> seeded = CellTransit.BuildShadowCellSet(
cache,
0xA9B40001u,
sphere,
1,
isStatic: false);
uint landcell = seeded[0];
cache.RegisterBuildingForTest(landcell, new BuildingPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Portals =
[
new BldPortalInfo(NeighborCell, otherPortalId: 0, flags: 0),
],
});
IReadOnlyList<uint> unavailable = CellTransit.BuildShadowCellSet(
cache,
0xA9B40001u,
sphere,
1,
isStatic: false);
Assert.NotEmpty(unavailable);
Assert.All(unavailable, id => Assert.True((id & 0xFFFFu) < 0x0100u));
Assert.DoesNotContain(NeighborCell, unavailable);
RegisterFlatTerrain(cache);
IReadOnlyList<uint> hydrated = CellTransit.BuildShadowCellSet(
cache,
0xA9B40001u,
sphere,
1,
isStatic: false);
Assert.Contains(landcell, hydrated);
Assert.Contains(NeighborCell, hydrated);
}
// ── Exterior straddle from an indoor seed ──────────────────────────
[Fact]

View file

@ -11,14 +11,14 @@ namespace AcDream.Core.Tests.Physics;
public class CellGraphPopulationTests
{
[Fact]
public void CacheCellStruct_AddsEnvCellToGraph_EvenWhenPhysicsBspIsNull()
public void CacheCellStruct_PublishesLoadedCell_WhenPhysicsAndContainmentRootsAreNull()
{
var cache = new PhysicsDataCache();
var cellStruct = new CellStruct
{
VertexArray = new VertexArray { Vertices = new Dictionary<ushort, SWVertex>() },
Polygons = new Dictionary<ushort, Polygon>(),
// PhysicsBSP omitted (defaults to null) — triggers the null-BSP drop from _cellStruct
CellBSP = new CellBSPTree { Root = null },
};
var dat = new DatEnvCell
{
@ -29,8 +29,14 @@ public class CellGraphPopulationTests
cache.CacheCellStruct(0xA9B40174u, dat, cellStruct, Matrix4x4.Identity);
Assert.Null(cache.GetCellStruct(0xA9B40174u)); // dropped from physics cache
Assert.NotNull(cache.CellGraph.GetVisible(0xA9B40174u)); // but present in the graph
CellPhysics loaded = Assert.IsType<CellPhysics>(
cache.GetCellStruct(0xA9B40174u));
Assert.True(CollisionTraversal.HasCellContainment(cache, loaded));
Assert.True(CollisionTraversal.PointInsideCell(
cache,
loaded,
new Vector3(10_000f, -10_000f, 500f)));
Assert.NotNull(cache.CellGraph.GetVisible(0xA9B40174u));
Assert.IsType<EnvCell>(cache.CellGraph.GetVisible(0xA9B40174u));
}
}

View file

@ -8,16 +8,11 @@ namespace AcDream.Core.Tests.Physics;
public class CellTransitCheckBuildingTransitTests
{
[Fact]
public void BuildingPortalWithUnloadedCellBSP_NoCandidateAdded()
public void BuildingPortalWithLoadedNullRoot_CellIsAdmitted()
{
// Verifies the null-CellBSP guard: when the destination interior cell
// is cached but its CellBSP isn't yet loaded (or is structurally absent),
// CheckBuildingTransit must NOT add the cell to candidates — even though
// PointInsideCellBsp(null, _) returns true.
//
// Happy-path (CellBSP present, sphere inside) requires a synthetic
// CellBSPTree which is non-trivial to construct from DatReaderWriter
// types. Deferred to visual verification.
// Retail separates an unavailable CEnvCell lookup from an authored
// CellStruct whose cell_bsp root is null. The latter is loaded, and
// the null-root sphere query is the universal-inside base case.
// Building at world origin. One portal to interior cell 0xA9B40100.
var building = new BuildingPhysics
@ -33,14 +28,13 @@ public class CellTransitCheckBuildingTransitTests
},
};
// Interior cell with null CellBSP — PointInsideCellBsp(null, _) returns true,
// but CheckBuildingTransit guards on CellBSP?.Root being non-null, so this
// cell is skipped.
// Interior cell with an authored null containment root.
var interiorCell = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = new DatReaderWriter.Types.CellBSPTree { Root = null },
};
var cache = new PhysicsDataCache();
@ -53,8 +47,33 @@ public class CellTransitCheckBuildingTransitTests
sphereRadius: 0.5f,
candidates);
// CellBSP is null → containment guard (otherCell?.CellBSP?.Root is null)
// skips this cell. No candidate added.
Assert.Contains(0xA9B40100u, candidates);
}
[Fact]
public void BuildingPortalWithUnavailableCell_NoCandidateAdded()
{
var building = new BuildingPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Portals =
[
new BldPortalInfo(
otherCellId: 0xA9B40100u,
otherPortalId: 0,
flags: 0),
],
};
var candidates = new HashSet<uint>();
CellTransit.CheckBuildingTransit(
new PhysicsDataCache(),
building,
worldSphereCenter: Vector3.Zero,
sphereRadius: 0.5f,
candidates);
Assert.Empty(candidates);
}

View file

@ -315,10 +315,11 @@ public class CellTransitFindCellSetTests
}
[Fact]
public void IndoorSeed_CellWithoutBsp_CannotVerify_StaysCurrent()
public void IndoorSeed_LoadedNullRoot_IsUniversallyInside_StaysCurrent()
{
// Stale-beats-null while streaming hydrates: a registered cell with
// no CellBSP yet cannot be verified — trust the claim (no demotion).
// Retail distinguishes a failed cell lookup from a loaded CellStruct
// whose containment root is null. The latter is the BSP query's
// universally-inside base case, so the current cell wins immediately.
Matrix4x4.Invert(Matrix4x4.Identity, out var inv);
var cellNoBsp = new CellPhysics
{

View file

@ -160,4 +160,50 @@ public sealed class PhysicsDataCacheProductionTests
structure.ContainmentBsp,
runtimeCell.FlatContainmentBsp);
}
[Fact]
public void ProductionCellPublication_PreservesLoadedCellWithEmptyRoots()
{
const uint cellId = 0xA9B4_0174u;
PhysicsDataCache cache = PhysicsDataCache.CreateProduction();
var emptyPhysics = new FlatPhysicsBsp(
-1,
ImmutableArray<FlatPhysicsBspNode>.Empty,
ImmutableArray<int>.Empty,
FlatPolygonTable.Empty);
var emptyContainment = new FlatCellContainmentBsp(
-1,
ImmutableArray<FlatCellBspNode>.Empty);
var structure = new FlatCellStructureCollisionAsset(
emptyPhysics,
emptyContainment,
FlatPolygonTable.Empty);
var topology = new FlatEnvCellTopology(
ImmutableArray<FlatEnvCellPortal>.Empty,
ImmutableArray<uint>.Empty,
seenOutside: false);
cache.CacheCellStruct(
cellId,
new EnvCell(),
Matrix4x4.Identity,
structure,
topology);
CellPhysics loaded = Assert.IsType<CellPhysics>(
cache.GetCellStruct(cellId));
Assert.False(CollisionTraversal.HasPhysics(cache, loaded));
Assert.True(CollisionTraversal.HasCellContainment(cache, loaded));
Assert.True(CollisionTraversal.PointInsideCell(
cache,
loaded,
new Vector3(10_000f, -10_000f, 500f)));
var graphCell = Assert.IsType<AcDream.Core.World.Cells.EnvCell>(
cache.CellGraph.GetVisible(cellId));
Assert.True(graphCell.PointInCell(
new Vector3(10_000f, -10_000f, 500f)));
Assert.Equal(1, cache.CellStructCount);
Assert.Equal(1, cache.FlatCellStructCount);
Assert.Equal(0, cache.GraphCellStructCount);
}
}

View file

@ -27,6 +27,23 @@ public class EnvCellTests
public void PointInCell_NullBsp_Aabb_OutsideIsFalse()
=> Assert.False(Make(new Vector3(0,0,0), new Vector3(10,10,10)).PointInCell(new Vector3(20,5,5)));
[Fact]
public void PointInCell_LoadedNullRoot_IsUniversallyInside()
{
var cell = new EnvCell(
0xA9B4_0174u,
Matrix4x4.Identity,
Matrix4x4.Identity,
Vector3.Zero,
Vector3.One,
Array.Empty<UcgCellPortal>(),
Array.Empty<uint>(),
seenOutside: false,
containmentBsp: new CellBSPTree { Root = null });
Assert.True(cell.PointInCell(new Vector3(10_000f, -10_000f, 500f)));
}
[Fact]
public void PointInCell_TransformsWorldToLocalBeforeTesting()
{