diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
index 118e0134..f73e9226 100644
--- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
+++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
@@ -988,6 +988,27 @@ namespace AcDream.App.Rendering.Wb {
}
}
+ ///
+ /// #113: the set of polygon ids referenced by the GfxObj's drawing BSP —
+ /// the polys retail actually renders (D3DPolyRender traverses the BSP;
+ /// dictionary-orphaned polys are physics/no-draw geometry). Returns null
+ /// when the model has no drawing BSP (caller draws everything).
+ ///
+ internal static HashSet? CollectDrawingBspPolygonIds(GfxObj gfxObj) {
+ if (gfxObj.DrawingBSP?.Root is null) return null;
+ var ids = new HashSet();
+ CollectDrawingBspPolygonIds(gfxObj.DrawingBSP.Root, ids);
+ return ids;
+ }
+
+ private static void CollectDrawingBspPolygonIds(DatReaderWriter.Types.DrawingBSPNode node, HashSet ids) {
+ if (node.Polygons is not null)
+ foreach (var pid in node.Polygons)
+ ids.Add((ushort)pid);
+ if (node.PosNode is not null) CollectDrawingBspPolygonIds(node.PosNode, ids);
+ if (node.NegNode is not null) CollectDrawingBspPolygonIds(node.NegNode, ids);
+ }
+
private ObjectMeshData? PrepareGfxObjMeshData(ulong id, GfxObj gfxObj, Vector3 scale, CancellationToken ct) {
var vertices = new List();
var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>();
@@ -996,8 +1017,23 @@ namespace AcDream.App.Rendering.Wb {
var (min, max) = ComputeBounds(gfxObj, scale);
var boundingBox = new BoundingBox(min, max);
- foreach (var poly in gfxObj.Polygons.Values) {
+ // #113 (2026-06-11): retail draws a GfxObj by TRAVERSING its drawing
+ // BSP — a polygon present in the Polygons dictionary but referenced by
+ // no DrawingBSP node is never rendered (physics/no-draw geometry).
+ // The Holtburg meeting hall (0x010014C3) keeps its walkable exterior
+ // stair-ramp as dictionary polys {0,1}: in the PhysicsBSP (NPCs walk
+ // it) but absent from every DrawingBSP node — retail shows a plain
+ // wall; iterating the dictionary drew the "phantom staircase"
+ // (invisible-but-walkable in retail, visible in acdream). The hill
+ // cottage (0x01000827) carries 8 such orphans. Filter to the BSP-
+ // referenced set when a drawing BSP exists; models without one draw
+ // everything (unchanged).
+ var drawnPolyIds = CollectDrawingBspPolygonIds(gfxObj);
+
+ foreach (var polyEntry in gfxObj.Polygons) {
ct.ThrowIfCancellationRequested();
+ if (drawnPolyIds is not null && !drawnPolyIds.Contains(polyEntry.Key)) continue;
+ var poly = polyEntry.Value;
if (poly.VertexIds.Count < 3) continue;
// Handle Positive Surface
diff --git a/tests/AcDream.App.Tests/Rendering/Issue113DrawingBspFilterTests.cs b/tests/AcDream.App.Tests/Rendering/Issue113DrawingBspFilterTests.cs
new file mode 100644
index 00000000..a1c95cff
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Issue113DrawingBspFilterTests.cs
@@ -0,0 +1,73 @@
+using System;
+using System.IO;
+using System.Linq;
+using AcDream.App.Rendering.Wb;
+using DatReaderWriter;
+using DatReaderWriter.Options;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace AcDream.App.Tests.Rendering;
+
+///
+/// #113 root cause #2 (2026-06-11): retail renders a GfxObj by traversing its
+/// DRAWING BSP — dictionary polygons referenced by no BSP node are physics/
+/// no-draw geometry and are never drawn. The Holtburg meeting hall
+/// (0x010014C3) keeps its walkable exterior stair-ramp as dictionary polys
+/// {0,1} (present in the PhysicsBSP — The Sentry patrols it; absent from
+/// every DrawingBSP node — retail shows a plain wall). Our extraction
+/// iterated the dictionary and drew the "phantom staircase". These pins hold
+/// ObjectMeshManager.CollectDrawingBspPolygonIds to the dat facts.
+///
+public class Issue113DrawingBspFilterTests
+{
+ private readonly ITestOutputHelper _out;
+ public Issue113DrawingBspFilterTests(ITestOutputHelper output) => _out = output;
+
+ private static string? ResolveDatDir()
+ {
+ var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
+ if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
+ return fromEnv;
+ var def = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Documents", "Asheron's Call");
+ return Directory.Exists(def) ? def : null;
+ }
+
+ [Fact]
+ public void MeetingHall_OrphanStairPolys_AreExcludedFromDrawSet()
+ {
+ var datDir = ResolveDatDir();
+ if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+
+ var hall = dats.Get(0x010014C3u)!;
+ var drawn = ObjectMeshManager.CollectDrawingBspPolygonIds(hall);
+ Assert.NotNull(drawn);
+
+ // The two orphans (the invisible walkable stair-ramp) must be excluded;
+ // everything else referenced.
+ Assert.DoesNotContain((ushort)0, drawn!);
+ Assert.DoesNotContain((ushort)1, drawn!);
+ var orphans = hall.Polygons.Keys.Where(k => !drawn.Contains(k)).OrderBy(k => k).ToArray();
+ _out.WriteLine($"hall orphans: [{string.Join(",", orphans)}]");
+ Assert.Equal(new ushort[] { 0, 1 }, orphans);
+ }
+
+ [Fact]
+ public void HillCottage_OrphanPolys_AreExcludedFromDrawSet()
+ {
+ var datDir = ResolveDatDir();
+ if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+
+ var cottage = dats.Get(0x01000827u)!;
+ var drawn = ObjectMeshManager.CollectDrawingBspPolygonIds(cottage);
+ Assert.NotNull(drawn);
+
+ var orphans = cottage.Polygons.Keys.Where(k => !drawn!.Contains(k)).OrderBy(k => k).ToArray();
+ _out.WriteLine($"cottage orphans: [{string.Join(",", orphans)}]");
+ Assert.Equal(new ushort[] { 0, 1, 2, 3, 4, 5, 6, 7 }, orphans);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Conformance/Issue113PhantomStairsDumpTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue113PhantomStairsDumpTests.cs
index 1e704c35..c0aa6cff 100644
--- a/tests/AcDream.Core.Tests/Conformance/Issue113PhantomStairsDumpTests.cs
+++ b/tests/AcDream.Core.Tests/Conformance/Issue113PhantomStairsDumpTests.cs
@@ -671,6 +671,98 @@ public sealed class Issue113PhantomStairsDumpTests
return inside;
}
+ ///
+ /// Round 6 (post-bisect): the phantom stairs+walkway render in BOTH builds
+ /// (the clip only hid the at-wall part). Retail draws NONE of it on this
+ /// face. Discriminator: do the meeting-hall model's ramp polys carry
+ /// distinctive per-poly flags (Stippling / SidesType / surfaces) that
+ /// retail skips and our building-mesh extraction mishandles?
+ ///
+ [Fact]
+ public void DumpHallModel_PolyFlagHistogram()
+ {
+ var datDir = ConformanceDats.ResolveDatDir();
+ if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+
+ foreach (uint mid in new uint[] { 0x010014C3u, 0x01000827u })
+ {
+ var gfx = dats.Get(mid)!;
+ _out.WriteLine($"=== model 0x{mid:X8}: Flags={gfx.Flags} polys={gfx.Polygons.Count} physPolys={gfx.PhysicsPolygons?.Count ?? 0} ===");
+
+ var hist = new Dictionary();
+ foreach (var kv in gfx.Polygons)
+ {
+ var poly = kv.Value;
+ var pts = ResolveLocalPoly(gfx, poly);
+ if (pts is null) continue;
+ var n = NewellNormal(pts);
+ bool ramp = n != Vector3.Zero && MathF.Abs(n.Z) > 0.15f && MathF.Abs(n.Z) <= 0.9f;
+ string key = $"stip={poly.Stippling} sides={poly.SidesType} posSurf={poly.PosSurface} negSurf={poly.NegSurface}";
+ var e = hist.TryGetValue(key, out var v) ? v : (N: 0, Ramps: 0);
+ hist[key] = (e.N + 1, e.Ramps + (ramp ? 1 : 0));
+ }
+ foreach (var (key, v) in hist.OrderByDescending(k => k.Value.N))
+ _out.WriteLine($" [{v.N,3} polys / {v.Ramps,3} ramps] {key}");
+
+ if (gfx.PhysicsPolygons is not null && gfx.PhysicsPolygons.Count > 0)
+ {
+ int shared = gfx.Polygons.Keys.Count(k => gfx.PhysicsPolygons.ContainsKey(k));
+ _out.WriteLine($" physPoly ids shared with render polys: {shared}/{gfx.Polygons.Count}");
+ }
+
+ // Drawing-BSP coverage: retail draws a GfxObj by TRAVERSING the
+ // drawing BSP; polys absent from every BSP node's Polygons list are
+ // never drawn (orphans = physics/legacy geometry). Our extraction
+ // iterates the Polygons dictionary directly — it draws orphans.
+ if (gfx.DrawingBSP?.Root is not null)
+ {
+ var referenced = new HashSet();
+ void Walk(DatReaderWriter.Types.DrawingBSPNode? node)
+ {
+ if (node is null) return;
+ if (node.Polygons is not null)
+ foreach (var pid in node.Polygons) referenced.Add((ushort)pid);
+ Walk(node.PosNode);
+ Walk(node.NegNode);
+ }
+ Walk(gfx.DrawingBSP.Root);
+
+ var orphans = gfx.Polygons.Keys.Where(k => !referenced.Contains(k)).OrderBy(k => k).ToList();
+ _out.WriteLine($" drawingBSP referenced={referenced.Count} dictPolys={gfx.Polygons.Count} ORPHANS={orphans.Count}");
+ int orphanRamps = 0; var omin = new Vector3(float.MaxValue); var omax = new Vector3(float.MinValue);
+ foreach (var pid in orphans)
+ {
+ var pts = ResolveLocalPoly(gfx, gfx.Polygons[pid]);
+ if (pts is null) continue;
+ foreach (var p in pts) { omin = Vector3.Min(omin, p); omax = Vector3.Max(omax, p); }
+ var n = NewellNormal(pts);
+ if (n != Vector3.Zero && MathF.Abs(n.Z) > 0.15f && MathF.Abs(n.Z) <= 0.9f) orphanRamps++;
+ }
+ if (orphans.Count > 0)
+ _out.WriteLine($" orphan stats: ramps={orphanRamps} AABB=({omin.X:F1},{omin.Y:F1},{omin.Z:F1})..({omax.X:F1},{omax.Y:F1},{omax.Z:F1}) ids=[{string.Join(",", orphans.Take(40))}]");
+ }
+ else
+ {
+ _out.WriteLine(" drawingBSP: NULL");
+ }
+
+ // Degrade chain: retail draws Degrades[0] (close detail), never the
+ // base, when a table exists (GfxObjDegradeResolver doc; #47).
+ if (gfx.Flags.HasFlag(DatReaderWriter.Enums.GfxObjFlags.HasDIDDegrade) && gfx.DIDDegrade != 0)
+ {
+ var ddi = dats.Get(gfx.DIDDegrade);
+ if (ddi is null) { _out.WriteLine($" DIDDegrade=0x{gfx.DIDDegrade:X8} MISSING"); continue; }
+ _out.WriteLine($" DIDDegrade=0x{gfx.DIDDegrade:X8} entries={ddi.Degrades.Count}");
+ foreach (var d in ddi.Degrades)
+ {
+ _out.WriteLine($" degrade id=0x{d.Id:X8}");
+ ScanGfxStairs(dats, (uint)d.Id, $" -> ramp scan 0x{d.Id:X8}");
+ }
+ }
+ }
+ }
+
private void ScanModelStairs(DatCollection dats, uint modelId)
{
if ((modelId & 0xFF000000u) == 0x01000000u)