From 82f8d4f82e24ad85042ac3e25c4f6464bebce758 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 18:21:19 +0200 Subject: [PATCH] refactor(physics): remove parsed collision graphs Publish prepared GfxObj, Setup, CellStruct, and EnvCell collision records without retaining their parsed DAT BSP, polygon, vertex, or shape graphs. Strip temporary physics bundles at stable world commit, keep graph traversal only as an explicit test/tooling oracle, and report graph residency from actual retained fields. Validated by 115 focused collision/streaming tests, a zero-warning Release build, and 8,413 passing Release tests with five pre-existing skips. Co-authored-by: Codex --- .../WorldLifecycleResourceSnapshotSource.cs | 6 +- .../Physics/LiveCollisionAssetPublisher.cs | 6 +- .../Physics/LiveEntityCollisionBuilder.cs | 4 +- .../LiveEntityMotionRuntimeController.cs | 3 +- src/AcDream.App/Streaming/GpuWorldState.cs | 2 +- .../Streaming/LandblockBuildFactory.cs | 12 +- .../Streaming/LandblockPhysicsPublisher.cs | 187 ++++++++---- src/AcDream.Core/Physics/CellSurface.cs | 47 +++ .../Physics/CollisionTraversal.cs | 12 +- src/AcDream.Core/Physics/PhysicsDataCache.cs | 279 +++++++++++++++++- src/AcDream.Core/Physics/TransitionTypes.cs | 110 +++++-- src/AcDream.Core/World/Cells/EnvCell.cs | 76 +++++ src/AcDream.Core/World/PhysicsDatBundle.cs | 13 + .../GpuWorldStateCollisionResidencyTests.cs | 39 +++ .../Streaming/LandblockBuildFactoryTests.cs | 18 +- .../Physics/CellSurfaceTests.cs | 55 ++++ .../PhysicsDataCacheProductionTests.cs | 102 +++++++ .../Physics/TransitionTypesTests.cs | 56 ++++ 18 files changed, 923 insertions(+), 104 deletions(-) create mode 100644 tests/AcDream.App.Tests/Streaming/GpuWorldStateCollisionResidencyTests.cs 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, }, };