fix(render): #113 root cause #2 - GfxObj meshes draw only DrawingBSP-referenced polys (the REAL phantom staircase)
The user gate + bisect overturned the coincident-cell attribution: the phantom staircase persists in the PRE-session build (bisect screenshot at the hall wall) and is drawn by the ENTITY pipeline, untouched by any clip. Root cause (dat-proven, DumpHallModel_PolyFlagHistogram): retail renders a GfxObj by TRAVERSING its drawing BSP (D3DPolyRender); polygons present in the Polygons dictionary but referenced by NO DrawingBSP node are never drawn - they are physics/no-draw geometry. The Holtburg meeting hall (0x010014C3) keeps its exterior stair-ramp as dictionary polys 0+1: in the PhysicsBSP (ACE walks The Sentry on it at z 117-118; invisible-but- walkable in retail) but orphaned from the draw tree (true at ALL degrade levels - the LOD theory is dead, Degrades[0] IS the base model). The hill cottage (0x01000827) carries 8 such orphans. Our extraction iterated the dictionary -> drew the collision skeleton: the wall staircase up close, the flying stairs over the cottage roofline from afar (orphan ramp spans world 221-232 at z 116-124.5; visible over the cottage roof from the west). Fix: PrepareGfxObjMeshData filters to CollectDrawingBspPolygonIds(gfxObj) when a drawing BSP exists; models without one draw everything (unchanged). Physics untouched (collision keeps the full physics set - retail parity). CellStruct extraction not touched (different conventions; no orphan evidence there yet). Dat-backed pins: Issue113DrawingBspFilterTests (hall orphans == 0+1, cottage orphans == 0..7). Suites: App 226 / Core 1392 + the 4 pre-existing #99-era failures / UI 420 / Net 294. Note: the earlier shell-clip enable (927fd8f, scoped9ce335e) remains correct and orthogonal - it crops interior CELL geometry to apertures outdoors; this commit removes the phantom SHELL geometry at its source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6c9bbce433
commit
e46d3d9273
3 changed files with 202 additions and 1 deletions
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
/// </summary>
|
||||
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<DatReaderWriter.DBObjs.GfxObj>(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<DatReaderWriter.DBObjs.GfxObj>(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);
|
||||
}
|
||||
}
|
||||
|
|
@ -671,6 +671,98 @@ public sealed class Issue113PhantomStairsDumpTests
|
|||
return inside;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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?
|
||||
/// </summary>
|
||||
[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<DatReaderWriter.DBObjs.GfxObj>(mid)!;
|
||||
_out.WriteLine($"=== model 0x{mid:X8}: Flags={gfx.Flags} polys={gfx.Polygons.Count} physPolys={gfx.PhysicsPolygons?.Count ?? 0} ===");
|
||||
|
||||
var hist = new Dictionary<string, (int N, int Ramps)>();
|
||||
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<ushort>();
|
||||
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<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}");
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue