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)