diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index f50a144..fbe8a2d 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -359,6 +359,25 @@ 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:6116 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 new file mode 100644 index 0000000..5312aaf --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PhysicsDataCachePhantomSourceTests.cs @@ -0,0 +1,74 @@ +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:6116 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)); + } +}