From e22332541057a7c8c0893d2d40a0adf97271a65b Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 10 Jun 2026 22:10:20 +0200 Subject: [PATCH] test(conformance): door-vanish mystery SOLVED - every 'orphan' is a DrawingBSPNode.Portals PortalRef (no static filter can be right) Charter open mystery #1 (docs/research/2026-06-11-building-render-holistic- port-handoff.md "4.1): the e46d3d9 DrawingBSP poly filter made doors vanish because the PosNode/NegNode walk only collected node.Polygons and never node.Portals (List {PolyId, PortalIndex}). Dat-proven across all 13 Holtburg-area building models (A9B4/A9B3/AAB3/ A9B5/AAB4): TRUE-orphans = ZERO everywhere. Every dictionary poly the filter dropped is a PORTAL POLYGON - the baked door-filling (1.9x2.5 m) and window-filling quads at doorway/window apertures, AND the meeting hall's phantom stair polys {0,1} (ramp-shaped portal apertures into the interior stair cells). Consequences for the holistic port: - The door entities (setup 0x020019FF) were never affected: base parts + every degrade variant have full BSP coverage, and doors don't take the IsIssue47HumanoidSetup degrade swap. The vanished 'doors' were shell portal polys. - Retail draws portal polys CONDITIONALLY during portal-view traversal (closed doors/windows draw a surface; open apertures and the hall's stair apertures don't). The phantom staircase and the door rendering are the SAME mechanism with opposite signs - there is NO correct static filter; this is the dat-side proof the one-drawing-discipline port is required. - The exact retail conditional (BSP portal-node draw gate in CPhysicsPart::Draw / BSPPORTAL) is a named Phase A question. Diagnostic-only commit: new dump facts in Issue113DoorVanishDiagnosticTests (door setup + degrade chains, control models, Holtburg orphan sweep with portal discrimination). No production code. Co-Authored-By: Claude Fable 5 --- .../Issue113DoorVanishDiagnosticTests.cs | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Conformance/Issue113DoorVanishDiagnosticTests.cs diff --git a/tests/AcDream.Core.Tests/Conformance/Issue113DoorVanishDiagnosticTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue113DoorVanishDiagnosticTests.cs new file mode 100644 index 00000000..f698b0b0 --- /dev/null +++ b/tests/AcDream.Core.Tests/Conformance/Issue113DoorVanishDiagnosticTests.cs @@ -0,0 +1,319 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Reflection; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Conformance; + +/// +/// Holistic-port open mystery #1 (charter §4.1, 2026-06-11): why did the +/// DrawingBSP poly filter (e46d3d9, un-applied 124c6cb) make DOORS vanish +/// Holtburg-wide? Door Setup id 0x020019FF identified from [B.7] pick lines +/// (guid 0x7A9B403A at the inn doorway). This dump answers, per door part +/// GfxObj: does a DrawingBSP exist, what poly ids does the PosNode/NegNode +/// walk reference, what does the dictionary hold, and do the node objects +/// carry polygon references in properties the walk missed (portal-type +/// nodes / leaf subclasses)? Pure diagnostic — no production code. +/// +public sealed class Issue113DoorVanishDiagnosticTests +{ + private readonly ITestOutputHelper _out; + public Issue113DoorVanishDiagnosticTests(ITestOutputHelper output) => _out = output; + + private const uint DoorSetupId = 0x020019FFu; + + [Fact] + public void DumpDoorSetup_DrawingBspCoverage() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + var setup = dats.Get(DoorSetupId); + Assert.NotNull(setup); + _out.WriteLine($"=== Setup 0x{DoorSetupId:X8}: Flags={setup!.Flags} parts={setup.Parts.Count} ==="); + + foreach (var partId in setup.Parts.Distinct()) + { + DumpGfxObjBspCoverage(dats, partId); + + // GameWindow's spawn path applies GfxObjDegradeResolver to MeshRefs — + // the RENDERED id may be Degrades[0], not the base part id. Run the + // same coverage on every degrade variant. + var gfx = dats.Get(partId); + if (gfx is not null && 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} ids=[{string.Join(",", ddi.Degrades.Select(d => $"0x{d.Id:X8}"))}]"); + foreach (var d in ddi.Degrades.Select(d => (uint)d.Id).Distinct()) + { + if (d == 0 || d == partId) { _out.WriteLine($" (degrade 0x{d:X8} == base or zero — skipped)"); continue; } + DumpGfxObjBspCoverage(dats, d); + } + } + } + } + + /// + /// Hypothesis (b): what the user saw vanish was not door ENTITIES but + /// building-shell-baked door-looking geometry that the filter dropped + /// (DrawingBSP orphans shaped like vertical door slabs in doorway + /// apertures). Sweep every building model in the Holtburg-area + /// landblocks; per orphan poly print centroid / normal / span, flagging + /// vertical door-sized quads. + /// + [Fact] + public void DumpHoltburgBuildings_OrphanGeometry() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + var modelIds = new SortedSet(); + foreach (uint lb in new uint[] { 0xA9B40000u, 0xA9B30000u, 0xAAB30000u, 0xA9B50000u, 0xAAB40000u }) + { + var lbi = dats.Get(lb | 0xFFFEu); + if (lbi is null) continue; + foreach (var b in lbi.Buildings ?? new()) modelIds.Add(b.ModelId); + } + _out.WriteLine($"building models: [{string.Join(",", modelIds.Select(m => $"0x{m:X8}"))}]"); + + foreach (var mid in modelIds) + { + var gfx = dats.Get(mid); + if (gfx?.DrawingBSP?.Root is null) { _out.WriteLine($"0x{mid:X8}: no gfx/BSP"); continue; } + + var walked = new HashSet(); + void Walk(DatReaderWriter.Types.DrawingBSPNode? n) + { + if (n is null) return; + if (n.Polygons is not null) foreach (var pid in n.Polygons) walked.Add((ushort)pid); + Walk(n.PosNode); Walk(n.NegNode); + } + Walk(gfx.DrawingBSP.Root); + + // Portal-poly sweep: DrawingBSPNode.Portals elements are objects — + // reflect their properties and collect any ushort/short/int-valued + // ones as candidate polygon ids. + var portalPolyIds = new HashSet(); + var portalElemDumped = false; + void WalkPortals(DatReaderWriter.Types.DrawingBSPNode? n) + { + if (n is null) return; + if (n.Portals is not null) + { + foreach (var elem in (IEnumerable)n.Portals) + { + if (elem is null) continue; + var et = elem.GetType(); + if (!portalElemDumped) + { + portalElemDumped = true; + var props = et.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => $"{p.PropertyType.Name} {p.Name}"); + _out.WriteLine($" [portal elem type] {et.Name}: {string.Join("; ", props)}"); + } + if (elem is ushort us) portalPolyIds.Add(us); + else if (elem is short s && s >= 0) portalPolyIds.Add((ushort)s); + else + foreach (var p in et.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var v = p.GetValue(elem); + if (v is ushort pu && p.Name.Contains("Poly", StringComparison.OrdinalIgnoreCase)) portalPolyIds.Add(pu); + else if (v is short ps && ps >= 0 && p.Name.Contains("Poly", StringComparison.OrdinalIgnoreCase)) portalPolyIds.Add((ushort)ps); + else if (v is int pi && pi is >= 0 and <= ushort.MaxValue && p.Name.Contains("Poly", StringComparison.OrdinalIgnoreCase)) portalPolyIds.Add((ushort)pi); + } + } + } + WalkPortals(n.PosNode); WalkPortals(n.NegNode); + } + WalkPortals(gfx.DrawingBSP.Root); + + var orphans = gfx.Polygons.Keys.Where(k => !walked.Contains(k)).OrderBy(k => k).ToList(); + var orphansInPortals = orphans.Where(portalPolyIds.Contains).ToList(); + var orphansTrue = orphans.Where(o => !portalPolyIds.Contains(o)).ToList(); + _out.WriteLine(""); + _out.WriteLine($"=== model 0x{mid:X8}: dict={gfx.Polygons.Count} walked={walked.Count} orphans={orphans.Count} | portalPolyIds={portalPolyIds.Count} [{string.Join(",", portalPolyIds.OrderBy(x => x))}] | orphans-in-portals={orphansInPortals.Count} TRUE-orphans=[{string.Join(",", orphansTrue)}] ==="); + foreach (var pid in orphans) + { + var poly = gfx.Polygons[pid]; + if (poly.VertexIds.Count < 3) { _out.WriteLine($" poly {pid}: degenerate"); continue; } + var pts = new List(); + bool ok = true; + foreach (var vid in poly.VertexIds) + { + if (!gfx.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) { ok = false; break; } + pts.Add(new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z)); + } + if (!ok) continue; + var n2 = NewellNormal(pts); + var c = pts.Aggregate(Vector3.Zero, (a, b) => a + b) / pts.Count; + float sx = pts.Max(p => p.X) - pts.Min(p => p.X); + float sy = pts.Max(p => p.Y) - pts.Min(p => p.Y); + float sz = pts.Max(p => p.Z) - pts.Min(p => p.Z); + bool vertical = MathF.Abs(n2.Z) < 0.15f; + float horizSpan = MathF.Max(sx, sy); + bool doorish = vertical && sz is > 1.5f and < 3.5f && horizSpan is > 0.7f and < 2.5f; + _out.WriteLine( + $" poly {pid,3}: c=({c.X:F2},{c.Y:F2},{c.Z:F2}) n=({n2.X:F2},{n2.Y:F2},{n2.Z:F2}) " + + $"span=({sx:F1},{sy:F1},{sz:F1}) verts={pts.Count}{(doorish ? " <== DOOR-SIZED VERTICAL QUAD" : vertical ? " (vertical)" : MathF.Abs(n2.Z) > 0.9f ? " (flat)" : " (ramp)")}"); + } + } + } + + private static Vector3 NewellNormal(List pts) + { + var n = Vector3.Zero; + for (int i = 0; i < pts.Count; i++) + { + var a = pts[i]; var b = pts[(i + 1) % pts.Count]; + n.X += (a.Y - b.Y) * (a.Z + b.Z); + n.Y += (a.Z - b.Z) * (a.X + b.X); + n.Z += (a.X - b.X) * (a.Y + b.Y); + } + return n.LengthSquared() < 1e-10f ? Vector3.Zero : Vector3.Normalize(n); + } + + /// + /// Control group: the two #113 models (hall + cottage) whose orphans ARE + /// the phantom geometry, for type-model comparison against the door. + /// + [Fact] + public void DumpControls_HallAndCottage() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + DumpGfxObjBspCoverage(dats, 0x010014C3u); // meeting hall shell + DumpGfxObjBspCoverage(dats, 0x01000827u); // hill cottage shell + } + + private void DumpGfxObjBspCoverage(DatCollection dats, uint gfxId) + { + var gfx = dats.Get(gfxId); + if (gfx is null) { _out.WriteLine($"GfxObj 0x{gfxId:X8}: MISSING"); return; } + + _out.WriteLine(""); + _out.WriteLine($"=== GfxObj 0x{gfxId:X8}: Flags={gfx.Flags} dictPolys={gfx.Polygons.Count} physPolys={gfx.PhysicsPolygons?.Count ?? 0} verts={gfx.VertexArray.Vertices.Count} ==="); + + if (gfx.DrawingBSP?.Root is null) + { + _out.WriteLine(" DrawingBSP: NULL (filter would draw everything — door vanish NOT explained here)"); + return; + } + + // --- 1. The e46d3d9 walk: PosNode/NegNode + node.Polygons only --- + var walked = new HashSet(); + var nodeTypes = new Dictionary(); + void Walk(DatReaderWriter.Types.DrawingBSPNode? node) + { + if (node is null) return; + var tn = node.GetType().Name; + nodeTypes[tn] = nodeTypes.TryGetValue(tn, out var c) ? c + 1 : 1; + if (node.Polygons is not null) + foreach (var pid in node.Polygons) walked.Add((ushort)pid); + Walk(node.PosNode); + Walk(node.NegNode); + } + Walk(gfx.DrawingBSP.Root); + + _out.WriteLine($" node runtime types: {string.Join(", ", nodeTypes.Select(kv => $"{kv.Key}×{kv.Value}"))}"); + _out.WriteLine($" e46d3d9 walk referenced={walked.Count} / dict={gfx.Polygons.Count}"); + + var orphans = gfx.Polygons.Keys.Where(k => !walked.Contains(k)).OrderBy(k => k).ToList(); + _out.WriteLine($" ORPHANS (filter would DROP): {orphans.Count} ids=[{string.Join(",", orphans.Take(60))}]"); + + // --- 2. Reflection sweep: per node type, every property that could hold + // polygon ids the walk missed (portal nodes, leaf indexes, etc.) --- + var seenTypes = new HashSet(); + var extraRefs = new Dictionary>(); + void Sweep(object? node) + { + if (node is null) return; + var t = node.GetType(); + if (seenTypes.Add(t)) + { + var props = t.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => $"{p.PropertyType.Name} {p.Name}"); + _out.WriteLine($" [type] {t.Name}: {string.Join("; ", props)}"); + } + foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (p.GetIndexParameters().Length > 0) continue; + object? v; + try { v = p.GetValue(node); } catch { continue; } + if (v is null) continue; + + if (p.Name is "PosNode" or "NegNode") { Sweep(v); continue; } + + // any enumerable of ushort/short/int that isn't the known Polygons list + if (p.Name != "Polygons" && v is IEnumerable en && v is not string) + { + var ids = new HashSet(); + foreach (var item in en) + { + if (item is ushort us) ids.Add(us); + else if (item is short s && s >= 0) ids.Add((ushort)s); + else if (item is int i && i is >= 0 and <= ushort.MaxValue) ids.Add((ushort)i); + else { ids = null!; break; } + } + if (ids is { Count: > 0 }) + { + var key = $"{t.Name}.{p.Name}"; + if (!extraRefs.TryGetValue(key, out var set)) extraRefs[key] = set = new HashSet(); + set.UnionWith(ids); + } + } + // nested single node-like objects (e.g. a portal payload object) + else if (v is DatReaderWriter.Types.DrawingBSPNode child && p.Name is not "PosNode" and not "NegNode") + { + Sweep(child); + } + } + } + Sweep(gfx.DrawingBSP.Root); + + foreach (var (key, set) in extraRefs.OrderBy(k => k.Key)) + { + var hit = set.Where(id => gfx.Polygons.ContainsKey(id)).OrderBy(x => x).ToList(); + var orphanRescue = hit.Where(id => orphans.Contains(id)).ToList(); + _out.WriteLine($" [extra ids] {key}: {set.Count} values, {hit.Count} are valid poly ids, {orphanRescue.Count} would rescue orphans [{string.Join(",", orphanRescue.Take(40))}]"); + } + + // --- 3. Orphan geometry: what would the filter have dropped, visually? --- + if (orphans.Count > 0) + { + var omin = new Vector3(float.MaxValue); var omax = new Vector3(float.MinValue); + int degenerate = 0; + foreach (var pid in orphans) + { + var poly = gfx.Polygons[pid]; + if (poly.VertexIds.Count < 3) { degenerate++; continue; } + foreach (var vid in poly.VertexIds) + if (gfx.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) + { + var p = new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z); + omin = Vector3.Min(omin, p); omax = Vector3.Max(omax, p); + } + } + _out.WriteLine($" orphan AABB=({omin.X:F2},{omin.Y:F2},{omin.Z:F2})..({omax.X:F2},{omax.Y:F2},{omax.Z:F2}) degenerate={degenerate}"); + } + + // --- 4. Full-model AABB for scale --- + var mmin = new Vector3(float.MaxValue); var mmax = new Vector3(float.MinValue); + foreach (var kvp in gfx.VertexArray.Vertices) + { + var p = new Vector3(kvp.Value.Origin.X, kvp.Value.Origin.Y, kvp.Value.Origin.Z); + mmin = Vector3.Min(mmin, p); mmax = Vector3.Max(mmax, p); + } + _out.WriteLine($" model AABB=({mmin.X:F2},{mmin.Y:F2},{mmin.Z:F2})..({mmax.X:F2},{mmax.Y:F2},{mmax.Z:F2})"); + } +}