diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs
index 98820720..f97d4025 100644
--- a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs
+++ b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs
@@ -188,9 +188,9 @@ internal sealed class WorldLifecycleResourceSnapshotSource
DatObjectCacheHits: datObjectCacheStats.Hits,
DatObjectCacheMisses: datObjectCacheStats.Misses,
DatObjectCacheEvictions: datObjectCacheStats.Evictions,
- PhysicsGraphGfxObjs: _physics.GfxObjCount,
- PhysicsGraphSetups: _physics.SetupCount,
- PhysicsGraphCells: _physics.CellStructCount,
+ PhysicsGraphGfxObjs: _physics.GraphGfxObjCount,
+ PhysicsGraphSetups: _physics.GraphSetupCount,
+ PhysicsGraphCells: _physics.GraphCellStructCount,
PhysicsFlatGfxObjs: _physics.FlatGfxObjCount,
PhysicsFlatSetups: _physics.FlatSetupCount,
PhysicsFlatCells: _physics.FlatCellStructCount,
diff --git a/src/AcDream.App/Physics/LiveCollisionAssetPublisher.cs b/src/AcDream.App/Physics/LiveCollisionAssetPublisher.cs
index 09e166af..7d52a487 100644
--- a/src/AcDream.App/Physics/LiveCollisionAssetPublisher.cs
+++ b/src/AcDream.App/Physics/LiveCollisionAssetPublisher.cs
@@ -5,10 +5,12 @@ using DatReaderWriter.DBObjs;
namespace AcDream.App.Physics;
///
-/// Publishes the graph and prepared collision views for DAT objects discovered
+/// Publishes package-prepared collision views for DAT objects discovered
/// through the live-object path. Streamed statics carry their prepared closure
/// on LandblockBuild; live CreateObject/appearance data arrives outside
-/// that transaction and therefore uses this matching strict publisher.
+/// that transaction and therefore uses this matching strict publisher. The
+/// production cache consumes source metadata transiently and never retains its
+/// parsed collision graph.
///
internal sealed class LiveCollisionAssetPublisher
{
diff --git a/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs b/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs
index 8d9bece5..47d1903a 100644
--- a/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs
+++ b/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs
@@ -43,11 +43,11 @@ internal sealed class LiveEntityCollisionBuilder
PhysicsDataCache physicsData,
LiveEntityDefaultPoseResolver defaultPose)
: this(
- id => physicsData.GetGfxObj(id)?.FlatPhysicsBsp?.RootIndex >= 0,
+ id => physicsData.GetFlatGfxObj(id)?.PhysicsBsp.RootIndex >= 0,
id =>
{
FlatPhysicsBsp? flat =
- physicsData.GetGfxObj(id)?.FlatPhysicsBsp;
+ physicsData.GetFlatGfxObj(id)?.PhysicsBsp;
return flat is { RootIndex: >= 0 }
? flat.Nodes[flat.RootIndex].BoundingSphere.Radius
: null;
diff --git a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
index 55195a3f..9a18c92b 100644
--- a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
+++ b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
@@ -268,7 +268,8 @@ internal sealed class LiveEntityMotionRuntimeController
public (float Radius, float Height) GetSetupCylinder(
uint serverGuid, AcDream.Core.World.WorldEntity entity)
{
- var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
+ FlatSetupCollision? setup =
+ _physicsDataCache.GetFlatSetup(entity.SourceGfxObjOrSetupId);
if (setup is null)
return (0f, 0f);
// Live spawns bake ObjScale into MeshRefs and never populate
diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs
index 6d9be87f..7d8dc419 100644
--- a/src/AcDream.App/Streaming/GpuWorldState.cs
+++ b/src/AcDream.App/Streaming/GpuWorldState.cs
@@ -634,7 +634,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
source.LandblockId,
source.Heightmap,
entities ?? new List(source.Entities),
- source.PhysicsDats);
+ PhysicsDatBundle.Empty);
private static List MutableEntities(LoadedLandblock landblock) =>
(List)landblock.Entities;
diff --git a/src/AcDream.App/Streaming/LandblockBuildFactory.cs b/src/AcDream.App/Streaming/LandblockBuildFactory.cs
index dffe9998..5be22537 100644
--- a/src/AcDream.App/Streaming/LandblockBuildFactory.cs
+++ b/src/AcDream.App/Streaming/LandblockBuildFactory.cs
@@ -101,9 +101,19 @@ public sealed class LandblockBuildFactory
return build;
}
+ AcDream.Core.World.LandblockCollisionBuild collisions =
+ BuildPreparedCollisionClosure(build.Landblock);
+ AcDream.Core.World.PhysicsDatBundle publicationDats =
+ (build.Landblock.PhysicsDats
+ ?? AcDream.Core.World.PhysicsDatBundle.Empty)
+ .WithoutCollisionGraphs();
return build with
{
- Collisions = BuildPreparedCollisionClosure(build.Landblock),
+ Landblock = build.Landblock with
+ {
+ PhysicsDats = publicationDats,
+ },
+ Collisions = collisions,
};
}
diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs
index 1d21c744..c2725c79 100644
--- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs
+++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs
@@ -402,16 +402,19 @@ public sealed class LandblockPhysicsPublisher
{
long cacheStarted = Stopwatch.GetTimestamp();
uint gfxObjectId = publication.GfxObjectIds[publication.GfxCursor];
- if (datBundle.GfxObjs.TryGetValue(gfxObjectId, out var gfx))
+ if (publication.Build.Collisions?.GfxObjs.TryGetValue(
+ gfxObjectId,
+ out FlatGfxObjCollisionAsset? prepared) == true)
{
- FlatGfxObjCollisionAsset? prepared = null;
- publication.Build.Collisions?.GfxObjs.TryGetValue(
- gfxObjectId,
- out prepared);
- _physicsDataCache.CacheGfxObj(
- gfxObjectId,
- gfx,
- prepared);
+ _physicsDataCache.CacheGfxObj(gfxObjectId, prepared);
+ }
+ else if (datBundle.GfxObjs.TryGetValue(
+ gfxObjectId,
+ out var source))
+ {
+ // Graph-oracle fixture seam. Production near builds always
+ // carry the strict prepared closure.
+ _physicsDataCache.CacheGfxObj(gfxObjectId, source);
}
publication.GfxCursor++;
_gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted;
@@ -420,13 +423,16 @@ public sealed class LandblockPhysicsPublisher
{
uint setupId =
publication.SetupObjectIds[publication.SetupCursor];
- if (datBundle.Setups.TryGetValue(setupId, out var setup))
- {
- FlatSetupCollision? prepared = null;
- publication.Build.Collisions?.Setups.TryGetValue(
+ if (publication.Build.Collisions?.Setups.TryGetValue(
setupId,
- out prepared);
- _physicsDataCache.CacheSetup(setupId, setup, prepared);
+ out FlatSetupCollision? prepared) == true)
+ {
+ _physicsDataCache.CacheSetup(setupId, prepared);
+ }
+ else if (datBundle.Setups.TryGetValue(setupId, out var source))
+ {
+ // Graph-oracle fixture seam only.
+ _physicsDataCache.CacheSetup(setupId, source);
}
publication.SetupCursor++;
}
@@ -505,8 +511,97 @@ public sealed class LandblockPhysicsPublisher
LoadedLandblock landblock = publication.Build.Landblock;
uint envCellId =
(landblock.LandblockId & 0xFFFF0000u) | (0x0100u + offset);
- if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell)
- || envCell.EnvironmentId == 0
+ if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell))
+ return;
+
+ if (publication.Build.Collisions is not { } collisions)
+ {
+ PublishGraphFixtureCell(
+ publication,
+ datBundle,
+ envCellId,
+ envCell);
+ return;
+ }
+
+ if (!collisions.CellStructures.TryGetValue(
+ envCellId,
+ out FlatCellStructureCollisionAsset? preparedStructure)
+ || !collisions.EnvCells.TryGetValue(
+ envCellId,
+ out FlatEnvCellTopology? preparedTopology))
+ {
+ return;
+ }
+
+ Quaternion rotation = envCell.Position.Orientation;
+ Vector3 cellOriginWorld = envCell.Position.Origin + publication.Origin;
+ Matrix4x4 physicsCellTransform =
+ Matrix4x4.CreateFromQuaternion(rotation)
+ * Matrix4x4.CreateTranslation(cellOriginWorld);
+
+ _physicsDataCache.CacheCellStruct(
+ envCellId,
+ envCell,
+ physicsCellTransform,
+ preparedStructure,
+ preparedTopology);
+
+ FlatPolygonTable physicsPolygons =
+ preparedStructure.PhysicsBsp.PolygonTable;
+
+ var portalPlanes = new List();
+ FlatPolygonTable portalPolygons = preparedStructure.PortalPolygons;
+ for (int portalIndex = 0;
+ portalIndex < preparedTopology.Portals.Length;
+ portalIndex++)
+ {
+ FlatEnvCellPortal portal = preparedTopology.Portals[portalIndex];
+ if ((uint)portal.PolygonIndex
+ >= (uint)portalPolygons.Polygons.Length)
+ {
+ continue;
+ }
+ FlatCollisionPolygon polygon =
+ portalPolygons.Polygons[portal.PolygonIndex];
+ if (polygon.VertexRange.Count < 3)
+ continue;
+ var portalVertices = new Vector3[polygon.VertexRange.Count];
+ for (int index = 0; index < portalVertices.Length; index++)
+ {
+ Vector3 local = portalPolygons.Vertices[
+ polygon.VertexRange.Start + index];
+ portalVertices[index] =
+ Vector3.Transform(local, rotation) + cellOriginWorld;
+ }
+
+ portalPlanes.Add(PortalPlane.FromVertices(
+ portalVertices.AsSpan(),
+ portal.OtherCellId,
+ envCellId & 0xFFFFu,
+ (ushort)portal.Flags));
+ }
+
+ publication.CellSurfaces.Add(new CellSurface(
+ envCellId,
+ physicsPolygons,
+ rotation,
+ cellOriginWorld));
+ publication.PortalPlanes.AddRange(portalPlanes);
+ }
+
+ ///
+ /// Parsed-graph publication is retained only for unit/conformance fixtures
+ /// constructed without a prepared collision closure. Production cannot
+ /// reach this branch because near-build admission requires that closure.
+ ///
+ private void PublishGraphFixtureCell(
+ LandblockPhysicsPublication publication,
+ PhysicsDatBundle datBundle,
+ uint envCellId,
+ DatReaderWriter.DBObjs.EnvCell envCell)
+ {
+ if (envCell.EnvironmentId == 0
|| !datBundle.Environments.TryGetValue(
0x0D000000u | envCell.EnvironmentId,
out var environment)
@@ -518,51 +613,33 @@ public sealed class LandblockPhysicsPublisher
}
Quaternion rotation = envCell.Position.Orientation;
- Vector3 cellOriginWorld = envCell.Position.Origin + publication.Origin;
+ Vector3 cellOriginWorld =
+ envCell.Position.Origin + publication.Origin;
Matrix4x4 physicsCellTransform =
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cellOriginWorld);
-
- FlatCellStructureCollisionAsset? preparedStructure = null;
- FlatEnvCellTopology? preparedTopology = null;
- publication.Build.Collisions?.CellStructures.TryGetValue(
- envCellId,
- out preparedStructure);
- publication.Build.Collisions?.EnvCells.TryGetValue(
- envCellId,
- out preparedTopology);
_physicsDataCache.CacheCellStruct(
envCellId,
envCell,
cellStruct,
- physicsCellTransform,
- preparedStructure,
- preparedTopology);
+ physicsCellTransform);
var worldVertices = new Dictionary(
cellStruct.VertexArray.Vertices.Count);
foreach ((ushort vertexId, var vertex) in
cellStruct.VertexArray.Vertices)
{
- Vector3 worldPosition =
+ worldVertices[vertexId] =
Vector3.Transform(vertex.Origin, rotation)
+ cellOriginWorld;
- worldVertices[(ushort)vertexId] = worldPosition;
}
var polygonVertexIds = new List>(
cellStruct.PhysicsPolygons.Count);
foreach (var polygon in cellStruct.PhysicsPolygons.Values)
- {
- var vertexIds = new List(polygon.VertexIds.Count);
- foreach (short vertexId in polygon.VertexIds)
- vertexIds.Add(vertexId);
- polygonVertexIds.Add(vertexIds);
- }
+ polygonVertexIds.Add([.. polygon.VertexIds]);
var portalPlanes = new List();
- // CellPortal.PolygonId indexes rendering Polygons, not
- // PhysicsPolygons (ACViewer EnvCell.find_transit_cells).
foreach (var portal in envCell.CellPortals)
{
if (!cellStruct.Polygons.TryGetValue(
@@ -589,7 +666,7 @@ public sealed class LandblockPhysicsPublisher
continue;
portalPlanes.Add(PortalPlane.FromVertices(
- portalVertices.AsSpan(),
+ portalVertices,
portal.OtherCellId,
envCellId & 0xFFFFu,
(ushort)portal.Flags));
@@ -689,17 +766,26 @@ public sealed class LandblockPhysicsPublisher
LogMultipartRegistration(landblock, entity, bspShapes);
}
- SetupPhysics? setup =
- _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
+ FlatSetupCollision? setup =
+ _physicsDataCache.GetFlatSetup(entity.SourceGfxObjOrSetupId);
+ if (setup is null
+ && _physicsDataCache.GetSetup(
+ entity.SourceGfxObjOrSetupId) is { } graphSetup)
+ {
+ // Graph-oracle fixture seam only. Production Setup publication is
+ // already package-flat and never allocates this conversion.
+ setup = FlatCollisionAssetBuilder.FlattenSetup(graphSetup);
+ }
if (setup is not null && entityBspCount == 0)
{
float scale = entity.Scale > 0f ? entity.Scale : 1f;
var setupShapes = new List();
for (int cylinderIndex = 0;
- cylinderIndex < setup.CylSpheres.Count;
+ cylinderIndex < setup.Cylinders.Length;
cylinderIndex++)
{
- CylSphere cylinder = setup.CylSpheres[cylinderIndex];
+ FlatCollisionCylinder cylinder =
+ setup.Cylinders[cylinderIndex];
float radius = cylinder.Radius * scale;
float baseHeight = cylinder.Height > 0f
? cylinder.Height
@@ -719,13 +805,14 @@ public sealed class LandblockPhysicsPublisher
CylHeight: height));
}
- if (setup.CylSpheres.Count == 0)
+ if (setup.Cylinders.Length == 0)
{
for (int sphereIndex = 0;
- sphereIndex < setup.Spheres.Count;
+ sphereIndex < setup.Spheres.Length;
sphereIndex++)
{
- Sphere sphere = setup.Spheres[sphereIndex];
+ FlatCollisionSphere sphere =
+ setup.Spheres[sphereIndex];
if (sphere.Radius <= 0f)
continue;
@@ -746,8 +833,8 @@ public sealed class LandblockPhysicsPublisher
}
}
- if (setup.CylSpheres.Count == 0
- && setup.Spheres.Count == 0
+ if (setup.Cylinders.Length == 0
+ && setup.Spheres.Length == 0
&& setup.Radius > 0f)
{
float radius = setup.Radius * scale;
diff --git a/src/AcDream.Core/Physics/CellSurface.cs b/src/AcDream.Core/Physics/CellSurface.cs
index bd792dcc..9acc2667 100644
--- a/src/AcDream.Core/Physics/CellSurface.cs
+++ b/src/AcDream.Core/Physics/CellSurface.cs
@@ -73,6 +73,53 @@ public sealed class CellSurface
}
}
+ ///
+ /// Construct directly from the immutable production polygon table. This
+ /// preserves the same polygon/fan order as the dictionary fixture path
+ /// without reconstructing a temporary vertex dictionary and nested ID
+ /// lists during publication.
+ ///
+ public CellSurface(
+ uint cellId,
+ FlatPolygonTable polygons,
+ Quaternion rotation,
+ Vector3 translation)
+ {
+ ArgumentNullException.ThrowIfNull(polygons);
+ CellId = cellId;
+ int triangleCount = 0;
+ for (int i = 0; i < polygons.Polygons.Length; i++)
+ triangleCount += Math.Max(0, polygons.Polygons[i].VertexRange.Count - 2);
+ _triangles = new List<(Vector3, Vector3, Vector3)>(triangleCount);
+
+ for (int polygonIndex = 0;
+ polygonIndex < polygons.Polygons.Length;
+ polygonIndex++)
+ {
+ FlatIndexRange range =
+ polygons.Polygons[polygonIndex].VertexRange;
+ if (range.Count < 3)
+ continue;
+
+ Vector3 first =
+ Vector3.Transform(polygons.Vertices[range.Start], rotation)
+ + translation;
+ Vector3 previous =
+ Vector3.Transform(polygons.Vertices[range.Start + 1], rotation)
+ + translation;
+ for (int vertexOffset = 2;
+ vertexOffset < range.Count;
+ vertexOffset++)
+ {
+ Vector3 current = Vector3.Transform(
+ polygons.Vertices[range.Start + vertexOffset],
+ rotation) + translation;
+ _triangles.Add((first, previous, current));
+ previous = current;
+ }
+ }
+ }
+
///
/// Project (worldX, worldY) onto this cell's floor polygons and
/// return the Z. Returns null if outside all floor polygons.
diff --git a/src/AcDream.Core/Physics/CollisionTraversal.cs b/src/AcDream.Core/Physics/CollisionTraversal.cs
index a054b3e3..d75f4bea 100644
--- a/src/AcDream.Core/Physics/CollisionTraversal.cs
+++ b/src/AcDream.Core/Physics/CollisionTraversal.cs
@@ -232,7 +232,7 @@ internal static class CollisionTraversal
flatShadow.BeginGraphPass();
try
{
- graphRefereeResult = gfxObject.BSP.Root is not null;
+ graphRefereeResult = gfxObject.BSP?.Root is not null;
}
catch (Exception fault)
{
@@ -268,7 +268,7 @@ internal static class CollisionTraversal
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
- return gfxObject.BSP.Root is not null;
+ return gfxObject.BSP?.Root is not null;
bool flatResult = false;
Exception? flatFault = null;
@@ -287,7 +287,7 @@ internal static class CollisionTraversal
shadow.EndFlatPass();
}
- bool graphResult = gfxObject.BSP.Root is not null;
+ bool graphResult = gfxObject.BSP?.Root is not null;
if (flatFault is null)
{
shadow.RecordBoolean(
@@ -887,7 +887,7 @@ internal static class CollisionTraversal
try
{
graphRefereeState = BSPQuery.FindCollisions(
- gfxObject.BSP.Root,
+ gfxObject.BSP!.Root!,
gfxObject.Resolved,
graphTransition!,
localSphereCenter,
@@ -951,7 +951,7 @@ internal static class CollisionTraversal
if (shadow is null || !shadow.TrySample(out long sample))
{
return BSPQuery.FindCollisions(
- gfxObject.BSP.Root,
+ gfxObject.BSP!.Root!,
gfxObject.Resolved,
transition,
localSphereCenter,
@@ -999,7 +999,7 @@ internal static class CollisionTraversal
}
TransitionState graphState = BSPQuery.FindCollisions(
- gfxObject.BSP.Root,
+ gfxObject.BSP!.Root!,
gfxObject.Resolved,
transition,
localSphereCenter,
diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs
index 3f6a7ba2..91e60ade 100644
--- a/src/AcDream.Core/Physics/PhysicsDataCache.cs
+++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs
@@ -13,9 +13,10 @@ namespace AcDream.Core.Physics;
/// Thread-safe cache of physics-relevant data extracted from GfxObj and Setup
/// dat objects during streaming and live-object materialization. Prepared
/// payloads are built/read off the hot path; one update-thread publisher
-/// installs prepared production records and, during the I6 acceptance window,
-/// their graph referee. ConcurrentDictionary keeps diagnostics and tooling
-/// reads safe without a global lock.
+/// installs prepared production records without retaining the parsed DAT
+/// collision graph. The public constructor remains the graph-oracle seam for
+/// conformance fixtures and bake/equivalence tooling. ConcurrentDictionary
+/// keeps diagnostics and tooling reads safe without a global lock.
///
public sealed class PhysicsDataCache
{
@@ -41,7 +42,8 @@ public sealed class PhysicsDataCache
private PhysicsDataCache(bool requirePreparedCollision)
{
_requirePreparedCollision = requirePreparedCollision;
- if (PhysicsDiagnostics.CollisionShadowSampleEvery > 0)
+ if (!requirePreparedCollision
+ && PhysicsDiagnostics.CollisionShadowSampleEvery > 0)
{
CollisionShadow = new CollisionShadowVerifier(
PhysicsDiagnostics.CollisionShadowSampleEvery,
@@ -52,7 +54,7 @@ public sealed class PhysicsDataCache
///
/// Creates the gameplay cache with Slice I6's prepared flat collision
/// representation authoritative. The public constructor deliberately
- /// remains the graph-oracle fixture seam until the I6 referee is removed.
+ /// remains the graph-oracle fixture seam for conformance and bake tooling.
///
public static PhysicsDataCache CreateProduction()
{
@@ -69,9 +71,8 @@ public sealed class PhysicsDataCache
CollisionShadow?.Stats ?? default;
///
- /// Slice I graph/flat differential selector. Production is flat-authoritative
- /// from I6 onward. Graph remains an explicit fixture/oracle mode until the
- /// referee is removed after acceptance.
+ /// Slice I graph/flat differential selector. Production is permanently
+ /// flat-authoritative. Graph remains an explicit fixture/oracle mode.
///
internal CollisionTraversalMode CollisionTraversalMode { get; set; } =
CollisionTraversalMode.Graph;
@@ -106,6 +107,15 @@ public sealed class PhysicsDataCache
GfxObj gfxObj,
FlatGfxObjCollisionAsset? prepared = null)
{
+ ArgumentNullException.ThrowIfNull(gfxObj);
+
+ if (_requirePreparedCollision
+ && prepared is null
+ && _flatGfxObj.TryGetValue(gfxObjId, out var retainedPrepared))
+ {
+ prepared = retainedPrepared;
+ }
+
if (_requirePreparedCollision &&
gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics) &&
gfxObj.PhysicsBSP?.Root is not null &&
@@ -115,6 +125,13 @@ public sealed class PhysicsDataCache
throw MissingPreparedCollision("GfxObj", gfxObjId);
}
+ if (_requirePreparedCollision)
+ {
+ if (prepared is not null)
+ CachePreparedGfxObj(gfxObjId, prepared);
+ return;
+ }
+
if (prepared is not null)
_flatGfxObj.TryAdd(gfxObjId, prepared);
@@ -169,6 +186,53 @@ public sealed class PhysicsDataCache
}
}
+ ///
+ /// Publishes one package-prepared GfxObj without retaining its parsed DAT
+ /// BSP, polygon dictionary, or vertex array.
+ ///
+ public void CacheGfxObj(
+ uint gfxObjId,
+ FlatGfxObjCollisionAsset prepared)
+ {
+ ArgumentNullException.ThrowIfNull(prepared);
+ CachePreparedGfxObj(gfxObjId, prepared);
+ }
+
+ private void CachePreparedGfxObj(
+ uint gfxObjId,
+ FlatGfxObjCollisionAsset prepared)
+ {
+ _flatGfxObj.TryAdd(gfxObjId, prepared);
+ if (prepared.VisualBounds is { } bounds)
+ {
+ _visualBounds.TryAdd(gfxObjId, new GfxObjVisualBounds
+ {
+ Min = bounds.Min,
+ Max = bounds.Max,
+ Center = bounds.Center,
+ Radius = bounds.Radius,
+ HalfExtents = bounds.HalfExtents,
+ });
+ }
+
+ if (prepared.PhysicsBsp.RootIndex < 0)
+ return;
+
+ FlatCollisionSphere root =
+ prepared.PhysicsBsp.Nodes[prepared.PhysicsBsp.RootIndex]
+ .BoundingSphere;
+ _gfxObj.TryAdd(gfxObjId, new GfxObjPhysics
+ {
+ SourceId = gfxObjId,
+ BoundingSphere = new Sphere
+ {
+ Origin = root.Origin,
+ Radius = root.Radius,
+ },
+ FlatPhysicsBsp = prepared.PhysicsBsp,
+ });
+ }
+
///
/// Get the cached visual AABB for a GfxObj, or null if not cached.
///
@@ -230,11 +294,29 @@ public sealed class PhysicsDataCache
Setup setup,
FlatSetupCollision? prepared = null)
{
+ ArgumentNullException.ThrowIfNull(setup);
+
+ if (_requirePreparedCollision
+ && prepared is null
+ && _flatSetup.TryGetValue(setupId, out var retainedPrepared))
+ {
+ prepared = retainedPrepared;
+ }
+
if (_requirePreparedCollision &&
prepared is null &&
!_flatSetup.ContainsKey(setupId))
throw MissingPreparedCollision("Setup", setupId);
+ if (_requirePreparedCollision)
+ {
+ CachePreparedSetup(
+ setupId,
+ prepared
+ ?? throw MissingPreparedCollision("Setup", setupId));
+ return;
+ }
+
if (prepared is not null)
_flatSetup.TryAdd(setupId, prepared);
@@ -257,6 +339,30 @@ public sealed class PhysicsDataCache
};
}
+ ///
+ /// Publishes one package-prepared Setup without retaining parsed DBObj
+ /// cylinder/sphere lists.
+ ///
+ public void CacheSetup(uint setupId, FlatSetupCollision prepared)
+ {
+ ArgumentNullException.ThrowIfNull(prepared);
+ CachePreparedSetup(setupId, prepared);
+ }
+
+ private void CachePreparedSetup(uint setupId, FlatSetupCollision prepared)
+ {
+ _flatSetup.TryAdd(setupId, prepared);
+ _setup.TryAdd(setupId, new SetupPhysics
+ {
+ SourceId = setupId,
+ Height = prepared.Height,
+ Radius = prepared.Radius,
+ StepUpHeight = prepared.StepUpHeight,
+ StepDownHeight = prepared.StepDownHeight,
+ FlatCollision = prepared,
+ });
+ }
+
///
/// Extract and cache the physics BSP + polygon data from a CellStruct
/// (indoor room geometry). No-ops if the id is already cached or the
@@ -270,6 +376,17 @@ public sealed class PhysicsDataCache
FlatCellStructureCollisionAsset? preparedStructure = null,
FlatEnvCellTopology? preparedTopology = null)
{
+ ArgumentNullException.ThrowIfNull(envCell);
+ ArgumentNullException.ThrowIfNull(cellStruct);
+
+ if (_requirePreparedCollision)
+ {
+ if (preparedStructure is null)
+ _flatCellStruct.TryGetValue(envCellId, out preparedStructure);
+ if (preparedTopology is null)
+ _flatEnvCell.TryGetValue(envCellId, out preparedTopology);
+ }
+
if (_requirePreparedCollision &&
preparedStructure is null &&
!_flatCellStruct.ContainsKey(envCellId))
@@ -284,6 +401,19 @@ public sealed class PhysicsDataCache
if (preparedTopology is not null)
_flatEnvCell.TryAdd(envCellId, preparedTopology);
+ if (_requirePreparedCollision)
+ {
+ CachePreparedCellStruct(
+ envCellId,
+ envCell,
+ worldTransform,
+ preparedStructure
+ ?? throw MissingPreparedCollision("CellStruct", envCellId),
+ preparedTopology
+ ?? throw MissingPreparedCollision("EnvCell topology", envCellId));
+ return;
+ }
+
// UCG Stage 1: register in the unified graph for ALL cells — before the
// idempotency + null-BSP guards below, so BSP-less cells are still included.
if (!CellGraph.Contains(envCellId))
@@ -450,6 +580,83 @@ public sealed class PhysicsDataCache
}
}
+ ///
+ /// Publishes one package-prepared EnvCell without retaining its parsed DAT
+ /// CellStruct, BSP trees, polygon dictionaries, or vertex arrays.
+ ///
+ public void CacheCellStruct(
+ uint envCellId,
+ DatReaderWriter.DBObjs.EnvCell envCell,
+ Matrix4x4 worldTransform,
+ FlatCellStructureCollisionAsset preparedStructure,
+ FlatEnvCellTopology preparedTopology)
+ {
+ ArgumentNullException.ThrowIfNull(envCell);
+ ArgumentNullException.ThrowIfNull(preparedStructure);
+ ArgumentNullException.ThrowIfNull(preparedTopology);
+ CachePreparedCellStruct(
+ envCellId,
+ envCell,
+ worldTransform,
+ preparedStructure,
+ preparedTopology);
+ }
+
+ private void CachePreparedCellStruct(
+ uint envCellId,
+ DatReaderWriter.DBObjs.EnvCell envCell,
+ Matrix4x4 worldTransform,
+ FlatCellStructureCollisionAsset preparedStructure,
+ FlatEnvCellTopology preparedTopology)
+ {
+ _flatCellStruct.TryAdd(envCellId, preparedStructure);
+ _flatEnvCell.TryAdd(envCellId, preparedTopology);
+
+ if (!CellGraph.Contains(envCellId))
+ {
+ CellGraph.Add(UcgEnvCell.FromPrepared(
+ envCellId,
+ worldTransform,
+ preparedStructure,
+ preparedTopology));
+ }
+
+ // Preserve CacheCellStruct's existing distinction: BSP-less cells
+ // participate in the cell graph but do not masquerade as hydrated
+ // collision cells.
+ if (preparedStructure.PhysicsBsp.RootIndex < 0)
+ return;
+ if (_cellStruct.ContainsKey(envCellId))
+ return;
+
+ Matrix4x4.Invert(worldTransform, out Matrix4x4 inverseTransform);
+ var portals = new List(preparedTopology.Portals.Length);
+ for (int i = 0; i < preparedTopology.Portals.Length; i++)
+ {
+ FlatEnvCellPortal portal = preparedTopology.Portals[i];
+ portals.Add(new PortalInfo(
+ portal.OtherCellId,
+ portal.PolygonId,
+ portal.Flags));
+ }
+
+ _cellStruct.TryAdd(envCellId, new CellPhysics
+ {
+ SourceId = envCellId,
+ WorldTransform = worldTransform,
+ InverseWorldTransform = inverseTransform,
+ Resolved = new Dictionary(),
+ FlatPhysicsBsp = preparedStructure.PhysicsBsp,
+ FlatContainmentBsp = preparedStructure.ContainmentBsp,
+ FlatPortalPolygons = preparedStructure.PortalPolygons,
+ FlatTopology = preparedTopology,
+ Portals = portals,
+ VisibleCellIds = new HashSet(
+ preparedTopology.VisibleCellIds),
+ SeenOutside = preparedTopology.SeenOutside,
+ });
+ }
+
///
/// Pre-resolve all physics polygons: lookup vertex positions from VertexArray
/// and compute the face plane. Matches ACE's Polygon constructor which calls
@@ -533,6 +740,54 @@ public sealed class PhysicsDataCache
public int FlatSetupCount => _flatSetup.Count;
public int FlatCellStructCount => _flatCellStruct.Count;
public int FlatEnvCellCount => _flatEnvCell.Count;
+ public int GraphGfxObjCount => CountRetainedGfxObjGraphs();
+ public int GraphSetupCount => CountRetainedSetupGraphs();
+ public int GraphCellStructCount => CountRetainedCellGraphs();
+
+ private int CountRetainedGfxObjGraphs()
+ {
+ int count = 0;
+ foreach (GfxObjPhysics value in _gfxObj.Values)
+ {
+ if (value.BSP is not null
+ || value.PhysicsPolygons is not null
+ || value.Vertices is not null
+ || value.Resolved.Count != 0)
+ {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private int CountRetainedSetupGraphs()
+ {
+ int count = 0;
+ foreach (SetupPhysics value in _setup.Values)
+ {
+ if (value.CylSpheres.Count != 0 || value.Spheres.Count != 0)
+ count++;
+ }
+ return count;
+ }
+
+ private int CountRetainedCellGraphs()
+ {
+ int count = 0;
+ foreach (CellPhysics value in _cellStruct.Values)
+ {
+ if (value.BSP is not null
+ || value.CellBSP is not null
+ || value.PhysicsPolygons is not null
+ || value.Vertices is not null
+ || value.Resolved.Count != 0
+ || value.PortalPolygons is not null)
+ {
+ count++;
+ }
+ }
+ return count;
+ }
///
/// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached
@@ -682,16 +937,16 @@ public sealed class ResolvedPolygon
public sealed class GfxObjPhysics
{
public uint SourceId { get; init; }
- public required PhysicsBSPTree BSP { get; init; }
- public required Dictionary PhysicsPolygons { get; init; }
+ public PhysicsBSPTree? BSP { get; init; }
+ public Dictionary? PhysicsPolygons { get; init; }
public Sphere? BoundingSphere { get; init; }
- public required VertexArray Vertices { get; init; }
+ public VertexArray? Vertices { get; init; }
///
/// Pre-resolved polygon data with vertex positions and computed planes.
/// Populated once at cache time so BSP queries don't pay per-test lookup cost.
///
- public required Dictionary Resolved { get; init; }
+ public Dictionary Resolved { get; init; } = new();
///
/// Prepared integer-indexed representation. Slice I5 guarantees this for
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 993dba7e..94771c05 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -2225,7 +2225,9 @@ public sealed class Transition
worldVertices = System.Array.Empty();
hitPolyId = 0;
- if (cellPhysics.BSP?.Root is null) return false;
+ bool useFlat = cellPhysics.FlatPhysicsBsp is { RootIndex: >= 0 };
+ if (!useFlat && cellPhysics.BSP?.Root is null)
+ return false;
// Save/restore WalkableAllowance: CPolygon::walkable_hits_sphere reads
// path.WalkableAllowance (acclient_2013_pseudo_c.txt:323010). For
@@ -2238,23 +2240,40 @@ public sealed class Transition
this.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
ResolvedPolygon? hitPoly = null;
+ int flatHitIndex = -1;
ushort hitId = 0;
Vector3 adjustedCenter;
bool found;
try
{
- found = BSPQuery.FindWalkableSphere(
- cellPhysics.BSP.Root,
- cellPhysics.Resolved,
- this,
- localFootCenter,
- sphereRadius,
- INDOOR_WALKABLE_PROBE_DISTANCE,
- Vector3.UnitZ, // local Z is up for indoor cells (identity transform)
- out hitPoly,
- out hitId,
- out adjustedCenter);
+ if (useFlat)
+ {
+ found = FlatBspQuery.FindWalkableSphere(
+ cellPhysics.FlatPhysicsBsp!,
+ this,
+ localFootCenter,
+ sphereRadius,
+ INDOOR_WALKABLE_PROBE_DISTANCE,
+ Vector3.UnitZ,
+ out flatHitIndex,
+ out hitId,
+ out adjustedCenter);
+ }
+ else
+ {
+ found = BSPQuery.FindWalkableSphere(
+ cellPhysics.BSP!.Root!,
+ cellPhysics.Resolved,
+ this,
+ localFootCenter,
+ sphereRadius,
+ INDOOR_WALKABLE_PROBE_DISTANCE,
+ Vector3.UnitZ,
+ out hitPoly,
+ out hitId,
+ out adjustedCenter);
+ }
}
finally
{
@@ -2267,19 +2286,64 @@ public sealed class Transition
// path (SampleTerrainWalkable returns only plane + vertices, no adjusted
// sphere). The local is held only to satisfy the out param.
- if (!found || hitPoly is null) return false;
+ if (!found)
+ return false;
- // Transform hit polygon's plane + vertices to world space. Math is
- // unchanged from the previous TryFindIndoorWalkablePlane implementation.
- var worldNormal = Vector3.TransformNormal(hitPoly.Plane.Normal, cellPhysics.WorldTransform);
- worldNormal = Vector3.Normalize(worldNormal);
- var worldV0 = Vector3.Transform(hitPoly.Vertices[0], cellPhysics.WorldTransform);
- float worldD = -Vector3.Dot(worldNormal, worldV0);
- worldPlane = new System.Numerics.Plane(worldNormal, worldD);
+ Plane localPlane;
+ if (useFlat)
+ {
+ FlatPolygonTable table =
+ cellPhysics.FlatPhysicsBsp!.PolygonTable;
+ if ((uint)flatHitIndex >= (uint)table.Polygons.Length)
+ return false;
+ FlatCollisionPolygon polygon = table.Polygons[flatHitIndex];
+ localPlane = polygon.Plane;
- worldVertices = new Vector3[hitPoly.Vertices.Length];
- for (int i = 0; i < hitPoly.Vertices.Length; i++)
- worldVertices[i] = Vector3.Transform(hitPoly.Vertices[i], cellPhysics.WorldTransform);
+ var worldNormal = Vector3.TransformNormal(
+ localPlane.Normal,
+ cellPhysics.WorldTransform);
+ worldNormal = Vector3.Normalize(worldNormal);
+ var worldV0 = Vector3.Transform(
+ table.Vertices[polygon.VertexRange.Start],
+ cellPhysics.WorldTransform);
+ float worldD = -Vector3.Dot(worldNormal, worldV0);
+ worldPlane = new System.Numerics.Plane(worldNormal, worldD);
+
+ worldVertices = new Vector3[polygon.VertexRange.Count];
+ for (int i = 0; i < worldVertices.Length; i++)
+ {
+ worldVertices[i] = Vector3.Transform(
+ table.Vertices[polygon.VertexRange.Start + i],
+ cellPhysics.WorldTransform);
+ }
+ }
+ else
+ {
+ if (hitPoly is null)
+ return false;
+ localPlane = hitPoly.Plane;
+ Vector3[] localVertices = hitPoly.Vertices;
+
+ // Transform hit polygon's plane + vertices to world space. Math is
+ // unchanged from the previous graph implementation.
+ var worldNormal = Vector3.TransformNormal(
+ localPlane.Normal,
+ cellPhysics.WorldTransform);
+ worldNormal = Vector3.Normalize(worldNormal);
+ var worldV0 = Vector3.Transform(
+ localVertices[0],
+ cellPhysics.WorldTransform);
+ float worldD = -Vector3.Dot(worldNormal, worldV0);
+ worldPlane = new System.Numerics.Plane(worldNormal, worldD);
+
+ worldVertices = new Vector3[localVertices.Length];
+ for (int i = 0; i < localVertices.Length; i++)
+ {
+ worldVertices[i] = Vector3.Transform(
+ localVertices[i],
+ cellPhysics.WorldTransform);
+ }
+ }
hitPolyId = hitId;
return true;
diff --git a/src/AcDream.Core/World/Cells/EnvCell.cs b/src/AcDream.Core/World/Cells/EnvCell.cs
index eb14030b..c4b96750 100644
--- a/src/AcDream.Core/World/Cells/EnvCell.cs
+++ b/src/AcDream.Core/World/Cells/EnvCell.cs
@@ -87,6 +87,82 @@ public sealed class EnvCell : ObjCell
seenOutside, cellStruct.CellBSP, flatContainmentBsp);
}
+ ///
+ /// Build the production cell-graph record from package-prepared collision
+ /// arrays. No parsed DAT CellStruct/BSP/polygon/vertex graph is retained.
+ ///
+ public static EnvCell FromPrepared(
+ uint id,
+ Matrix4x4 worldTransform,
+ FlatCellStructureCollisionAsset structure,
+ FlatEnvCellTopology topology)
+ {
+ ArgumentNullException.ThrowIfNull(structure);
+ ArgumentNullException.ThrowIfNull(topology);
+ Matrix4x4.Invert(worldTransform, out Matrix4x4 inverse);
+
+ Vector3 min = new(float.MaxValue);
+ Vector3 max = new(float.MinValue);
+ IncludeVertices(
+ structure.PhysicsBsp.PolygonTable.Vertices,
+ ref min,
+ ref max);
+ IncludeVertices(
+ structure.PortalPolygons.Vertices,
+ ref min,
+ ref max);
+ if (min.X == float.MaxValue)
+ {
+ min = Vector3.Zero;
+ max = Vector3.Zero;
+ }
+
+ var portals = new List(topology.Portals.Length);
+ for (int i = 0; i < topology.Portals.Length; i++)
+ {
+ FlatEnvCellPortal portal = topology.Portals[i];
+ FlatCollisionPolygon polygon =
+ structure.PortalPolygons.Polygons[portal.PolygonIndex];
+ var vertices = new Vector3[polygon.VertexRange.Count];
+ structure.PortalPolygons.Vertices.AsSpan(
+ polygon.VertexRange.Start,
+ polygon.VertexRange.Count).CopyTo(vertices);
+ portals.Add(new CellPortal(
+ // Preserve FromDat's existing representation exactly. CellGraph
+ // portal-ID normalization is a separate behavioral concern and
+ // must not be changed by this graph-residency cleanup.
+ portal.OtherCellId,
+ otherPortalId: 0,
+ portal.PolygonId,
+ portal.Flags,
+ vertices));
+ }
+
+ return new EnvCell(
+ id,
+ worldTransform,
+ inverse,
+ min,
+ max,
+ portals,
+ topology.VisibleCellIds,
+ topology.SeenOutside,
+ containmentBsp: null,
+ structure.ContainmentBsp);
+ }
+
+ private static void IncludeVertices(
+ System.Collections.Immutable.ImmutableArray vertices,
+ ref Vector3 min,
+ ref Vector3 max)
+ {
+ for (int i = 0; i < vertices.Length; i++)
+ {
+ min = Vector3.Min(min, vertices[i]);
+ max = Vector3.Max(max, vertices[i]);
+ }
+ }
+
private static IReadOnlyList ResolvePortalPolygon(CellStruct cellStruct, ushort polygonId)
{
if (!cellStruct.Polygons.TryGetValue(polygonId, out var poly) || poly.VertexIds.Count < 3)
diff --git a/src/AcDream.Core/World/PhysicsDatBundle.cs b/src/AcDream.Core/World/PhysicsDatBundle.cs
index 42606e2b..ba66f59b 100644
--- a/src/AcDream.Core/World/PhysicsDatBundle.cs
+++ b/src/AcDream.Core/World/PhysicsDatBundle.cs
@@ -26,4 +26,17 @@ public sealed record PhysicsDatBundle(
new Dictionary(),
new Dictionary(),
new Dictionary());
+
+ ///
+ /// Drops parsed collision-bearing GfxObj and Environment/CellStruct graphs
+ /// after their immutable package assets have been resolved. The remaining
+ /// records are the small publication facts still consumed by building,
+ /// placement, and static-light owners.
+ ///
+ public PhysicsDatBundle WithoutCollisionGraphs() => new(
+ Info,
+ EnvCells,
+ Empty.Environments,
+ Setups,
+ Empty.GfxObjs);
}
diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateCollisionResidencyTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateCollisionResidencyTests.cs
new file mode 100644
index 00000000..872569e5
--- /dev/null
+++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateCollisionResidencyTests.cs
@@ -0,0 +1,39 @@
+using AcDream.App.Streaming;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.App.Tests.Streaming;
+
+public sealed class GpuWorldStateCollisionResidencyTests
+{
+ [Fact]
+ public void StableSpatialState_DropsPublicationDatBundle()
+ {
+ const uint landblockId = 0xA9B4_FFFFu;
+ var bundle = new PhysicsDatBundle(
+ new LandBlockInfo(),
+ new Dictionary
+ {
+ [0xA9B4_0100u] = new EnvCell(),
+ },
+ new Dictionary(),
+ new Dictionary
+ {
+ [0x0200_0001u] = new Setup(),
+ },
+ new Dictionary());
+ var state = new GpuWorldState();
+
+ state.AddLandblock(new LoadedLandblock(
+ landblockId,
+ new LandBlock(),
+ Array.Empty(),
+ bundle));
+
+ Assert.True(state.TryGetLandblock(
+ landblockId,
+ out LoadedLandblock? retained));
+ Assert.NotNull(retained);
+ Assert.Same(PhysicsDatBundle.Empty, retained.PhysicsDats);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs
index 81dce483..0fc1c0c0 100644
--- a/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs
@@ -244,7 +244,9 @@ public sealed class LandblockBuildFactoryTests
Assert.Empty(envCells.Shells);
Assert.NotNull(physics.Info);
Assert.Equal(2, physics.EnvCells.Count);
- Assert.Single(physics.Environments);
+ Assert.Empty(physics.Environments);
+ Assert.Equal(2, result.Collisions!.CellStructures.Count);
+ Assert.Equal(2, result.Collisions.EnvCells.Count);
}
[Fact]
@@ -385,13 +387,23 @@ public sealed class LandblockBuildFactoryTests
physics.EnvCells.Keys.Order(),
first.Collisions.CellStructures.Keys.Order());
Assert.Equal(physics.Setups.Keys.Order(), first.Collisions.Setups.Keys.Order());
- Assert.Equal(physics.GfxObjs.Keys.Order(), first.Collisions.GfxObjs.Keys.Order());
+ Assert.Empty(physics.Environments);
+ Assert.Empty(physics.GfxObjs);
+ uint[] expectedGfxObjIds = first.Landblock.Entities
+ .SelectMany(static entity => entity.MeshRefs)
+ .Select(static mesh => mesh.GfxObjId)
+ .Distinct()
+ .Order()
+ .ToArray();
+ Assert.Equal(
+ expectedGfxObjIds,
+ first.Collisions.GfxObjs.Keys.Order());
foreach (WorldEntity entity in first.Landblock.Entities)
{
if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
Assert.Contains(entity.SourceGfxObjOrSetupId, physics.Setups.Keys);
foreach (MeshRef mesh in entity.MeshRefs)
- Assert.Contains(mesh.GfxObjId, physics.GfxObjs.Keys);
+ Assert.Contains(mesh.GfxObjId, first.Collisions.GfxObjs.Keys);
}
}
diff --git a/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs
index f0336d66..6a59cf28 100644
--- a/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
+using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Physics;
+using DatReaderWriter.Enums;
using Xunit;
namespace AcDream.Core.Tests.Physics;
@@ -87,4 +89,57 @@ public class CellSurfaceTests
Assert.NotNull(z);
Assert.InRange(z!.Value, 5f, 8f); // approximate
}
+
+ [Fact]
+ public void PreparedPolygonTable_MatchesLegacyWorldSurface()
+ {
+ var vertices = new Dictionary
+ {
+ [0] = new(-2f, -2f, 1f),
+ [1] = new(2f, -2f, 2f),
+ [2] = new(2f, 2f, 4f),
+ [3] = new(-2f, 2f, 3f),
+ };
+ var table = new FlatPolygonTable(
+ ImmutableArray.Create(
+ new FlatCollisionPolygon(
+ 0,
+ default,
+ CullMode.None,
+ 4,
+ new FlatIndexRange(0, 4))),
+ ImmutableArray.Create(
+ vertices[0],
+ vertices[1],
+ vertices[2],
+ vertices[3]));
+ Quaternion rotation = Quaternion.CreateFromAxisAngle(
+ Vector3.UnitZ,
+ 0.37f);
+ Vector3 translation = new(17f, -9f, 6f);
+ var prepared = new CellSurface(
+ 0x0100,
+ table,
+ rotation,
+ translation);
+
+ Vector3 localSample = new(0.25f, -0.5f, 0f);
+ Vector3 worldSample =
+ Vector3.Transform(localSample, rotation) + translation;
+ var transformedVertices = vertices.ToDictionary(
+ static pair => pair.Key,
+ pair => Vector3.Transform(pair.Value, rotation) + translation);
+ var legacy = new CellSurface(
+ 0x0100,
+ transformedVertices,
+ [new List { 0, 1, 2, 3 }]);
+
+ float? expected = legacy.SampleFloorZ(worldSample.X, worldSample.Y);
+ float? actual = prepared.SampleFloorZ(worldSample.X, worldSample.Y);
+
+ Assert.Equal(expected.HasValue, actual.HasValue);
+ Assert.Equal(
+ BitConverter.SingleToInt32Bits(expected!.Value),
+ BitConverter.SingleToInt32Bits(actual!.Value));
+ }
}
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs
index 1a3560c9..e02ed83b 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs
@@ -1,6 +1,8 @@
using System.Collections.Immutable;
+using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
namespace AcDream.Core.Tests.Physics;
@@ -58,4 +60,104 @@ public sealed class PhysicsDataCacheProductionTests
Assert.Same(prepared, cache.GetFlatSetup(0x0200_0001u));
}
+
+ [Fact]
+ public void ProductionPreparedRecords_RetainNoParsedCollisionGraph()
+ {
+ const uint gfxObjId = 0x0100_0001u;
+ const uint setupId = 0x0200_0001u;
+ const uint cellId = 0xA9B4_0100u;
+ PhysicsDataCache cache = PhysicsDataCache.CreateProduction();
+ Assert.Null(cache.CollisionShadow);
+
+ var node = new FlatPhysicsBspNode(
+ BSPNodeType.Leaf,
+ default,
+ -1,
+ -1,
+ 0,
+ 0,
+ new FlatCollisionSphere(Vector3.Zero, 3f),
+ new FlatIndexRange(0, 0));
+ var physicsBsp = new FlatPhysicsBsp(
+ 0,
+ ImmutableArray.Create(node),
+ ImmutableArray.Empty,
+ FlatPolygonTable.Empty);
+ var gfx = new FlatGfxObjCollisionAsset(
+ physicsBsp,
+ new FlatCollisionSphere(Vector3.Zero, 3f),
+ new FlatGfxObjVisualBounds(
+ -Vector3.One,
+ Vector3.One,
+ Vector3.Zero,
+ 1.7320508f,
+ Vector3.One));
+ var setup = new FlatSetupCollision(
+ ImmutableArray.Create(
+ new FlatCollisionCylinder(
+ Vector3.UnitZ,
+ 0.4f,
+ 1.2f)),
+ ImmutableArray.Empty,
+ 1.2f,
+ 0.4f,
+ 0.5f,
+ 0.5f);
+ var structure = new FlatCellStructureCollisionAsset(
+ physicsBsp,
+ new FlatCellContainmentBsp(
+ -1,
+ ImmutableArray.Empty),
+ FlatPolygonTable.Empty);
+ var topology = new FlatEnvCellTopology(
+ ImmutableArray.Empty,
+ ImmutableArray.Empty,
+ seenOutside: false);
+
+ cache.CacheGfxObj(gfxObjId, gfx);
+ cache.CacheSetup(setupId, setup);
+ cache.CacheCellStruct(
+ cellId,
+ new EnvCell(),
+ Matrix4x4.Identity,
+ structure,
+ topology);
+
+ GfxObjPhysics retainedGfx = Assert.IsType(
+ cache.GetGfxObj(gfxObjId));
+ Assert.Null(retainedGfx.BSP);
+ Assert.Null(retainedGfx.PhysicsPolygons);
+ Assert.Null(retainedGfx.Vertices);
+ Assert.Empty(retainedGfx.Resolved);
+
+ SetupPhysics retainedSetup = Assert.IsType(
+ cache.GetSetup(setupId));
+ Assert.Empty(retainedSetup.CylSpheres);
+ Assert.Empty(retainedSetup.Spheres);
+
+ CellPhysics retainedCell = Assert.IsType(
+ cache.GetCellStruct(cellId));
+ Assert.Null(retainedCell.BSP);
+ Assert.Null(retainedCell.CellBSP);
+ Assert.Null(retainedCell.PhysicsPolygons);
+ Assert.Null(retainedCell.Vertices);
+ Assert.Null(retainedCell.PortalPolygons);
+ Assert.Empty(retainedCell.Resolved);
+
+ Assert.Equal(0, cache.GraphGfxObjCount);
+ Assert.Equal(0, cache.GraphSetupCount);
+ Assert.Equal(0, cache.GraphCellStructCount);
+ Assert.Equal(1, cache.FlatGfxObjCount);
+ Assert.Equal(1, cache.FlatSetupCount);
+ Assert.Equal(1, cache.FlatCellStructCount);
+ Assert.Equal(1, cache.FlatEnvCellCount);
+ var runtimeCell =
+ Assert.IsType(
+ cache.CellGraph.GetVisible(cellId));
+ Assert.Null(runtimeCell.ContainmentBsp);
+ Assert.Same(
+ structure.ContainmentBsp,
+ runtimeCell.FlatContainmentBsp);
+ }
}
diff --git a/tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs
index e4dd8794..8b3db651 100644
--- a/tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs
@@ -47,6 +47,60 @@ public class TransitionTypesTests
Assert.Equal(sentinelAllowance, transition.SpherePath.WalkableAllowance);
}
+ [Fact]
+ public void TryFindIndoorWalkablePlane_FlatOnlyMatchesGraphResult()
+ {
+ CellPhysics graph = BuildTwoFloorCellPhysics(
+ lowerZ: 0f,
+ upperZ: 3f);
+ FlatPhysicsBsp flat = FlatCollisionAssetBuilder.FlattenPhysicsBsp(
+ graph.BSP!.Root,
+ graph.Resolved);
+ var flatOnly = new CellPhysics
+ {
+ WorldTransform = graph.WorldTransform,
+ InverseWorldTransform = graph.InverseWorldTransform,
+ Resolved = new Dictionary(),
+ FlatPhysicsBsp = flat,
+ };
+ var graphTransition = new Transition();
+ var flatTransition = new Transition();
+ graphTransition.SpherePath.WalkInterp = 1f;
+ flatTransition.SpherePath.WalkInterp = 1f;
+ Vector3 foot = new(0.5f, 0.5f, 0.4f);
+
+ bool graphFound = graphTransition.TryFindIndoorWalkablePlane(
+ graph,
+ foot,
+ 0.48f,
+ out Plane graphPlane,
+ out Vector3[] graphVertices,
+ out uint graphId);
+ bool flatFound = flatTransition.TryFindIndoorWalkablePlane(
+ flatOnly,
+ foot,
+ 0.48f,
+ out Plane flatPlane,
+ out Vector3[] flatVertices,
+ out uint flatId);
+
+ Assert.Equal(graphFound, flatFound);
+ Assert.Equal(graphId, flatId);
+ Assert.Equal(
+ BitConverter.SingleToInt32Bits(graphPlane.Normal.X),
+ BitConverter.SingleToInt32Bits(flatPlane.Normal.X));
+ Assert.Equal(
+ BitConverter.SingleToInt32Bits(graphPlane.Normal.Y),
+ BitConverter.SingleToInt32Bits(flatPlane.Normal.Y));
+ Assert.Equal(
+ BitConverter.SingleToInt32Bits(graphPlane.Normal.Z),
+ BitConverter.SingleToInt32Bits(flatPlane.Normal.Z));
+ Assert.Equal(
+ BitConverter.SingleToInt32Bits(graphPlane.D),
+ BitConverter.SingleToInt32Bits(flatPlane.D));
+ Assert.Equal(graphVertices, flatVertices);
+ }
+
///
/// Build a minimal CellPhysics with two horizontal walkable polygons at
/// local Z=lowerZ and Z=upperZ. Identity world transform so world == local.
@@ -76,6 +130,7 @@ public class TransitionTypesTests
Plane = new Plane(Vector3.UnitZ, -lowerZ),
NumPoints = 4,
SidesType = CullMode.None,
+ Id = 0,
},
[1] = new ResolvedPolygon
{
@@ -83,6 +138,7 @@ public class TransitionTypesTests
Plane = new Plane(Vector3.UnitZ, -upperZ),
NumPoints = 4,
SidesType = CullMode.None,
+ Id = 1,
},
};