From 79dee342f290f655f96c5d9b159971235caf8eeb Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 24 Jun 2026 18:57:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(physics):=20Slice=201=20=E2=80=94=20delete?= =?UTF-8?q?=20render-mesh-AABB=20synthetic=20collision;=20DAT-only=20shape?= =?UTF-8?q?=20authority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail oracle: CPartArray::InitParts@0x00517F40, CGfxObj::Serialize@0x00534970 (physics_bsp gated on serialized-flags bit-0), CPhysicsPart::find_obj_collisions@0x0050D8D0 (returns OK / passable when physics_bsp==null). Render-mesh bounds never enter collision. Changes: - GameWindow.cs: delete the ~200-line VISUAL mesh-bounds collision block (the isPhantomSetup / isPhantomGfxObj locals + the if-block computing worldMin/worldMax AABB + the ShadowObjects.Register call that capped and registered the synthetic cylinder). Also removes dead counter variables scHaveBounds/scRegistered/scNoBounds/ scTooThin; trims the ProbeBuildingEnabled summary line accordingly. - PhysicsDataCache.cs: delete IsPhantomGfxObjSource (the predicate that only existed to fence the mesh-AABB synthesis; the "phantom" concept is now the default — no DAT shape means no registration, verbatim with retail). - PhysicsDataCachePhantomSourceTests.cs: deleted (tested the removed method). - ShadowShapeBuilderShapeSourceTests.cs: new guard test — a Setup with parts but hasPhysicsBsp=false and no CylSpheres/Spheres yields an empty shape list, locking the DAT-only rule in the builder. - retail-divergence-register.md: AP-2 row deleted (divergence retired). Objects with no DAT physics shape (no CylSpheres, no Spheres, no part with a PhysicsBSP) now register no collision shape and are passable, verbatim with retail. Objects with real DAT shapes (BSP parts, CylSpheres) are unaffected. dotnet build green, 22/22 tests passing (ShadowShapeBuilderShapeSourceTests + CellarUpTrajectoryReplay + CornerFlood + Issue147ArwicBuildings replay harnesses). Visual gate pending: walk Holtburg + open world; objects that become passable must match retail (DAT has no physics shape — trees with real CylSpheres still solid). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 2 +- src/AcDream.App/Rendering/GameWindow.cs | 206 +----------------- src/AcDream.Core/Physics/PhysicsDataCache.cs | 18 -- .../PhysicsDataCachePhantomSourceTests.cs | 74 ------- .../ShadowShapeBuilderShapeSourceTests.cs | 41 ++++ 5 files changed, 44 insertions(+), 297 deletions(-) delete mode 100644 tests/AcDream.Core.Tests/Physics/PhysicsDataCachePhantomSourceTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 7a6441f6..75b855bf 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -104,7 +104,7 @@ accepted-divergence entries (#96, #49, #50). | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal` → `find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position | -| AP-2 | Visual-AABB fallback collision shape for Setups with no retail physics data; retail emits NO shapes (phantom). **#101** fixed the GfxObj-only class; the Setup-without-shapes fallback remains | `src/AcDream.Core/Physics/PhysicsDataCache.cs:96` | Lets the player collide with decorative meshes shipping no CylSphere/part-BSP instead of walking through furniture-like props | Retail-phantom entities block movement (the **#100/#101** family), and the synthetic box gives non-retail push-out normals when it collides | `CPartArray::InitParts` (cited at PhysicsDataCache.cs:386-389) | + | AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 | | AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) | | AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 | diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 5846d6bd..c730f0e7 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7112,7 +7112,7 @@ public sealed class GameWindow : IDisposable // 2. Setup: use Setup.Radius (the capsule radius) if available. // 3. Fallback: 1.0m (conservative default for trees / small objects). int lbBspCount = 0, lbCylCount = 0, lbNoneCount = 0; - int scTried = 0, scHaveBounds = 0, scRegistered = 0, scTooThin = 0, scNoBounds = 0; + int scTried = 0; foreach (var entity in lb.Entities) { // Phase G.2: if the entity's Setup has baked-in LightInfos, @@ -7364,207 +7364,6 @@ public sealed class GameWindow : IDisposable } } - // L-fix3 (2026-04-28): retail "decorative / phantom" detection. - // A Setup is phantom (no collision) when it has NO CylSpheres, - // NO Spheres, AND zero overall Radius. Small plants, grass - // tufts, flowers, ground-cover bushes all match — retail - // ships them with empty collision arrays so the player walks - // through them. Without this gate the mesh-bounds fallback - // below assigns every plant a 0.3 m+ collision cylinder - // (line ~4409 clamp) and they block the player. - // - // The gate is layered AFTER the Setup CylSphere / BSP - // registrations above (which are no-ops for phantom Setups - // anyway), so non-phantom scenery (trees with real - // CylSpheres or canopy-only BSPs) still gets the - // mesh-bounds fallback. The check is on entity.SourceGfxObjOrSetupId - // to look up the cached Setup; if it's a raw GfxObj - // (0x010xxxxx, no Setup metadata) we keep the old fallback - // behaviour because GfxObjs don't expose phantom intent. - bool isPhantomSetup = false; - if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) - { - var setupInfoForPhantom = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); - if (setupInfoForPhantom is not null - && setupInfoForPhantom.CylSpheres.Count == 0 - && setupInfoForPhantom.Spheres.Count == 0 - && setupInfoForPhantom.Radius <= 0.0001f) - { - isPhantomSetup = true; - } - } - - // Issue #101 (2026-05-25): retail-faithful phantom check for - // GfxObj-only entity sources. Retail's CPartArray::InitParts - // emits NO collision shapes when the source GfxObj has - // HasPhysics=False / null PhysicsBSP.Root. Our mesh-aabb-fallback - // below previously synthesized a clamped [0.30, 1.50]m cylinder - // from the visual mesh AABB — that's the source of the 10 - // phantom 0.80m cyls that block the Holtburg upper-floor - // staircase (issue #101). Suppress synthesis for these. - // Spec: docs/research/2026-05-25-a6-stairs-cyl-retail-investigation.md. - bool isPhantomGfxObj = _physicsDataCache.IsPhantomGfxObjSource(entity.SourceGfxObjOrSetupId); - - // VISUAL mesh-bounds collision: for SCENERY entities (IDs with - // 0x80000000 bit set, indicating procedurally-placed scenery), - // ALWAYS compute a cylinder from the world-space mesh AABB. - // This catches trees whose BSP is only on the canopy (player - // walks under) AND corrects CylSphere positioning issues caused - // by mesh files having vertices offset from the mesh origin. - // - // For stabs (low IDs) and live entities, keep the existing Setup - // CylSphere / BSP registrations — those are placed with precise - // frame data and don't have the scenery offset issue. - // - // L-fix3: skip entirely when the Setup is phantom — retail - // decorative meshes have no collision data on purpose. - if (!isPhantomSetup - && !isPhantomGfxObj - && !_isLandblockStab - && (_isOutdoorMesh || (entityBsp == 0 && entityCyl == 0)) - && entity.MeshRefs.Count > 0) - { - float entScale = entity.Scale > 0f ? entity.Scale : 1f; - bool haveBounds = false; - var worldMin = new System.Numerics.Vector3(float.MaxValue); - var worldMax = new System.Numerics.Vector3(float.MinValue); - - var entRoot = - System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation) * - System.Numerics.Matrix4x4.CreateTranslation(entity.Position); - - // First pass: compute overall vertical extent in world Z. - float overallMinZ = float.MaxValue; - float overallMaxZ = float.MinValue; - foreach (var mr in entity.MeshRefs) - { - var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId); - if (vb is null || vb.Radius <= 0f) continue; - var partWorld = mr.PartTransform * entRoot; - for (int bi = 0; bi < 8; bi++) - { - var corner = new System.Numerics.Vector3( - (bi & 1) != 0 ? vb.Max.X : vb.Min.X, - (bi & 2) != 0 ? vb.Max.Y : vb.Min.Y, - (bi & 4) != 0 ? vb.Max.Z : vb.Min.Z); - var w = System.Numerics.Vector3.Transform(corner, partWorld); - if (w.Z < overallMinZ) overallMinZ = w.Z; - if (w.Z > overallMaxZ) overallMaxZ = w.Z; - } - } - - // Second pass: use TRUNK HEIGHT ONLY (bottom 25% of the mesh - // or first 2.5m, whichever is smaller) for horizontal radius. - // This gives us the trunk thickness — not the canopy spread. - // The Z extent still uses the full mesh (so tall trees have - // tall collision cylinders). - float trunkHeight = MathF.Min(2.5f, (overallMaxZ - overallMinZ) * 0.25f); - if (trunkHeight < 0.5f) trunkHeight = 0.5f; - float trunkTopZ = overallMinZ + trunkHeight; - - foreach (var mr in entity.MeshRefs) - { - var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId); - if (vb is null || vb.Radius <= 0f) continue; - - var partWorld = mr.PartTransform * entRoot; - // Only accumulate horizontal extents from corners within the - // trunk height range. Pass the full vertical extent through. - for (int bi = 0; bi < 8; bi++) - { - var corner = new System.Numerics.Vector3( - (bi & 1) != 0 ? vb.Max.X : vb.Min.X, - (bi & 2) != 0 ? vb.Max.Y : vb.Min.Y, - (bi & 4) != 0 ? vb.Max.Z : vb.Min.Z); - var w = System.Numerics.Vector3.Transform(corner, partWorld); - - // Always track vertical extent - if (w.Z < worldMin.Z) worldMin.Z = w.Z; - if (w.Z > worldMax.Z) worldMax.Z = w.Z; - - // Only track horizontal extent for TRUNK-level vertices - if (w.Z <= trunkTopZ) - { - if (w.X < worldMin.X) worldMin.X = w.X; - if (w.Y < worldMin.Y) worldMin.Y = w.Y; - if (w.X > worldMax.X) worldMax.X = w.X; - if (w.Y > worldMax.Y) worldMax.Y = w.Y; - haveBounds = true; - } - } - } - - if (haveBounds) - { - if (_isScenery) scHaveBounds++; - - // RADIUS: prefer the Setup's CylSphere radius (the retail - // trunk radius — thin, matches tree trunks). Fall back to - // Setup.Radius or mesh AABB if CylSphere is unavailable. - // Always scale by entity.Scale. - float entScaleLocal = entity.Scale > 0f ? entity.Scale : 1f; - float cylRadius = -1f; - float cylHeight; - - var setupInfo = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); - if (setupInfo is not null) - { - if (setupInfo.CylSpheres.Count > 0 && setupInfo.CylSpheres[0].Radius > 0f) - { - // Retail CylSphere — the definitive trunk collision. - cylRadius = setupInfo.CylSpheres[0].Radius * entScaleLocal; - } - else if (setupInfo.Radius > 0f) - { - // Setup.Radius — the overall bounding radius. For - // thin trunks this might be the full tree radius - // (canopy included) but often it's the trunk. - cylRadius = setupInfo.Radius * entScaleLocal; - } - } - - // Fall back to mesh AABB trunk-level radius if no Setup data. - if (cylRadius < 0f) - { - float halfX = (worldMax.X - worldMin.X) * 0.5f; - float halfY = (worldMax.Y - worldMin.Y) * 0.5f; - cylRadius = MathF.Max(halfX, halfY); - } - - // Clamp: retail AC trunks are 0.3-1.0m. Bigger radii (from - // the AABB fallback for canopy-heavy meshes) are clearly - // wrong; clamp to a reasonable tree-trunk maximum. - if (cylRadius < 0.3f) cylRadius = 0.3f; - if (cylRadius > 1.5f) cylRadius = 1.5f; - - // HEIGHT: use Setup.Height scaled, or mesh AABB vertical extent. - if (setupInfo is not null && setupInfo.Height > 0f) - cylHeight = setupInfo.Height * entScaleLocal; - else - cylHeight = MathF.Max(worldMax.Z - entity.Position.Z, cylRadius); - - // CENTER: entity.Position (the rendered root). - var baseCenter = new System.Numerics.Vector3( - entity.Position.X, entity.Position.Y, entity.Position.Z); - - _physicsEngine.ShadowObjects.Register( - entity.Id, - entity.SourceGfxObjOrSetupId, - baseCenter, entity.Rotation, cylRadius, - origin.X, origin.Y, lb.LandblockId, - AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight, - seedCellId: entity.ParentCellId ?? 0u); - // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. - // state/flags literals: landblock-baked scenery; no server PhysicsState. - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) - Console.WriteLine(System.FormattableString.Invariant( - $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=mesh-aabb-fallback state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}")); - entityCyl++; - if (_isScenery) scRegistered++; - } - else if (_isScenery) scNoBounds++; - } - // Tally per-entity collision presence (debug counter — optional). if (entityBsp > 0) lbBspCount++; if (entityCyl > 0) lbCylCount++; @@ -7580,8 +7379,7 @@ public sealed class GameWindow : IDisposable } if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && scTried > 0) Console.WriteLine( - $"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} registered={scRegistered} " + - $"noBounds={scNoBounds} tooThin={scTooThin} (outdoorNone={lbNoneCount})"); + $"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} (outdoorNone={lbNoneCount})"); // Find scenery WITHOUT any cached visual bounds at all int sceneryNoCache = 0; diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index fbd2ce67..d0876495 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -384,24 +384,6 @@ public sealed class PhysicsDataCache public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null; - /// - /// Issue #101 (2026-05-25): retail-faithful phantom check for - /// GfxObj-only entity sources. Returns true when the entity's - /// SourceGfxObjOrSetupId is a GfxObj (high byte - /// 0x01) AND has no cached — - /// meaning the underlying GfxObj had HasPhysics=False or - /// a null PhysicsBSP.Root, so - /// short-circuited at the early-return on line 45/46. Retail's - /// CPartArray::InitParts emits NO collision shapes for - /// these — acdream's mesh-aabb-fallback synthesis at - /// GameWindow.cs:6127 must do the same. - /// - public bool IsPhantomGfxObjSource(uint sourceId) - { - if ((sourceId & 0xFF000000u) != 0x01000000u) return false; - return GetGfxObj(sourceId)?.BSP?.Root is null; - } - public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null; public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null; public int GfxObjCount => _gfxObj.Count; diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsDataCachePhantomSourceTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsDataCachePhantomSourceTests.cs deleted file mode 100644 index 3f3c55c1..00000000 --- a/tests/AcDream.Core.Tests/Physics/PhysicsDataCachePhantomSourceTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Collections.Generic; -using AcDream.Core.Physics; -using DatReaderWriter.Enums; -using DatReaderWriter.Types; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// Issue #101 (2026-05-25) — phantom-stair fix. Retail's -/// CPartArray::InitParts emits collision shapes only from -/// Setup-level CylSpheres/Spheres or per-Part -/// PhysicsBSP. There is NO synthesis from visual mesh AABB. -/// Acdream's mesh-aabb-fallback path at -/// GameWindow.cs:6127 previously fired for ANY entity that -/// reached it with entityBsp==0 && entityCyl==0, -/// including GfxObj-only stabs whose GfxObj has -/// HasPhysics=False. This produced the 10 phantom 0.80 m -/// cylinders that block the Holtburg upper-floor staircase (see -/// docs/research/2026-05-25-a6-stairs-cyl-retail-investigation.md). -/// -/// -/// captures the -/// retail rule as a predicate: "the entity's source is a GfxObj -/// (high byte 0x01) AND the cache has no -/// entry for it." When this returns true, the caller suppresses the -/// fallback synthesis. -/// -/// -public class PhysicsDataCachePhantomSourceTests -{ - [Fact] - public void IsPhantomGfxObjSource_SetupHighByte_ReturnsFalse() - { - // Setup source (high byte 0x02) is never the GfxObj-phantom case. - // The existing isPhantomSetup check at GameWindow.cs:6090 handles - // the Setup-side phantom. This predicate is scoped to GfxObj only. - var cache = new PhysicsDataCache(); - Assert.False(cache.IsPhantomGfxObjSource(0x020019FFu)); // door setup - Assert.False(cache.IsPhantomGfxObjSource(0x02000266u)); // some setup - } - - [Fact] - public void IsPhantomGfxObjSource_GfxObjUncached_ReturnsTrue() - { - // GfxObj source (high byte 0x01) with NO cached GfxObjPhysics - // = the phantom case. The stair-step GfxObj 0x0100081A from - // issue #101's broken-stairs capture has HasPhysics=False and - // does not enter the cache. Acdream should treat it as phantom. - var cache = new PhysicsDataCache(); - Assert.True(cache.IsPhantomGfxObjSource(0x0100081Au)); - } - - [Fact] - public void IsPhantomGfxObjSource_GfxObjCached_ReturnsFalse() - { - // GfxObj source (high byte 0x01) WITH a cached GfxObjPhysics - // (i.e. the GfxObj's HasPhysics flag was set and its PhysicsBSP - // root is non-null) is NOT phantom. The staircase BSP entity - // 0x40B50089 from issue #101's capture, backed by GfxObj - // 0x01000C16 with hasPhys=True, is this case. - var cache = new PhysicsDataCache(); - var leaf = new PhysicsBSPNode { Type = BSPNodeType.Leaf }; - var fakePhysics = new GfxObjPhysics - { - BSP = new PhysicsBSPTree { Root = leaf }, - PhysicsPolygons = new Dictionary(), - Vertices = new VertexArray(), - Resolved = new Dictionary(), - }; - cache.RegisterGfxObjForTest(0x01000C16u, fakePhysics); - Assert.False(cache.IsPhantomGfxObjSource(0x01000C16u)); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs new file mode 100644 index 00000000..a58a7eca --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Guards the DAT-only shape-source rule (Slice 1 of the unified +/// collision-inclusion phase, 2026-06-24). +/// +/// +/// Retail oracle: CPartArray::InitParts@0x00517F40 (parts come from +/// the Setup list only), CGfxObj::Serialize@0x00534970 (physics_bsp +/// gated on serialized-flags bit-0), CPhysicsPart::find_obj_collisions@0x0050D8D0 +/// (returns OK / passable when physics_bsp == null). Render-mesh bounds +/// never enter collision. +/// +/// +public class ShadowShapeBuilderShapeSourceTests +{ + // Retail: an object with no DAT physics shape (no CylSphere, no Sphere, + // no part with a PhysicsBSP) emits NO collision shape and is passable. + // CPhysicsPart::find_obj_collisions@0x0050D8D0 returns OK when physics_bsp==null. + [Fact] + public void Setup_WithNoCylSpheres_NoSpheres_NoPhysicsBspParts_YieldsEmptyShapeList() + { + var setup = new Setup + { + CylSpheres = new List(), + Spheres = new List(), + Parts = { 0x01000ABCu }, + PlacementFrames = new Dictionary(), + }; + var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: _ => false); + Assert.Empty(shapes); + } +}