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, }, };