acdream/tests/AcDream.Core.Tests/Conformance/Issue113DoorVanishDiagnosticTests.cs
Erik 695eca2c1f BR-1: RESOLVED as already-equivalent - premise falsified by pre-check, equivalence pinned, #113 attribution corrected
The plan's BR-1 ('implement the skipNoTexture draw-time surface gate')
died on its pre-check: acdream ALREADY suppresses every portal fill.
ReplicateProductionEmission_OnPortalFills replicates the exact emission
conditions of the production extractors on the hall/cottage fills:
pos=False neg=False for every one (Stippling.NoPos skips the positive
side at ObjectMeshManager.PrepareGfxObjMeshData:1046,
PrepareCellStructMeshData:1394, CellMesh.Build:44, GfxObjMesh.Build:71;
the fills have no negative surface). There is nothing to gate.

What ships instead: StipplingSurfaceEquivalenceTests - 2,607 polys across
13 building models + 13 environments, ZERO violations both directions:
NoPos <=> untextured-surface. Our build-time skip is proven equivalent to
retail's draw-time skipNoTexture rule (Ghidra 0x0059d4a4, default on
@0x00820e30) on this content. The pin fails loudly if future content
breaks the invariant - the cue to implement the draw-time gate then.

Corrections folded into the plan + comparison docs:
- The #113 phantom residual CANNOT be GfxObj fills (they never reach a
  vertex buffer). Plausible true sites are cell-side: flood-admitted
  cells drawn with the pass-all NoClipSlice when slot-less
  (RetailPViewRenderer.cs:71), and/or cell statics drawn unclipped +
  un-viewcone'd (object-lists-skip-portal-view-gate, confirmed).
  BR-2 opens with the probe that pins which.
- The e46d3d9 user-gate observations (filter removed phantom/doors) were
  confounded - the filter was a provable mesh no-op on shells AND doors.
- Ledger rows solid-surface-skip-missing + the acdream half of
  portal-polys-baked-unconditional re-marked REFUTED-for-fills; the
  retail mechanism descriptions and the un-consumed PortalIndex->
  CBldPortal pairing (BR-4) stand.

Suites: Core 1398 green (1392 baseline + 6 new facts) + the 4 pre-existing
#99-era failures + 1 skip. No production code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 06:25:31 +02:00

415 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// 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.
/// </summary>
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<DatReaderWriter.DBObjs.Setup>(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<DatReaderWriter.DBObjs.GfxObj>(partId);
if (gfx is not null && gfx.Flags.HasFlag(DatReaderWriter.Enums.GfxObjFlags.HasDIDDegrade) && gfx.DIDDegrade != 0)
{
var ddi = dats.Get<DatReaderWriter.DBObjs.GfxObjDegradeInfo>(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);
}
}
}
}
/// <summary>
/// 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.
/// </summary>
[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<uint>();
foreach (uint lb in new uint[] { 0xA9B40000u, 0xA9B30000u, 0xAAB30000u, 0xA9B50000u, 0xAAB40000u })
{
var lbi = dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(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<DatReaderWriter.DBObjs.GfxObj>(mid);
if (gfx?.DrawingBSP?.Root is null) { _out.WriteLine($"0x{mid:X8}: no gfx/BSP"); continue; }
var walked = new HashSet<ushort>();
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<ushort>();
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<Vector3>();
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<Vector3> 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);
}
/// <summary>
/// BR-1 pre-check: replicate ObjectMeshManager.PrepareGfxObjMeshData's
/// EXACT emission conditions (lines 1040-1058: pos side emitted unless
/// Stippling.NoPos; neg side if Negative/Both or (!NoNeg and SidesType ==
/// Clockwise)) on the portal-fill polys, printing raw enum values. The
/// #113 evidence says these fills RENDER (the phantom) — if the replica
/// says "skipped", the fills reach the screen another way and BR-1's gate
/// must move.
/// </summary>
[Fact]
public void ReplicateProductionEmission_OnPortalFills()
{
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<DatReaderWriter.DBObjs.GfxObj>(mid)!;
var walked = new HashSet<ushort>();
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);
var fills = gfx.Polygons.Keys.Where(k => !walked.Contains(k)).OrderBy(k => k).ToList();
_out.WriteLine($"=== model 0x{mid:X8} ===");
foreach (var pid in fills)
{
var poly = gfx.Polygons[pid];
bool noPos = poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos);
bool hasNeg = poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.Negative)
|| poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.Both)
|| (!poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoNeg)
&& poly.SidesType == DatReaderWriter.Enums.CullMode.Clockwise);
_out.WriteLine(
$" poly {pid,3}: stip={poly.Stippling}(raw={(int)poly.Stippling}) sides={poly.SidesType}(raw={(int)poly.SidesType}) " +
$"posSurf={poly.PosSurface} negSurf={poly.NegSurface} " +
$"-> production emits: pos={!noPos} neg={hasNeg && poly.NegSurface >= 0}");
}
}
}
/// <summary>
/// Phase A confirmation: retail's building/cell mesh pass skips surface
/// batches whose CSurface type has neither Base1Image (0x2) nor
/// Base1ClipMap (0x4) — the skipNoTexture rule (D3DPolyRender inner draw
/// 0x0059d4a0; default skipNoTexture=1). Prediction: the hall's phantom
/// stair-ramp portal-fill polys {0,1} reference SOLID (untextured)
/// surfaces while cottage door/window fills reference TEXTURED surfaces —
/// which is exactly why retail shows doors but not the ramp.
/// </summary>
[Fact]
public void DumpPortalFillSurfaceTypes()
{
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, 0x0100082Eu, 0x01000C17u })
{
var gfx = dats.Get<DatReaderWriter.DBObjs.GfxObj>(mid);
if (gfx?.DrawingBSP?.Root is null) continue;
var walked = new HashSet<ushort>();
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);
var portalFills = gfx.Polygons.Keys.Where(k => !walked.Contains(k)).OrderBy(k => k).ToList();
_out.WriteLine($"=== model 0x{mid:X8}: {portalFills.Count} portal-fill polys; Surfaces list has {gfx.Surfaces.Count} entries ===");
foreach (var pid in portalFills)
{
var poly = gfx.Polygons[pid];
string Describe(short surfIdx)
{
if (surfIdx < 0 || surfIdx >= gfx.Surfaces.Count) return $"idx{surfIdx}=OOB";
uint sid = gfx.Surfaces[surfIdx];
var surf = dats.Get<DatReaderWriter.DBObjs.Surface>(sid);
if (surf is null) return $"0x{sid:X8}=MISSING";
bool textured = surf.Type.HasFlag(DatReaderWriter.Enums.SurfaceType.Base1Image)
|| surf.Type.HasFlag(DatReaderWriter.Enums.SurfaceType.Base1ClipMap);
return $"0x{sid:X8} type={surf.Type} -> {(textured ? "TEXTURED (drawn)" : "SOLID (skipNoTexture SKIPS on building/cell pass)")}";
}
_out.WriteLine($" poly {pid,3}: sides={poly.SidesType} stip={poly.Stippling} pos[{Describe(poly.PosSurface)}] neg[{Describe(poly.NegSurface)}]");
}
}
}
/// <summary>
/// Control group: the two #113 models (hall + cottage) whose orphans ARE
/// the phantom geometry, for type-model comparison against the door.
/// </summary>
[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<DatReaderWriter.DBObjs.GfxObj>(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<ushort>();
var nodeTypes = new Dictionary<string, int>();
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<Type>();
var extraRefs = new Dictionary<string, HashSet<ushort>>();
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<ushort>();
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<ushort>();
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})");
}
}