acdream/tests/AcDream.Core.Tests/Conformance/Issue113PhantomStairsDumpTests.cs
Erik e46d3d9273 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, scoped 9ce335e) 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>
2026-06-10 20:52:52 +02:00

907 lines
46 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.Generic;
using System.Linq;
using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
namespace AcDream.Core.Tests.Conformance;
/// <summary>
/// #113 diagnostic dump (2026-06-10): an A9B3 building near local (183, 81, 116)
/// renders a phantom exterior staircase that retail does not show. Hypotheses:
/// H1 — a misplaced interior EnvCell (parse/transform bug; would also explain the
/// #112 containment gap at local (184.9, 82.5, 116.5) and missing object
/// collision); H2 — the cell is placed exactly where the dat says (AC interiors
/// legitimately overflow their shells) and the divergence is draw POLICY (retail
/// only draws interior cells flagged SeenOutside / portal-visible from outdoors).
/// This dump prints the raw dat facts that discriminate: building frames, every
/// interior cell's Position + world AABB + SeenOutside flag + stair signature
/// (distinct horizontal-face heights), and containment of the two key points.
/// </summary>
public sealed class Issue113PhantomStairsDumpTests
{
private readonly ITestOutputHelper _out;
public Issue113PhantomStairsDumpTests(ITestOutputHelper output) => _out = output;
private const uint Landblock = 0xA9B30000u;
private const uint EnvironmentFilePrefix = 0x0D000000u;
// User-observed phantom-stairs spot (A9B3-local frame; world y + 192).
private static readonly Vector3 StairsSpot = new(183.0f, 81.0f, 116.5f);
// #112 containment-gap point (A9B3-local, foot-center height).
private static readonly Vector3 GapPoint = new(184.915f, 82.464f, 116.48f);
[Fact]
public void DumpA9B3_Buildings_And_InteriorCells()
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var lbi = dats.Get<DatLandBlockInfo>(Landblock | 0xFFFEu);
Assert.NotNull(lbi);
_out.WriteLine($"=== LandBlockInfo 0x{Landblock | 0xFFFEu:X8}: NumCells={lbi!.NumCells}, Buildings={lbi.Buildings?.Count ?? 0} ===");
// ---- load every interior cell ----
var cells = new Dictionary<uint, (DatEnvCell Cell, List<Vector3> WorldVerts, Vector3 Min, Vector3 Max, List<float> FlatHeights, int RampPolys)>();
for (uint low = 0x0100; low < 0x0100 + lbi.NumCells; low++)
{
uint id = Landblock | low;
var dc = dats.Get<DatEnvCell>(id);
if (dc is null) { _out.WriteLine($" cell 0x{id:X8}: MISSING"); continue; }
var env = dats.Get<DatEnvironment>(EnvironmentFilePrefix | dc.EnvironmentId);
if (env is null || !env.Cells.TryGetValue(dc.CellStructure, out var cs) || cs is null)
{ _out.WriteLine($" cell 0x{id:X8}: env 0x{dc.EnvironmentId:X} struct {dc.CellStructure} MISSING"); continue; }
var world = ConformanceDats.WorldTransform(dc);
var verts = new List<Vector3>(cs.VertexArray.Vertices.Count);
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
foreach (var kvp in cs.VertexArray.Vertices)
{
var p = Vector3.Transform(
new Vector3(kvp.Value.Origin.X, kvp.Value.Origin.Y, kvp.Value.Origin.Z), world);
verts.Add(p);
min = Vector3.Min(min, p);
max = Vector3.Max(max, p);
}
// Stair signature: per-polygon world normal via Newell; horizontal
// faces (|n.Z| > 0.9) binned by height; mid-slope faces counted.
var flatHeights = new List<float>();
int rampPolys = 0;
foreach (var poly in cs.Polygons.Values)
{
if (poly.VertexIds.Count < 3) continue;
var pts = new List<Vector3>(poly.VertexIds.Count);
bool ok = true;
foreach (var vid in poly.VertexIds)
{
if (!cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) { ok = false; break; }
pts.Add(Vector3.Transform(new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z), world));
}
if (!ok) continue;
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);
}
if (n.LengthSquared() < 1e-10f) continue;
n = Vector3.Normalize(n);
if (MathF.Abs(n.Z) > 0.9f)
flatHeights.Add(pts.Average(p => p.Z));
else if (MathF.Abs(n.Z) > 0.15f)
rampPolys++;
}
cells[id] = (dc, verts, min, max, flatHeights, rampPolys);
}
// ---- per-cell report ----
_out.WriteLine("");
_out.WriteLine("=== Interior cells (positions are A9B3-local; world y = local y - 192) ===");
foreach (var (id, c) in cells.OrderBy(k => k.Key))
{
var dc = c.Cell;
var o = dc.Position.Origin;
var q = dc.Position.Orientation;
var distinctHeights = c.FlatHeights.Select(h => MathF.Round(h, 1)).Distinct().OrderBy(h => h).ToList();
string stairSig = distinctHeights.Count > 3
? $"STAIR? heights=[{string.Join(",", distinctHeights.Select(h => h.ToString("F1")))}]"
: $"heights=[{string.Join(",", distinctHeights.Select(h => h.ToString("F1")))}]";
_out.WriteLine(
$"cell 0x{id:X8} env=0x{dc.EnvironmentId:X4} struct={dc.CellStructure} " +
$"pos=({o.X:F2},{o.Y:F2},{o.Z:F2}) quat=(w{q.W:F3} x{q.X:F3} y{q.Y:F3} z{q.Z:F3}) " +
$"flags={dc.Flags} portals=[{string.Join(",", dc.CellPortals.Select(p => $"0x{p.OtherCellId:X3}({p.Flags})"))}] " +
$"vis={dc.VisibleCells?.Count ?? 0} statics={dc.StaticObjects?.Count ?? 0}");
_out.WriteLine(
$" AABB local=({c.Min.X:F1},{c.Min.Y:F1},{c.Min.Z:F1})..({c.Max.X:F1},{c.Max.Y:F1},{c.Max.Z:F1}) " +
$"rampPolys={c.RampPolys} {stairSig}");
bool nearStairs = AabbContains(c.Min, c.Max, StairsSpot, 1.5f);
bool hasGap = AabbContains(c.Min, c.Max, GapPoint, 0.0f);
if (nearStairs) _out.WriteLine($" *** AABB within 1.5m of phantom-stairs spot ({StairsSpot.X},{StairsSpot.Y},{StairsSpot.Z}) ***");
if (hasGap) _out.WriteLine($" *** AABB contains #112 gap point ({GapPoint.X},{GapPoint.Y},{GapPoint.Z}) ***");
}
// ---- buildings ----
_out.WriteLine("");
_out.WriteLine("=== Buildings ===");
int bIdx = 0;
foreach (var b in lbi.Buildings ?? new())
{
var o = b.Frame.Origin;
var q = b.Frame.Orientation;
var seeds = (b.Portals ?? new()).Where(p => p.OtherCellId != 0xFFFF)
.Select(p => (uint)(Landblock | p.OtherCellId)).Distinct().ToList();
// BFS through cell portals for the full cell set (mirrors BuildingLoader).
var set = new HashSet<uint>(seeds);
var queue = new Queue<uint>(seeds);
while (queue.Count > 0)
{
var cur = queue.Dequeue();
if (!cells.TryGetValue(cur, out var cc)) continue;
foreach (var p in cc.Cell.CellPortals)
{
if (p.OtherCellId == 0xFFFF) continue;
uint nid = Landblock | p.OtherCellId;
if (set.Add(nid)) queue.Enqueue(nid);
}
}
float dStairs = Vector2.Distance(new Vector2(o.X, o.Y), new Vector2(StairsSpot.X, StairsSpot.Y));
_out.WriteLine(
$"building[{bIdx}] model=0x{b.ModelId:X8} pos=({o.X:F2},{o.Y:F2},{o.Z:F2}) " +
$"quat=(w{q.W:F3} x{q.X:F3} y{q.Y:F3} z{q.Z:F3}) numLeaves={b.NumLeaves} " +
$"portals={b.Portals?.Count ?? 0} dist2D(stairsSpot)={dStairs:F1}m");
_out.WriteLine(
$" cells=[{string.Join(",", set.OrderBy(x => x).Select(x => $"0x{x & 0xFFFF:X3}"))}]");
bIdx++;
}
// ---- containment summary at the two key points ----
_out.WriteLine("");
_out.WriteLine("=== Containment (AABB-level) at key points ===");
foreach (var (label, pt) in new[] { ("stairsSpot", StairsSpot), ("gapPoint", GapPoint) })
{
var hits = cells.Where(kv => AabbContains(kv.Value.Min, kv.Value.Max, pt, 0f))
.Select(kv => $"0x{kv.Key & 0xFFFF:X3}").ToList();
_out.WriteLine($"{label} ({pt.X:F1},{pt.Y:F1},{pt.Z:F1}): AABB-contained by [{string.Join(",", hits)}]");
}
}
private static bool AabbContains(Vector3 min, Vector3 max, Vector3 p, float pad) =>
p.X >= min.X - pad && p.X <= max.X + pad &&
p.Y >= min.Y - pad && p.Y <= max.Y + pad &&
p.Z >= min.Z - pad && p.Z <= max.Z + pad;
/// <summary>
/// Round 2: the stair suspect must be either (a) a static object inside a
/// ground cell, (b) an outdoor stab, or (c) part of the shell GfxObj itself.
/// Dump all three + the shell AABB (TARDIS check: interior cells overflow
/// the shell?).
/// </summary>
[Fact]
public void DumpA9B3_Statics_OutdoorStabs_And_ShellModel()
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var lbi = dats.Get<DatLandBlockInfo>(Landblock | 0xFFFEu);
Assert.NotNull(lbi);
_out.WriteLine("=== LandBlockInfo.Objects (outdoor stabs) ===");
foreach (var stab in lbi!.Objects ?? new())
{
var o = stab.Frame.Origin;
float d = Vector2.Distance(new Vector2(o.X, o.Y), new Vector2(StairsSpot.X, StairsSpot.Y));
_out.WriteLine($" stab model=0x{stab.Id:X8} pos=({o.X:F2},{o.Y:F2},{o.Z:F2}) dist2D(stairsSpot)={d:F1}m");
}
_out.WriteLine("");
_out.WriteLine("=== Per-cell StaticObjects (frame is CELL-LOCAL; world = cellPos + local here, identity quat) ===");
var cellPos = new Vector3(180f, 84f, 116f);
var staticModels = new HashSet<uint>();
for (uint low = 0x0100; low < 0x0100 + lbi.NumCells; low++)
{
uint id = Landblock | low;
var dc = dats.Get<DatEnvCell>(id);
if (dc?.StaticObjects is null || dc.StaticObjects.Count == 0) continue;
foreach (var s in dc.StaticObjects)
{
var o = s.Frame.Origin;
var w = cellPos + o;
var q = s.Frame.Orientation;
float d = Vector2.Distance(new Vector2(w.X, w.Y), new Vector2(StairsSpot.X, StairsSpot.Y));
staticModels.Add(s.Id);
_out.WriteLine(
$" cell 0x{low:X3} static model=0x{s.Id:X8} local=({o.X:F2},{o.Y:F2},{o.Z:F2}) " +
$"world=({w.X:F2},{w.Y:F2},{w.Z:F2}) quat=(w{q.W:F3} z{q.Z:F3}) dist2D(stairsSpot)={d:F1}m");
}
}
_out.WriteLine("");
_out.WriteLine("=== Static/shell model AABBs (model-local) ===");
staticModels.Add(0x01000827u); // the shell
foreach (var mid in staticModels.OrderBy(m => m))
DumpModelAabb(dats, mid);
// TARDIS check: shell world AABB vs interior-cell envelope.
_out.WriteLine("");
_out.WriteLine("=== Shell stair-signature scan near the phantom spot ===");
// Shell model is placed at building frame (180,84,116), identity quat —
// model-local (3,-3,0..3) corresponds to local-frame (183,81,116..119).
DumpStairSignatureNear(dats, 0x01000827u, new Vector3(180f, 84f, 116f),
boxMin: new Vector3(181.0f, 77.0f, 115.5f), boxMax: new Vector3(186.5f, 83.5f, 121.0f));
_out.WriteLine("");
_out.WriteLine("=== Shell WHOLE-MODEL scan (does the shell contain stairs ANYWHERE?) ===");
DumpStairSignatureNear(dats, 0x01000827u, new Vector3(180f, 84f, 116f),
boxMin: new Vector3(-10000f), boxMax: new Vector3(10000f));
// Localize: roofs are big high slopes; stairs are a narrow low strip.
_out.WriteLine("");
_out.WriteLine("=== Shell ramp/low-flat poly localization (world local-frame, placed at 180,84,116) ===");
var shell = dats.Get<DatReaderWriter.DBObjs.GfxObj>(0x01000827u)!;
foreach (var kv in shell.Polygons)
{
var poly = kv.Value;
if (poly.VertexIds.Count < 3) continue;
var pts = new List<Vector3>();
bool ok = true;
foreach (var vid in poly.VertexIds)
{
if (!shell.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) { ok = false; break; }
pts.Add(new Vector3(180f + v.Origin.X, 84f + v.Origin.Y, 116f + v.Origin.Z));
}
if (!ok) continue;
var n = NewellNormal(pts);
if (n == Vector3.Zero) continue;
var c = pts.Aggregate(Vector3.Zero, (a, b) => a + b) / pts.Count;
bool ramp = MathF.Abs(n.Z) > 0.15f && MathF.Abs(n.Z) <= 0.9f;
bool lowFlat = MathF.Abs(n.Z) > 0.9f && c.Z < 120.5f;
if (!ramp && !lowFlat) continue;
_out.WriteLine(
$" poly {kv.Key,3} {(ramp ? "RAMP" : "FLAT")} c=({c.X:F1},{c.Y:F1},{c.Z:F2}) nZ={n.Z:F2} verts={pts.Count} " +
$"xy-span=({pts.Max(p => p.X) - pts.Min(p => p.X):F1},{pts.Max(p => p.Y) - pts.Min(p => p.Y):F1})");
}
}
/// <summary>
/// Rider (#112 residual): 0x104's exterior portal polygon + plane, and the
/// gap point's distance to it. Retail admits outside cells only when the
/// sphere STRADDLES this plane (|dist| &lt; radius + 0.0002, pc:310090-310120).
/// </summary>
[Fact]
public void Dump_Cell104_ExteriorPortalPlane_Vs_GapPoint()
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
foreach (uint low in new uint[] { 0x0104, 0x0102, 0x0105, 0x0100 })
{
uint id = Landblock | low;
var dc = dats.Get<DatEnvCell>(id);
if (dc is null) continue;
var env = dats.Get<DatEnvironment>(EnvironmentFilePrefix | dc.EnvironmentId);
if (env is null || !env.Cells.TryGetValue(dc.CellStructure, out var cs) || cs is null) continue;
var world = ConformanceDats.WorldTransform(dc);
foreach (var p in dc.CellPortals)
{
if (p.OtherCellId != 0xFFFF) continue;
if (!cs.Polygons.TryGetValue(p.PolygonId, out var poly)) { _out.WriteLine($"cell 0x{low:X3}: portal poly {p.PolygonId} MISSING"); continue; }
var pts = new List<Vector3>();
foreach (var vid in poly.VertexIds)
if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v))
pts.Add(Vector3.Transform(new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z), world));
if (pts.Count < 3) continue;
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);
}
n = Vector3.Normalize(n);
float dPlane = -Vector3.Dot(n, pts[0]);
float dist = Vector3.Dot(n, GapPoint) + dPlane;
_out.WriteLine(
$"cell 0x{low:X3} EXTERIOR portal poly={p.PolygonId} flags={p.Flags} " +
$"verts=[{string.Join(" ", pts.Select(v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]");
_out.WriteLine(
$" plane n=({n.X:F3},{n.Y:F3},{n.Z:F3}) d={dPlane:F3} " +
$"dist(gapPoint)={dist:F3} | straddle@r=0.48: {(MathF.Abs(dist) < 0.48f + 0.0002f ? "YES retail ADMITS outside cells here" : "NO retail keeps curr_cell")}");
}
}
}
/// <summary>
/// Round 3: the issue112-gate1.log pick lines show the NPCs "behind the
/// stairs" live in AAB3 (world x 209252) — the block EAST of A9B3. The
/// phantom staircase is likely on an AAB3 building. Dump AAB3's buildings
/// + per-model stair scan + cells near the user's viewpoint.
/// </summary>
[Fact]
public void DumpAAB3_Buildings_And_StairScan()
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
const uint lb = 0xAAB30000u;
// User stood at world (183, -111) = AAB3-local (-9, 81). NPCs at
// AAB3-local (17.7, 84.4) and (27.7, 83.6).
var viewer = new Vector2(-9f, 81f);
var lbi = dats.Get<DatLandBlockInfo>(lb | 0xFFFEu);
Assert.NotNull(lbi);
_out.WriteLine($"=== AAB3 LandBlockInfo: NumCells={lbi!.NumCells}, Buildings={lbi.Buildings?.Count ?? 0} ===");
int bIdx = 0;
foreach (var b in lbi.Buildings ?? new())
{
var o = b.Frame.Origin;
var q = b.Frame.Orientation;
float d = Vector2.Distance(new Vector2(o.X, o.Y), viewer);
var seeds = (b.Portals ?? new()).Where(p => p.OtherCellId != 0xFFFF)
.Select(p => p.OtherCellId).Distinct().OrderBy(x => x).ToList();
_out.WriteLine(
$"building[{bIdx}] model=0x{b.ModelId:X8} pos=({o.X:F2},{o.Y:F2},{o.Z:F2}) " +
$"quat=(w{q.W:F3} z{q.Z:F3}) portals={b.Portals?.Count ?? 0} " +
$"seedCells=[{string.Join(",", seeds.Select(s => $"0x{s:X3}"))}] dist(viewer)={d:F1}m");
bIdx++;
}
_out.WriteLine("");
_out.WriteLine("=== Per-building-model whole-shell stair scan ===");
foreach (var mid in (lbi.Buildings ?? new()).Select(b => b.ModelId).Distinct().OrderBy(m => m))
ScanModelStairs(dats, mid);
_out.WriteLine("");
_out.WriteLine("=== AAB3 LandBlockInfo.Objects (outdoor stabs) — sorted by dist to viewer ===");
var stabs = (lbi.Objects ?? new())
.Select(s => (s, d: Vector2.Distance(new Vector2(s.Frame.Origin.X, s.Frame.Origin.Y), viewer)))
.OrderBy(t => t.d).ToList();
foreach (var (s, d) in stabs)
{
var o = s.Frame.Origin; var q = s.Frame.Orientation;
_out.WriteLine($" stab model=0x{s.Id:X8} pos=({o.X:F2},{o.Y:F2},{o.Z:F2}) quat=(w{q.W:F3} z{q.Z:F3}) dist={d:F1}m [world=({o.X + 192f:F1},{o.Y - 192f:F1},{o.Z:F1})]");
}
_out.WriteLine("");
_out.WriteLine("=== Stair scan per stab model within 45m of viewer ===");
foreach (var mid in stabs.Where(t => t.d < 45f).Select(t => t.s.Id).Distinct().OrderBy(m => m))
ScanModelStairs(dats, mid);
_out.WriteLine("");
_out.WriteLine("=== AAB3 interior cells (ALL — no distance filter) ===");
for (uint low = 0x0100; low < 0x0100 + lbi.NumCells; low++)
{
var dc = dats.Get<DatEnvCell>(lb | low);
if (dc is null) continue;
var env = dats.Get<DatEnvironment>(EnvironmentFilePrefix | dc.EnvironmentId);
if (env is null || !env.Cells.TryGetValue(dc.CellStructure, out var cs) || cs is null) continue;
var world = ConformanceDats.WorldTransform(dc);
var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue);
var flat = new List<float>(); int ramps = 0;
foreach (var poly in cs.Polygons.Values)
{
if (poly.VertexIds.Count < 3) continue;
var pts = new List<Vector3>();
bool ok = true;
foreach (var vid in poly.VertexIds)
{
if (!cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) { ok = false; break; }
var p = Vector3.Transform(new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z), world);
pts.Add(p); min = Vector3.Min(min, p); max = Vector3.Max(max, p);
}
if (!ok) continue;
var n = NewellNormal(pts);
if (n.LengthSquared() < 1e-10f) continue;
if (MathF.Abs(n.Z) > 0.9f) flat.Add(pts.Average(p => p.Z));
else if (MathF.Abs(n.Z) > 0.15f) ramps++;
}
var c2 = new Vector2((min.X + max.X) / 2, (min.Y + max.Y) / 2);
float dist = Vector2.Distance(c2, viewer);
var heights = flat.Select(h => MathF.Round(h, 1)).Distinct().OrderBy(h => h).ToList();
string sig = (heights.Count > 3 || ramps > 0) ? $" <== STAIR-ISH ramps={ramps} heights=[{string.Join(",", heights.Select(h => h.ToString("F1")))}]" : "";
_out.WriteLine(
$"cell 0x{low:X3} env=0x{dc.EnvironmentId:X4} flags={dc.Flags} statics={dc.StaticObjects?.Count ?? 0} " +
$"AABB=({min.X:F1},{min.Y:F1},{min.Z:F1})..({max.X:F1},{max.Y:F1},{max.Z:F1}) dist={dist:F1}m{sig}");
}
}
/// <summary>
/// Round 4: the player stood at world (216.4,-108.6) (AAB3-local (24.4,83.4))
/// during the stairs-clicks — 10 m west of AAB3 building[0] (the watchtower,
/// model 0x010014C3 at local (36,84,116), quat 90° about Z). Discriminator:
/// if the rotated shell's openings align with the tower cells' exterior
/// portal polygons, our placement matches the dat's intent (retail must show
/// the same face); if a solid wall covers a doorway, our placement is wrong.
/// Also localize the model's ramps to know which face the stairs are on.
/// </summary>
[Fact]
public void DumpAAB3_Watchtower_DoorAlignment_And_RampFaces()
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
const uint lb = 0xAAB30000u;
var lbi = dats.Get<DatLandBlockInfo>(lb | 0xFFFEu);
Assert.NotNull(lbi);
var b = lbi!.Buildings![0];
var shellWorld =
Matrix4x4.CreateFromQuaternion(b.Frame.Orientation) *
Matrix4x4.CreateTranslation(b.Frame.Origin);
_out.WriteLine($"tower model=0x{b.ModelId:X8} frame=({b.Frame.Origin.X},{b.Frame.Origin.Y},{b.Frame.Origin.Z}) quat=(w{b.Frame.Orientation.W:F3} z{b.Frame.Orientation.Z:F3})");
_out.WriteLine($"bld portals: [{string.Join("; ", (b.Portals ?? new()).Select(p => $"other=0x{p.OtherCellId:X3} otherPortal={p.OtherPortalId} flags={p.Flags} stabs={p.StabList?.Count ?? 0}"))}]");
// -- tower model ramp localization (model-local, z bins) --
var gfx = dats.Get<DatReaderWriter.DBObjs.GfxObj>(b.ModelId)!;
var bins = new Dictionary<string, (int N, Vector3 Sum, float MinZ, float MaxZ)>();
foreach (var poly in gfx.Polygons.Values)
{
var pts = ResolveLocalPoly(gfx, poly);
if (pts is null) continue;
var n = NewellNormal(pts);
if (n == Vector3.Zero || MathF.Abs(n.Z) <= 0.15f || MathF.Abs(n.Z) > 0.9f) continue;
var c = pts.Aggregate(Vector3.Zero, (a, p) => a + p) / pts.Count;
string bin = c.Z < 4f ? "z<4 (ground stairs?)" : c.Z < 12f ? "z 4-12 (mid stairs?)" : "z>12 (roof)";
var e = bins.TryGetValue(bin, out var v) ? v : (N: 0, Sum: Vector3.Zero, MinZ: float.MaxValue, MaxZ: float.MinValue);
bins[bin] = (e.N + 1, e.Sum + c, MathF.Min(e.MinZ, c.Z), MathF.Max(e.MaxZ, c.Z));
}
_out.WriteLine("=== tower ramp polys by z-bin (model-local centroid mean) ===");
foreach (var (bin, v) in bins.OrderBy(k => k.Key))
{
var mean = v.Sum / v.N;
var w = Vector3.Transform(mean, shellWorld);
_out.WriteLine($" {bin}: n={v.N} meanLocal=({mean.X:F1},{mean.Y:F1},{mean.Z:F1}) z {v.MinZ:F1}..{v.MaxZ:F1} -> meanWorld=({w.X:F1},{w.Y:F1},{w.Z:F1}) [world abs=({w.X + 192f:F1},{w.Y - 192f:F1})]");
}
// -- low flat treads (model z < 9, |nZ|>0.9, small) = stair treads --
_out.WriteLine("=== tower small flat plates below z=9 (stair treads / landings) ===");
foreach (var kv in gfx.Polygons)
{
var pts = ResolveLocalPoly(gfx, kv.Value);
if (pts is null) continue;
var n = NewellNormal(pts);
if (n == Vector3.Zero || MathF.Abs(n.Z) <= 0.9f) continue;
var c = pts.Aggregate(Vector3.Zero, (a, p) => a + p) / pts.Count;
if (c.Z >= 9f || c.Z <= 0.05f) continue;
float sx = pts.Max(p => p.X) - pts.Min(p => p.X);
float sy = pts.Max(p => p.Y) - pts.Min(p => p.Y);
var w = Vector3.Transform(c, shellWorld);
_out.WriteLine($" poly {kv.Key,3} c-local=({c.X:F1},{c.Y:F1},{c.Z:F2}) span=({sx:F1},{sy:F1}) -> world=({w.X:F1},{w.Y:F1},{w.Z:F2})");
}
// -- tower cells + door alignment --
_out.WriteLine("=== tower cells (positions are AAB3-local) ===");
var shellVertPolys = new List<List<Vector3>>(); // world-space vertical shell polys
foreach (var poly in gfx.Polygons.Values)
{
var pts = ResolveLocalPoly(gfx, poly);
if (pts is null) continue;
var n = NewellNormal(pts);
if (n == Vector3.Zero || MathF.Abs(n.Z) > 0.15f) continue;
shellVertPolys.Add(pts.Select(p => Vector3.Transform(p, shellWorld)).ToList());
}
for (uint low = 0x0100; low < 0x0100 + lbi.NumCells; low++)
{
var dc = dats.Get<DatEnvCell>(lb | low);
if (dc is null) continue;
var env = dats.Get<DatEnvironment>(EnvironmentFilePrefix | dc.EnvironmentId);
if (env is null || !env.Cells.TryGetValue(dc.CellStructure, out var cs) || cs is null) continue;
var world = ConformanceDats.WorldTransform(dc);
var o = dc.Position.Origin; var q = dc.Position.Orientation;
_out.WriteLine($"cell 0x{low:X3} env=0x{dc.EnvironmentId:X4} pos=({o.X:F2},{o.Y:F2},{o.Z:F2}) quat=(w{q.W:F3} z{q.Z:F3}) flags={dc.Flags} portals=[{string.Join(",", dc.CellPortals.Select(p => $"0x{p.OtherCellId:X3}"))}]");
foreach (var p in dc.CellPortals)
{
if (p.OtherCellId != 0xFFFF) continue;
if (!cs.Polygons.TryGetValue(p.PolygonId, out var poly)) continue;
var rect = new List<Vector3>();
foreach (var vid in poly.VertexIds)
if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v))
rect.Add(Vector3.Transform(new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z), world));
if (rect.Count < 3) continue;
var c = rect.Aggregate(Vector3.Zero, (a, v2) => a + v2) / rect.Count;
// Sample interior points of the portal rect; count shell polys
// covering them (coplanar within 0.3 m + point inside 2D proj).
int covered = 0, samples = 0;
var pn = NewellNormal(rect);
foreach (var t in new[] { 0.5f, 0.25f, 0.75f })
{
var sample = Vector3.Lerp(rect[0], rect[2 % rect.Count], t);
samples++;
foreach (var wallPoly in shellVertPolys)
{
var wn = NewellNormal(wallPoly);
if (MathF.Abs(Vector3.Dot(wn, pn)) < 0.9f) continue; // not parallel
float dist = Vector3.Dot(wn, sample - wallPoly[0]);
if (MathF.Abs(dist) > 0.3f) continue; // not coplanar
if (PointInPolygonOnPlane(sample, wallPoly, wn)) { covered++; break; }
}
}
_out.WriteLine(
$" EXTERIOR portal rect c=({c.X:F2},{c.Y:F2},{c.Z:F2}) " +
$"verts=[{string.Join(" ", rect.Select(v2 => $"({v2.X:F1},{v2.Y:F1},{v2.Z:F1})"))}] " +
$"shell-coverage {covered}/{samples} {(covered > 0 ? "<== BLOCKED BY SHELL WALL (misalignment!)" : "(open aligned)")}");
}
}
}
/// <summary>
/// Round 5: top-down ASCII map of the watchtower zone from the dat, two
/// z-bands, with the ACE Sentry patrol segment + Aun + user overlaid.
/// '#'=vertical wall poly, '/'=ramp, '_'=flat, 'S'=Sentry path, 'A'=Aun,
/// 'U'=user at clicks, '*'=outdoor stab. World-x left→right, world-y is
/// printed top→bottom DESCENDING (north up).
/// </summary>
[Fact]
public void DumpAAB3_Watchtower_TopDownMap()
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
const uint lb = 0xAAB30000u;
var lbi = dats.Get<DatLandBlockInfo>(lb | 0xFFFEu)!;
var b = lbi.Buildings![0];
var shellWorld =
Matrix4x4.CreateFromQuaternion(b.Frame.Orientation) *
Matrix4x4.CreateTranslation(b.Frame.Origin);
var gfx = dats.Get<DatReaderWriter.DBObjs.GfxObj>(b.ModelId)!;
// model AABB world-placed
var mmin = new Vector3(float.MaxValue); var mmax = new Vector3(float.MinValue);
foreach (var kvp in gfx.VertexArray.Vertices)
{
var w = Vector3.Transform(new Vector3(kvp.Value.Origin.X, kvp.Value.Origin.Y, kvp.Value.Origin.Z), shellWorld);
mmin = Vector3.Min(mmin, w); mmax = Vector3.Max(mmax, w);
}
_out.WriteLine($"tower WORLD AABB (AAB3-local frame): ({mmin.X:F1},{mmin.Y:F1},{mmin.Z:F1})..({mmax.X:F1},{mmax.Y:F1},{mmax.Z:F1}) [world abs x {mmin.X + 192f:F1}..{mmax.X + 192f:F1}, y {mmin.Y - 192f:F1}..{mmax.Y - 192f:F1}]");
foreach (var (zLo, zHi, label) in new[] { (115.5f, 119.5f, "GROUND band z 115.5-119.5"), (119.5f, 125.5f, "UPPER band z 119.5-125.5") })
{
// grid over AAB3-local x 8..52, y 66..102, 0.5 m cells
const float x0 = 8f, x1 = 52f, y0 = 66f, y1 = 102f, cs = 0.5f;
int W = (int)((x1 - x0) / cs), H = (int)((y1 - y0) / cs);
var grid = new char[H, W];
for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) grid[r, c] = ' ';
void Plot(Vector3 p, char ch, bool force = false)
{
int c = (int)((p.X - x0) / cs), r = (int)((p.Y - y0) / cs);
if (c < 0 || c >= W || r < 0 || r >= H) return;
if (force || grid[r, c] == ' ' || (ch == '/' && grid[r, c] == '#')) grid[r, c] = ch;
}
foreach (var poly in gfx.Polygons.Values)
{
var ptsLocal = ResolveLocalPoly(gfx, poly);
if (ptsLocal is null) continue;
var pts = ptsLocal.Select(p => Vector3.Transform(p, shellWorld)).ToList();
var n = NewellNormal(pts);
if (n == Vector3.Zero) continue;
char ch = MathF.Abs(n.Z) > 0.9f ? '_' : MathF.Abs(n.Z) > 0.15f ? '/' : '#';
// rasterize edges by sampling
for (int i = 0; i < pts.Count; i++)
{
var a = pts[i]; var bb = pts[(i + 1) % pts.Count];
for (float t = 0; t <= 1f; t += 0.05f)
{
var p = Vector3.Lerp(a, bb, t);
if (p.Z < zLo || p.Z > zHi) continue;
Plot(p, ch);
}
}
}
// overlays (AAB3-local = world x-192, y+192)
foreach (var s in lbi.Objects ?? new()) Plot(s.Frame.Origin, '*', force: true);
for (float t = 0; t <= 1f; t += 0.1f) // Sentry path world (219.74,-108.40,117.2)->(217.10,-108.41,117.9)
Plot(new Vector3(219.74f - 192f + t * (217.10f - 219.74f), -108.40f + 192f, 117.5f), 'S', force: true);
Plot(new Vector3(209.683f - 192f, -107.560f + 192f, 116f), 'A', force: true);
Plot(new Vector3(216.436f - 192f, -108.644f + 192f, 116f), 'U', force: true);
Plot(new Vector3(b.Frame.Origin.X, b.Frame.Origin.Y, (zLo + zHi) / 2), 'O', force: true);
_out.WriteLine("");
_out.WriteLine($"=== {label} === (x {x0}..{x1} local = world {x0 + 192}..{x1 + 192}; y rows DESC from {y1 - 192f} to {y0 - 192f} world)");
for (int r = H - 1; r >= 0; r--)
{
var row = new char[W];
for (int c = 0; c < W; c++) row[c] = grid[r, c];
float wy = (y0 + r * cs) - 192f;
_out.WriteLine($"y{wy,7:F1} |{new string(row)}|");
}
}
}
private static List<Vector3>? ResolveLocalPoly(DatReaderWriter.DBObjs.GfxObj gfx, DatReaderWriter.Types.Polygon poly)
{
if (poly.VertexIds.Count < 3) return null;
var pts = new List<Vector3>(poly.VertexIds.Count);
foreach (var vid in poly.VertexIds)
{
if (!gfx.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) return null;
pts.Add(new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z));
}
return pts;
}
private static bool PointInPolygonOnPlane(Vector3 p, List<Vector3> poly, Vector3 n)
{
// Project onto the dominant plane axes and run 2D even-odd test.
Func<Vector3, Vector2> proj;
var an = Vector3.Abs(n);
if (an.X >= an.Y && an.X >= an.Z) proj = v => new Vector2(v.Y, v.Z);
else if (an.Y >= an.Z) proj = v => new Vector2(v.X, v.Z);
else proj = v => new Vector2(v.X, v.Y);
var pt = proj(p);
bool inside = false;
for (int i = 0, j = poly.Count - 1; i < poly.Count; j = i++)
{
var a = proj(poly[i]); var bb = proj(poly[j]);
if (a.Y > pt.Y != bb.Y > pt.Y &&
pt.X < (bb.X - a.X) * (pt.Y - a.Y) / (bb.Y - a.Y) + a.X)
inside = !inside;
}
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)
{
ScanGfxStairs(dats, modelId, $"model 0x{modelId:X8}");
}
else if ((modelId & 0xFF000000u) == 0x02000000u)
{
var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(modelId);
if (setup is null) { _out.WriteLine($"model 0x{modelId:X8}: Setup MISSING"); return; }
foreach (var partId in setup.Parts.Distinct())
ScanGfxStairs(dats, partId, $"model 0x{modelId:X8} part 0x{partId:X8}");
}
}
private void ScanGfxStairs(DatCollection dats, uint gfxId, string label)
{
var gfx = dats.Get<DatReaderWriter.DBObjs.GfxObj>(gfxId);
if (gfx is null) { _out.WriteLine($"{label}: GfxObj MISSING"); return; }
var flat = new List<float>(); int ramps = 0; int polys = 0;
var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue);
foreach (var poly in gfx.Polygons.Values)
{
if (poly.VertexIds.Count < 3) 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; }
var p = new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z);
pts.Add(p); min = Vector3.Min(min, p); max = Vector3.Max(max, p);
}
if (!ok) continue;
polys++;
var n = NewellNormal(pts);
if (n.LengthSquared() < 1e-10f) continue;
if (MathF.Abs(n.Z) > 0.9f) flat.Add(pts.Average(p => p.Z));
else if (MathF.Abs(n.Z) > 0.15f) ramps++;
}
var heights = flat.Select(h => MathF.Round(h, 1)).Distinct().OrderBy(h => h).ToList();
string verdict = (heights.Count > 4 || ramps > 2) ? " <== STAIR-ISH" : "";
_out.WriteLine(
$"{label}: polys={polys} ramps={ramps} AABB z {min.Z:F1}..{max.Z:F1} " +
$"flatHeights=[{string.Join(",", heights.Select(h => h.ToString("F1")))}]{verdict}");
}
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);
}
private void DumpModelAabb(DatCollection dats, uint modelId)
{
if ((modelId & 0xFF000000u) == 0x01000000u)
{
var gfx = dats.Get<DatReaderWriter.DBObjs.GfxObj>(modelId);
if (gfx is null) { _out.WriteLine($" model 0x{modelId:X8}: GfxObj MISSING"); return; }
var (min, max, nVerts) = GfxAabb(gfx);
_out.WriteLine($" model 0x{modelId:X8} (GfxObj): verts={nVerts} AABB=({min.X:F1},{min.Y:F1},{min.Z:F1})..({max.X:F1},{max.Y:F1},{max.Z:F1}) span=({max.X - min.X:F1},{max.Y - min.Y:F1},{max.Z - min.Z:F1})");
}
else if ((modelId & 0xFF000000u) == 0x02000000u)
{
var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(modelId);
if (setup is null) { _out.WriteLine($" model 0x{modelId:X8}: Setup MISSING"); return; }
var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue);
int total = 0;
foreach (var partId in setup.Parts)
{
var gfx = dats.Get<DatReaderWriter.DBObjs.GfxObj>(partId);
if (gfx is null) continue;
var (pmin, pmax, n) = GfxAabb(gfx);
min = Vector3.Min(min, pmin); max = Vector3.Max(max, pmax); total += n;
}
_out.WriteLine($" model 0x{modelId:X8} (Setup, {setup.Parts.Count} parts, part frames NOT applied): verts={total} AABB≈({min.X:F1},{min.Y:F1},{min.Z:F1})..({max.X:F1},{max.Y:F1},{max.Z:F1})");
}
else _out.WriteLine($" model 0x{modelId:X8}: unknown namespace");
}
private static (Vector3 Min, Vector3 Max, int Count) GfxAabb(DatReaderWriter.DBObjs.GfxObj gfx)
{
var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue);
int n = 0;
foreach (var kvp in gfx.VertexArray.Vertices)
{
var p = new Vector3(kvp.Value.Origin.X, kvp.Value.Origin.Y, kvp.Value.Origin.Z);
min = Vector3.Min(min, p); max = Vector3.Max(max, p); n++;
}
return (min, max, n);
}
private void DumpStairSignatureNear(DatCollection dats, uint gfxId, Vector3 placeAt, Vector3 boxMin, Vector3 boxMax)
{
var gfx = dats.Get<DatReaderWriter.DBObjs.GfxObj>(gfxId);
if (gfx is null) { _out.WriteLine($"GfxObj 0x{gfxId:X8} MISSING"); return; }
var flatHeights = new List<float>();
int polysInBox = 0, ramps = 0;
foreach (var poly in gfx.Polygons.Values)
{
if (poly.VertexIds.Count < 3) 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(placeAt + new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z));
}
if (!ok) continue;
var c = pts.Aggregate(Vector3.Zero, (a, b) => a + b) / pts.Count;
if (!AabbContains(boxMin, boxMax, c, 0f)) continue;
polysInBox++;
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);
}
if (n.LengthSquared() < 1e-10f) continue;
n = Vector3.Normalize(n);
if (MathF.Abs(n.Z) > 0.9f) flatHeights.Add(c.Z);
else if (MathF.Abs(n.Z) > 0.15f) ramps++;
}
var distinct = flatHeights.Select(h => MathF.Round(h, 2)).Distinct().OrderBy(h => h).ToList();
_out.WriteLine(
$"shell 0x{gfxId:X8} polys-in-box={polysInBox} ramps={ramps} " +
$"flat heights=[{string.Join(",", distinct.Select(h => h.ToString("F2")))}]");
_out.WriteLine(distinct.Count > 3
? ">>> SHELL ITSELF HAS STAIR-LIKE GEOMETRY AT THE SPOT (multiple flat heights)"
: ">>> shell shows no stair signature at the spot");
}
}