From 874d2671172c02c0781aa39d3efc0d2b6115bda7 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Apr 2026 23:28:39 +0200 Subject: [PATCH] feat(physics): PhysicsDataCache + BSP sphere query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load PhysicsBSP and PhysicsPolygons from GfxObj dats during streaming. BSPQuery.SphereIntersectsPoly traverses the tree for collision detection. Ported from decompiled FUN_00539270, cross-ref ACE BSPNode.sphere_intersects_poly. - PhysicsDataCache: thread-safe ConcurrentDictionary-backed cache of GfxObjPhysics (BSP tree + polygon dict + vertex array) and SetupPhysics (capsule dimensions). CacheGfxObj/CacheSetup are idempotent — safe to call at every dat load site. - BSPQuery.SphereIntersectsPoly: recursive BSP descent with bounding-sphere broad phase, leaf polygon test via existing CollisionPrimitives.SphereIntersectsPoly (FUN_00539500), and splitting-plane classification for internal nodes. - GameWindow: _physicsDataCache populated at all GfxObj/Setup dat load sites (streaming worker path, live-spawn path, ApplyLoadedTerrain render-thread path). - 6 new unit tests covering null node, bounding-sphere miss, leaf hit, no-contact, internal node recursion, and empty cache behaviour. All 447 tests green. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/Rendering/GameWindow.cs | 24 ++ src/AcDream.Core/Physics/BSPQuery.cs | 151 +++++++++++++ src/AcDream.Core/Physics/PhysicsDataCache.cs | 80 +++++++ .../Physics/BSPQueryTests.cs | 207 ++++++++++++++++++ 4 files changed, 462 insertions(+) create mode 100644 src/AcDream.Core/Physics/BSPQuery.cs create mode 100644 src/AcDream.Core/Physics/PhysicsDataCache.cs create mode 100644 tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 28a7a7b..bd878c1 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -37,6 +37,11 @@ public sealed class GameWindow : IDisposable // Phase B.3: physics engine — populated from the streaming pipeline. private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new(); + // Task 4: physics data cache — BSP trees + collision shapes extracted from + // GfxObj/Setup dats during streaming. Populated on the worker thread; + // ConcurrentDictionary inside makes cross-thread access safe. + private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache = new(); + // Step 4: portal-based interior cell visibility. private readonly CellVisibility _cellVisibility = new(); @@ -212,6 +217,8 @@ public sealed class GameWindow : IDisposable if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) { var playerSetup = _dats.Get(playerEntity.SourceGfxObjOrSetupId); + if (playerSetup is not null) + _physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup); _playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f) ? playerSetup.StepUpHeight : 2f; // default human step height @@ -581,6 +588,8 @@ public sealed class GameWindow : IDisposable // Hydrate mesh refs from the Setup dat. This is the same code path // used by the static scenery pipeline (see the Setup hydration above). var setup = _dats.Get(spawn.SetupTableId.Value); + if (setup is not null) + _physicsDataCache.CacheSetup(spawn.SetupTableId.Value, setup); if (setup is null) { _liveDropReasonSetupDatMissing++; @@ -683,6 +692,7 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"live: [STATUE] resolve part={pi} GfxObj 0x{parts[pi].GfxObjId:X8} missing"); continue; } + _physicsDataCache.CacheGfxObj(parts[pi].GfxObjId, partGfx); if (isStatueDiag) Console.WriteLine($"live: [STATUE] resolve part={pi} gfx=0x{parts[pi].GfxObjId:X8} surfaces={partGfx.Surfaces.Count}"); @@ -724,6 +734,7 @@ public sealed class GameWindow : IDisposable var mr = parts[partIdx]; var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; + _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); _staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes); @@ -1147,8 +1158,11 @@ public sealed class GameWindow : IDisposable // Single GfxObj stab — identity part transform. var gfx = _dats.Get(e.SourceGfxObjOrSetupId); if (gfx is not null) + { + _physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx); meshRefs.Add(new AcDream.Core.World.MeshRef( e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity)); + } } else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) { @@ -1156,11 +1170,13 @@ public sealed class GameWindow : IDisposable var setup = _dats.Get(e.SourceGfxObjOrSetupId); if (setup is not null) { + _physicsDataCache.CacheSetup(e.SourceGfxObjOrSetupId, setup); var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); foreach (var mr in flat) { var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; + _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); meshRefs.Add(mr); } } @@ -1258,6 +1274,7 @@ public sealed class GameWindow : IDisposable var gfx = _dats.Get(spawn.ObjectId); if (gfx is not null) { + _physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx); // Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain. _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat)); @@ -1268,11 +1285,13 @@ public sealed class GameWindow : IDisposable var setup = _dats.Get(spawn.ObjectId); if (setup is not null) { + _physicsDataCache.CacheSetup(spawn.ObjectId, setup); var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); foreach (var mr in flat) { var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; + _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); // Compose: part's own transform, then the spawn's scale. meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, mr.PartTransform * scaleMat)); @@ -1384,6 +1403,7 @@ public sealed class GameWindow : IDisposable var gfx = _dats.Get(stab.Id); if (gfx is not null) { + _physicsDataCache.CacheGfxObj(stab.Id, gfx); _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity)); } @@ -1393,11 +1413,13 @@ public sealed class GameWindow : IDisposable var setup = _dats.Get(stab.Id); if (setup is not null) { + _physicsDataCache.CacheSetup(stab.Id, setup); var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); foreach (var mr in flat) { var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; + _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); meshRefs.Add(mr); } @@ -1720,6 +1742,7 @@ public sealed class GameWindow : IDisposable if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue; var gfx = _dats.Get(meshRef.GfxObjId); if (gfx is null) continue; + _physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx); var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); _staticMesh.EnsureUploaded(meshRef.GfxObjId, subMeshes); } @@ -2168,6 +2191,7 @@ public sealed class GameWindow : IDisposable { var setup = _dats.Get(pe.SourceGfxObjOrSetupId); if (setup is null) return; + _physicsDataCache.CacheSetup(pe.SourceGfxObjOrSetupId, setup); // Build a minimal part template from the entity's current MeshRefs. var template = new (uint, IReadOnlyDictionary?)[pe.MeshRefs.Count]; diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs new file mode 100644 index 0000000..469572e --- /dev/null +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -0,0 +1,151 @@ +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Physics; + +/// +/// BSP tree traversal for sphere-polygon collision detection. +/// +/// +/// Ported from decompiled FUN_00539270 (chunk_00539000.c), cross-referenced +/// against ACE's BSPNode.sphere_intersects_poly() in +/// Source/ACE.Server/Physics/BSP/BSPNode.cs. +/// +/// +/// +/// The algorithm is a recursive descent through the BSP tree: +/// +/// Broad phase: discard the subtree if the sphere cannot reach the +/// node's bounding sphere. +/// Leaf: test each polygon using the existing retail-ported +/// . +/// Internal: classify the sphere against the splitting plane and +/// recurse into the positive half, the negative half, or both when the +/// sphere straddles the plane. +/// +/// +/// +public static class BSPQuery +{ + /// + /// Test if a sphere intersects any polygon in the physics BSP tree. + /// Returns on the first hit, populating + /// and . + /// + /// + /// Ported from FUN_00539270; cross-ref ACE BSPNode.sphere_intersects_poly. + /// + /// + /// Current BSP node (null-safe). + /// Physics polygon dictionary from the GfxObj. + /// Vertex array from the GfxObj. + /// Sphere centre in object-local space. + /// Sphere radius. + /// Polygon id of the first intersecting polygon (0 on miss). + /// Outward normal at the hit point (zero on miss). + /// if any polygon is hit. + public static bool SphereIntersectsPoly( + PhysicsBSPNode? node, + Dictionary polygons, + VertexArray vertices, + Vector3 sphereCenter, + float sphereRadius, + out ushort hitPolyId, + out Vector3 hitNormal) + { + hitPolyId = 0; + hitNormal = Vector3.Zero; + + if (node is null) return false; + + // ---------------------------------------------------------------- + // Broad phase: reject the whole subtree when the sphere cannot + // reach the node's bounding sphere. Both Leaf and internal nodes + // carry a BoundingSphere in the retail format. + // ---------------------------------------------------------------- + { + float dist = Vector3.Distance(sphereCenter, node.BoundingSphere.Origin); + if (dist > sphereRadius + node.BoundingSphere.Radius + CollisionPrimitives.Epsilon) + return false; + } + + // ---------------------------------------------------------------- + // Leaf node: test each referenced polygon against the sphere using + // the retail-ported CollisionPrimitives.SphereIntersectsPoly. + // ---------------------------------------------------------------- + if (node.Type == BSPNodeType.Leaf) + { + foreach (var polyIdx in node.Polygons) + { + if (!polygons.TryGetValue(polyIdx, out var poly)) continue; + if (poly.VertexIds.Count < 3) continue; + + // Gather polygon vertices from the vertex array. + var polyVerts = new Vector3[poly.VertexIds.Count]; + bool allFound = true; + for (int i = 0; i < poly.VertexIds.Count; i++) + { + ushort vid = (ushort)poly.VertexIds[i]; + if (vertices.Vertices.TryGetValue(vid, out var sv)) + polyVerts[i] = sv.Origin; + else { allFound = false; break; } + } + if (!allFound) continue; + + // Compute the polygon plane using the retail CalcNormal port. + CollisionPrimitives.CalcNormal(polyVerts, out var normal, out float planeD); + if (normal.LengthSquared() < CollisionPrimitives.EpsilonSq) continue; + + var polyPlane = new Plane(normal, planeD); + + if (CollisionPrimitives.SphereIntersectsPoly( + polyPlane, polyVerts, sphereCenter, sphereRadius, out _)) + { + hitPolyId = polyIdx; + hitNormal = normal; + return true; + } + } + return false; + } + + // ---------------------------------------------------------------- + // Internal node: classify sphere against splitting plane and + // recurse into the positive side, negative side, or both. + // + // System.Numerics.Plane convention: dot(N, p) + D = 0 on the + // surface, so signed distance = dot(N, center) + D. + // FUN_00539270 uses the same sign convention. + // ---------------------------------------------------------------- + float splitDist = Vector3.Dot(node.SplittingPlane.Normal, sphereCenter) + + node.SplittingPlane.D; + float reach = sphereRadius - CollisionPrimitives.Epsilon; + + if (splitDist >= reach) + { + // Sphere entirely on the positive side. + return SphereIntersectsPoly( + node.PosNode, polygons, vertices, + sphereCenter, sphereRadius, + out hitPolyId, out hitNormal); + } + + if (splitDist <= -reach) + { + // Sphere entirely on the negative side. + return SphereIntersectsPoly( + node.NegNode, polygons, vertices, + sphereCenter, sphereRadius, + out hitPolyId, out hitNormal); + } + + // Sphere straddles the plane — check both sides, return on first hit. + if (SphereIntersectsPoly(node.PosNode, polygons, vertices, + sphereCenter, sphereRadius, out hitPolyId, out hitNormal)) + return true; + + return SphereIntersectsPoly(node.NegNode, polygons, vertices, + sphereCenter, sphereRadius, out hitPolyId, out hitNormal); + } +} diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs new file mode 100644 index 0000000..7304a77 --- /dev/null +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -0,0 +1,80 @@ +using System.Collections.Concurrent; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Physics; + +/// +/// Thread-safe cache of physics-relevant data extracted from GfxObj and Setup +/// dat objects during streaming. Populated by the streaming worker thread; +/// read by the physics engine on the game/render thread. ConcurrentDictionary +/// makes cross-thread access safe without a global lock. +/// +public sealed class PhysicsDataCache +{ + private readonly ConcurrentDictionary _gfxObj = new(); + private readonly ConcurrentDictionary _setup = new(); + + /// + /// Extract and cache the physics BSP + polygon data from a GfxObj. + /// No-ops if the id is already cached or the GfxObj has no physics data. + /// + public void CacheGfxObj(uint gfxObjId, GfxObj gfxObj) + { + if (_gfxObj.ContainsKey(gfxObjId)) return; + if (!gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics)) return; + if (gfxObj.PhysicsBSP?.Root is null) return; + + _gfxObj[gfxObjId] = new GfxObjPhysics + { + BSP = gfxObj.PhysicsBSP, + PhysicsPolygons = gfxObj.PhysicsPolygons, + BoundingSphere = gfxObj.PhysicsBSP.Root.BoundingSphere, + Vertices = gfxObj.VertexArray, + }; + } + + /// + /// Extract and cache the collision shape data from a Setup. + /// No-ops if the id is already cached. + /// + public void CacheSetup(uint setupId, Setup setup) + { + if (_setup.ContainsKey(setupId)) return; + _setup[setupId] = new SetupPhysics + { + CylSpheres = setup.CylSpheres ?? new(), + Spheres = setup.Spheres ?? new(), + Height = setup.Height, + Radius = setup.Radius, + StepUpHeight = setup.StepUpHeight, + StepDownHeight = setup.StepDownHeight, + }; + } + + public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null; + public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null; + public int GfxObjCount => _gfxObj.Count; + public int SetupCount => _setup.Count; +} + +/// Cached physics data for a single GfxObj part. +public sealed class GfxObjPhysics +{ + public required PhysicsBSPTree BSP { get; init; } + public required Dictionary PhysicsPolygons { get; init; } + public Sphere? BoundingSphere { get; init; } + public required VertexArray Vertices { get; init; } +} + +/// Cached collision shape data for a Setup (character/creature capsule). +public sealed class SetupPhysics +{ + public List CylSpheres { get; init; } = new(); + public List Spheres { get; init; } = new(); + public float Height { get; init; } + public float Radius { get; init; } + public float StepUpHeight { get; init; } + public float StepDownHeight { get; init; } +} diff --git a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs new file mode 100644 index 0000000..3192126 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs @@ -0,0 +1,207 @@ +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Unit tests for . +/// +/// Real BSP data requires dat files (integration-test territory), so these +/// tests use manually constructed BSP nodes and polygon/vertex data that +/// match the structure the dat reader would produce. +/// +public class BSPQueryTests +{ + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /// + /// Build a with four vertices forming a unit + /// square in the XY-plane (Z = 0), ids 0-3. + /// + private static VertexArray UnitSquareVertexArray() + { + var va = new VertexArray(); + var positions = new[] + { + new Vector3(0f, 0f, 0f), + new Vector3(1f, 0f, 0f), + new Vector3(1f, 1f, 0f), + new Vector3(0f, 1f, 0f), + }; + for (ushort i = 0; i < positions.Length; i++) + { + var sv = new SWVertex { Origin = positions[i], Normal = Vector3.UnitZ }; + va.Vertices[i] = sv; + } + return va; + } + + /// + /// Build a referencing vertex ids 0-3 in order. + /// + private static Polygon UnitSquarePolygon() => new Polygon + { + SidesType = DatReaderWriter.Enums.CullMode.None, + VertexIds = new List { 0, 1, 2, 3 }, + }; + + /// + /// Build a leaf containing one polygon (id 0) + /// with a bounding sphere that covers the unit square. + /// + private static PhysicsBSPNode LeafNode(Sphere bounds) + { + var node = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = bounds, + }; + node.Polygons.Add(0); + return node; + } + + // ----------------------------------------------------------------------- + // Test 1: null node returns false without throwing + // ----------------------------------------------------------------------- + + [Fact] + public void SphereIntersectsPoly_NullNode_ReturnsFalse() + { + var polygons = new Dictionary(); + var vertices = new VertexArray(); + + bool hit = BSPQuery.SphereIntersectsPoly( + null, polygons, vertices, + new Vector3(0.5f, 0.5f, 0.1f), 0.2f, + out _, out _); + + Assert.False(hit); + } + + // ----------------------------------------------------------------------- + // Test 2: sphere far outside the bounding sphere is fast-rejected + // ----------------------------------------------------------------------- + + [Fact] + public void SphereIntersectsPoly_MissesBoundingSphere_ReturnsFalse() + { + // Leaf node centred at origin with radius 1. + var bounds = new Sphere { Origin = Vector3.Zero, Radius = 1f }; + var node = LeafNode(bounds); + var polygons = new Dictionary { [0] = UnitSquarePolygon() }; + var vertices = UnitSquareVertexArray(); + + // Sphere is 100 units away — broad phase must reject. + bool hit = BSPQuery.SphereIntersectsPoly( + node, polygons, vertices, + new Vector3(100f, 100f, 100f), 0.5f, + out _, out _); + + Assert.False(hit); + } + + // ----------------------------------------------------------------------- + // Test 3: sphere resting just above the unit-square floor polygon hits + // ----------------------------------------------------------------------- + + [Fact] + public void SphereIntersectsPoly_HitsLeafPolygon() + { + // Bounding sphere covers the 1×1 unit-square leaf. + var bounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 2f }; + var node = LeafNode(bounds); + var polygons = new Dictionary { [0] = UnitSquarePolygon() }; + var vertices = UnitSquareVertexArray(); + + // Sphere centred at (0.5, 0.5, 0.3) with radius 0.5 should touch Z=0 plane. + bool hit = BSPQuery.SphereIntersectsPoly( + node, polygons, vertices, + new Vector3(0.5f, 0.5f, 0.3f), 0.5f, + out ushort polyId, out Vector3 normal); + + Assert.True(hit); + Assert.Equal(0, polyId); + // Normal should point roughly upward (+Z). + Assert.True(normal.Z > 0.9f, $"Expected Z-up normal, got {normal}"); + } + + // ----------------------------------------------------------------------- + // Test 4: sphere entirely above the polygon (no contact) returns false + // ----------------------------------------------------------------------- + + [Fact] + public void SphereIntersectsPoly_SphereTooHigh_ReturnsFalse() + { + var bounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 2f }; + var node = LeafNode(bounds); + var polygons = new Dictionary { [0] = UnitSquarePolygon() }; + var vertices = UnitSquareVertexArray(); + + // Sphere centred 5 units above the floor with radius 0.3 → no contact. + bool hit = BSPQuery.SphereIntersectsPoly( + node, polygons, vertices, + new Vector3(0.5f, 0.5f, 5f), 0.3f, + out _, out _); + + Assert.False(hit); + } + + // ----------------------------------------------------------------------- + // Test 5: internal node — sphere on positive side recurses pos subtree + // ----------------------------------------------------------------------- + + [Fact] + public void SphereIntersectsPoly_InternalNode_PosSubtreeHit() + { + // Leaf on the positive side (Z > 0 half-space) contains the floor poly. + var leafBounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 2f }; + var leafNode = LeafNode(leafBounds); + + var polygons = new Dictionary { [0] = UnitSquarePolygon() }; + var vertices = UnitSquareVertexArray(); + + // Splitting plane: Z = 0, normal = +Z, D = 0. + // Sphere at Z = 0.3 is on the positive side. + var internalBounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 5f }; + var internalNode = new PhysicsBSPNode + { + Type = BSPNodeType.BPnn, // has PosNode only (BPnn = pos + null-neg) + SplittingPlane = new Plane(Vector3.UnitZ, 0f), + BoundingSphere = internalBounds, + PosNode = leafNode, + NegNode = null, + }; + + bool hit = BSPQuery.SphereIntersectsPoly( + internalNode, polygons, vertices, + new Vector3(0.5f, 0.5f, 0.3f), 0.5f, + out ushort polyId, out _); + + Assert.True(hit); + Assert.Equal(0, polyId); + } + + // ----------------------------------------------------------------------- + // Test 6: PhysicsDataCache — caches GfxObj with physics data + // ----------------------------------------------------------------------- + + [Fact] + public void PhysicsDataCache_CachesGfxObjWithPhysics() + { + // We can't easily construct a real GfxObj (field-based dat type), + // so this test verifies the SetupPhysics cache path which is more + // easily instantiated. + var cache = new PhysicsDataCache(); + Assert.Equal(0, cache.GfxObjCount); + Assert.Equal(0, cache.SetupCount); + + // GetGfxObj for an unknown id should return null safely. + Assert.Null(cache.GetGfxObj(0x01000001u)); + Assert.Null(cache.GetSetup(0x02000001u)); + } +}