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