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));
+ }
+}