using System; using System.Collections.Generic; using System.IO; using System.Numerics; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Options; using Xunit; using Xunit.Abstractions; using Env = System.Environment; namespace AcDream.Core.Tests.Rendering; /// /// #176 (purple flashing on dungeon floors at cell seams) + #177 (stairs pop /// in/out across levels) — dat-truth inspection for the Facility Hub anchor /// cells. The load-bearing topology fact from the #137 arc: corridor FLOORS /// are portal polygons (PortalSide floor-portals to under-rooms, e.g. /// 0x8A02016E visual polys 1/3/5 → 0x011E). These dumps answer: /// /// (a) are the floor-portal VISUAL polys textured (drawn by CellMesh.Build) /// or NoPos-stippled (skipped)? Same question for the RECIPROCAL portal /// polys in the other cell — two textured coincident planes would /// z-fight (#176's angle-dependent flash candidate); /// (b) which cell owns the actual stair-step geometry at the /// 0x8A020182 → 0x8A020183 level transit (#177's pop-in subject); /// (c) do any drawn polys reference surfaces that fail to resolve /// (the magenta-placeholder class)? /// /// ⚠️ id-space trap (cost the #137 saga a wrong mechanism): /// CellPortal.PolygonId indexes CellStruct.Polygons (VISUAL), never /// PhysicsPolygons — same ids in both tables are unrelated polygons. /// public class Issue176177DungeonSeamInspectionTests { private readonly ITestOutputHelper _out; public Issue176177DungeonSeamInspectionTests(ITestOutputHelper output) => _out = output; private static string? ResolveDatDir() { var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); return Directory.Exists(datDir) ? datDir : null; } private static Matrix4x4 WorldTransform(EnvCell cell) { var rot = new Quaternion( cell.Position.Orientation.X, cell.Position.Orientation.Y, cell.Position.Orientation.Z, cell.Position.Orientation.W); return Matrix4x4.CreateFromQuaternion(rot) * Matrix4x4.CreateTranslation( cell.Position.Origin.X, cell.Position.Origin.Y, cell.Position.Origin.Z); } private static (EnvCell cell, DatReaderWriter.Types.CellStruct cs)? LoadCell(DatCollection dats, uint cellId) { var envCell = dats.Get(cellId); if (envCell is null) return null; var environment = dats.Get(0x0D000000u | envCell.EnvironmentId); if (environment is null) return null; if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) return null; return (envCell, cs!); } private static List WorldVerts( DatReaderWriter.Types.CellStruct cs, DatReaderWriter.Types.Polygon poly, Matrix4x4 world) { var result = new List(poly.VertexIds.Count); foreach (var vid in poly.VertexIds) if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) result.Add(Vector3.Transform(v.Origin, world)); return result; } /// /// Mirror of CellMesh.Build's inclusion rules (verts ≥ 3, no NoPos /// stippling, PosSurface index in range) — the DRAWN verdict per poly. /// private static bool WouldDraw(DatReaderWriter.Types.Polygon poly, EnvCell cell) => poly.VertexIds.Count >= 3 && !poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos) && poly.PosSurface >= 0 && poly.PosSurface < cell.Surfaces.Count; /// /// (a)+(c): every CellPortal of the cell — the visual portal poly's /// stippling/sides/surface, world plane, span, DRAWN verdict, and whether /// the referenced Surface resolves in the dat. /// [Theory] [InlineData(0x8A02016Eu)] // corridor with floor-portals 1/3/5 → 0x011E (#176 anchor) [InlineData(0x8A02011Eu)] // the under-hall at z=−12 those portals lead to [InlineData(0x8A02017Au)] // adjacent corridor cell (the #137 seam partner) [InlineData(0x8A020182u)] // stair transit upper cell, z −6 (#177 anchor) [InlineData(0x8A020183u)] // stair transit lower cell, z −9 (#177 anchor) public void PortalPolys_SurfaceAndDrawVerdict_Dump(uint cellId) { var datDir = ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var loaded = LoadCell(dats, cellId); Assert.NotNull(loaded); var (cell, cs) = loaded!.Value; var world = WorldTransform(cell); _out.WriteLine($"=== 0x{cellId:X8} Env=0x{cell.EnvironmentId:X4} struct={cell.CellStructure} " + $"pos=({cell.Position.Origin.X:F2},{cell.Position.Origin.Y:F2},{cell.Position.Origin.Z:F2}) ==="); _out.WriteLine($" Surfaces ({cell.Surfaces.Count}): " + string.Join(" ", cell.Surfaces.ConvertAll(s => $"0x{0x08000000u | (uint)s:X8}"))); _out.WriteLine($" visualPolys={cs.Polygons.Count} physicsPolys={cs.PhysicsPolygons.Count} portals={cell.CellPortals.Count}"); // #177 pivot check: dungeon staircases are often EnvCell STATICS (the // #119 tower class) — if one lives here, the vanish subject is the // static's cull, not the shell flood. _out.WriteLine($" StaticObjects={cell.StaticObjects.Count}"); foreach (var so in cell.StaticObjects) _out.WriteLine($" static id=0x{so.Id:X8} at ({so.Frame.Origin.X:F2},{so.Frame.Origin.Y:F2},{so.Frame.Origin.Z:F2})"); foreach (var p in cell.CellPortals) { if (!cs.Polygons.TryGetValue((ushort)p.PolygonId, out var poly)) { _out.WriteLine($" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] NOT IN VISUAL TABLE"); continue; } var w = WorldVerts(cs, poly, world); var n = w.Count >= 3 ? Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0])) : Vector3.Zero; var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue); foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); } bool drawn = WouldDraw(poly, cell); string surfInfo = "posSurf=OUT-OF-RANGE"; if (poly.PosSurface >= 0 && poly.PosSurface < cell.Surfaces.Count) { uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface]; var surface = dats.Get(surfaceId); surfInfo = surface is null ? $"posSurf[{poly.PosSurface}]=0x{surfaceId:X8} SURFACE-MISS" : $"posSurf[{poly.PosSurface}]=0x{surfaceId:X8} type={surface.Type} origTex=0x{(uint)surface.OrigTextureId:X8}"; } _out.WriteLine( $" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] " + $"stip={poly.Stippling} sides={poly.SidesType} verts={poly.VertexIds.Count} " + $"n=({n.X:F2},{n.Y:F2},{n.Z:F2}) " + $"x=[{min.X:F2},{max.X:F2}] y=[{min.Y:F2},{max.Y:F2}] z=[{min.Z:F2},{max.Z:F2}] " + $"{surfInfo} DRAWN={drawn}"); } // (c) sweep: any DRAWN poly in the whole cell whose surface misses. int drawnCount = 0, missCount = 0; foreach (var (id, poly) in cs.Polygons) { if (!WouldDraw(poly, cell)) continue; drawnCount++; uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface]; if (dats.Get(surfaceId) is null) { missCount++; _out.WriteLine($" >>> DRAWN poly {id} has MISSING surface 0x{surfaceId:X8}"); } } _out.WriteLine($" drawn-poly sweep: {drawnCount} drawn, {missCount} with missing surfaces"); } /// /// (a) reciprocal check: for each anchor pair, world-transform BOTH /// sides' portal polys and test plane coincidence + both-drawn — the /// #176 z-fight candidate is a coincident pair with DRAWN=true twice. /// [Theory] [InlineData(0x8A02016Eu, 0x8A02011Eu)] [InlineData(0x8A02016Eu, 0x8A02017Au)] [InlineData(0x8A020182u, 0x8A020183u)] public void ReciprocalPortalPolys_CoincidenceAndDrawVerdict(uint cellA, uint cellB) { var datDir = ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var la = LoadCell(dats, cellA); var lb = LoadCell(dats, cellB); Assert.NotNull(la); Assert.NotNull(lb); var (ca, csa) = la!.Value; var (cb, csb) = lb!.Value; var wa = WorldTransform(ca); var wb = WorldTransform(cb); ushort lowA = (ushort)(cellA & 0xFFFFu); ushort lowB = (ushort)(cellB & 0xFFFFu); _out.WriteLine($"=== reciprocal pair 0x{cellA:X8} <-> 0x{cellB:X8} ==="); foreach (var pa in ca.CellPortals) { if (pa.OtherCellId != lowB) continue; if (!csa.Polygons.TryGetValue((ushort)pa.PolygonId, out var polyA)) continue; var va = WorldVerts(csa, polyA, wa); if (va.Count < 3) continue; var na = Vector3.Normalize(Vector3.Cross(va[1] - va[0], va[2] - va[0])); float da = Vector3.Dot(na, va[0]); bool drawnA = WouldDraw(polyA, ca); _out.WriteLine($" A poly={pa.PolygonId} [{pa.Flags}] n=({na.X:F2},{na.Y:F2},{na.Z:F2}) planeD={da:F2} " + $"stip={polyA.Stippling} sides={polyA.SidesType} DRAWN={drawnA}"); foreach (var pb in cb.CellPortals) { if (pb.OtherCellId != lowA) continue; if (!csb.Polygons.TryGetValue((ushort)pb.PolygonId, out var polyB)) continue; var vb = WorldVerts(csb, polyB, wb); if (vb.Count < 3) continue; var nb = Vector3.Normalize(Vector3.Cross(vb[1] - vb[0], vb[2] - vb[0])); float db = Vector3.Dot(nb, vb[0]); bool drawnB = WouldDraw(polyB, cb); float align = Vector3.Dot(na, nb); // Coincident planes: |align|≈1 and same plane offset (sign per normal direction). bool coincident = MathF.Abs(align) > 0.99f && MathF.Abs(MathF.Abs(da) - MathF.Abs(db)) < 0.05f; _out.WriteLine($" B poly={pb.PolygonId} [{pb.Flags}] n=({nb.X:F2},{nb.Y:F2},{nb.Z:F2}) planeD={db:F2} " + $"stip={polyB.Stippling} sides={polyB.SidesType} DRAWN={drawnB} " + $"align={align:F3} coincident={coincident}" + (coincident && drawnA && drawnB ? " >>> Z-FIGHT CANDIDATE (both drawn, same plane)" : "")); } } } /// /// #176 candidate (A2C): the opaque pass derives GL_SAMPLE_ALPHA_TO_COVERAGE /// from the sampled texture alpha (mesh_modern.frag uRenderPass==0 keeps /// alpha as-sampled). If the corridor floor texture decodes with alpha /// below 1.0, MSAA coverage punches see-through holes in the floor — /// fog-purple clear color — worst at grazing angles (mip-level dependent /// → camera-angle dependent, far floor = at seams). Decode the floor /// surface chain and histogram the alpha channel. /// [Theory] [InlineData(0x08000377u)] // corridor floor (portal strips + plain floors) [InlineData(0x08000376u)] [InlineData(0x08000375u)] [InlineData(0x08000378u)] [InlineData(0x08000379u)] // under-level walls [InlineData(0x080000DFu)] // stair-transit cells' 5th surface public void FloorSurface_DecodedAlphaHistogram(uint surfaceId) { var datDir = ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var surface = dats.Get(surfaceId); Assert.NotNull(surface); _out.WriteLine($"Surface 0x{surfaceId:X8}: type={surface!.Type} origTex=0x{(uint)surface.OrigTextureId:X8} " + $"transl={surface.Translucency:F2}"); if (surface.OrigTextureId == 0) { _out.WriteLine(" (no texture — solid color surface)"); return; } var surfTex = dats.Get((uint)surface.OrigTextureId); Assert.NotNull(surfTex); _out.WriteLine($" SurfaceTexture 0x{(uint)surface.OrigTextureId:X8}: {surfTex!.Textures.Count} textures " + $"[{string.Join(" ", surfTex.Textures.ConvertAll(t => $"0x{t:X8}"))}]"); foreach (var texId in surfTex.Textures) { var rs = dats.Get((uint)texId); if (rs is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: MISS"); continue; } // Decode with the production Core helpers (same paths the WB atlas uses). var data = new byte[rs.Width * rs.Height * 4]; bool decodedOk = true; switch (rs.Format) { case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16: { var pal = dats.Get(rs.DefaultPaletteId); if (pal is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: INDEX16 with no palette 0x{rs.DefaultPaletteId:X8}"); decodedOk = false; break; } AcDream.Core.Rendering.Wb.TextureHelpers.FillIndex16(rs.SourceData, pal, data, rs.Width, rs.Height); break; } case DatReaderWriter.Enums.PixelFormat.PFID_P8: { var pal = dats.Get(rs.DefaultPaletteId); if (pal is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: P8 with no palette"); decodedOk = false; break; } AcDream.Core.Rendering.Wb.TextureHelpers.FillP8(rs.SourceData, pal, data, rs.Width, rs.Height); break; } case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5: AcDream.Core.Rendering.Wb.TextureHelpers.FillR5G6B5(rs.SourceData, data, rs.Width, rs.Height); break; case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4: AcDream.Core.Rendering.Wb.TextureHelpers.FillA4R4G4B4(rs.SourceData, data, rs.Width, rs.Height); break; case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8: AcDream.Core.Rendering.Wb.TextureHelpers.FillA8R8G8B8(rs.SourceData, data, rs.Width, rs.Height); break; case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8: AcDream.Core.Rendering.Wb.TextureHelpers.FillR8G8B8(rs.SourceData, data, rs.Width, rs.Height); break; case DatReaderWriter.Enums.PixelFormat.PFID_DXT1: { // DXT1/BC1: 8-byte blocks — c0 (u16 LE), c1 (u16 LE), 16×2-bit // indices. c0 <= c1 selects 3-COLOR mode where index 3 decodes // to TRANSPARENT BLACK (alpha=0). Our atlas uploads DXT1 as the // RGBA variant (TextureFormatExtensions.ToCompressedGL), so any // such texel reaches the shader with alpha=0 — and the opaque // pass discards alpha<0.05 fragments. Count them. int blocks = rs.SourceData.Length / 8; int threeColorBlocks = 0; long transparentTexels = 0; for (int b = 0; b < blocks; b++) { int o = b * 8; ushort c0 = (ushort)(rs.SourceData[o] | (rs.SourceData[o + 1] << 8)); ushort c1 = (ushort)(rs.SourceData[o + 2] | (rs.SourceData[o + 3] << 8)); if (c0 > c1) continue; // 4-color opaque mode threeColorBlocks++; for (int bi = 0; bi < 4; bi++) { byte row = rs.SourceData[o + 4 + bi]; for (int t = 0; t < 4; t++) if (((row >> (t * 2)) & 0x3) == 3) transparentTexels++; } } _out.WriteLine($" RenderSurface 0x{texId:X8} DXT1 {rs.Width}x{rs.Height}: blocks={blocks} " + $"threeColorBlocks={threeColorBlocks} alpha0Texels={transparentTexels}" + (transparentTexels > 0 ? " >>> ALPHA=0 TEXELS PRESENT (opaque-pass discard punches holes)" : " (no transparent-mode texels)")); decodedOk = false; // histogram printed above; skip the RGBA path break; } default: _out.WriteLine($" RenderSurface 0x{texId:X8}: fmt={rs.Format} (not decoded by this test)"); decodedOk = false; break; } if (!decodedOk) continue; // Alpha histogram over the decoded RGBA bytes (stride 4, alpha at +3). int n = data.Length / 4; int a255 = 0, aHigh = 0, aMid = 0, aLow = 0, a0 = 0; byte minA = 255; for (int i = 0; i < n; i++) { byte a = data[i * 4 + 3]; if (a < minA) minA = a; if (a == 255) a255++; else if (a >= 243) aHigh++; // ≥0.95 — safe for A2C else if (a >= 13) aMid++; // 0.05..0.95 — partial coverage else if (a > 0) aLow++; else a0++; } _out.WriteLine($" RenderSurface 0x{texId:X8} fmt={rs.Format} {rs.Width}x{rs.Height}: " + $"alpha histogram n={n} a=255:{a255} 243-254:{aHigh} 13-242:{aMid} 1-12:{aLow} 0:{a0} minA={minA}" + (aMid + aLow + a0 > 0 ? " >>> SUB-OPAQUE ALPHA PRESENT (A2C hole candidate)" : " (fully opaque)")); } } /// /// #176 candidate: the under-hall 0x011E floods in at down-pitches and /// its surface list carries 0x08000034 (Base1Solid|Translucent — a /// COLORED translucent solid). If its drawn polys sit at z=−6 (coplanar /// with the corridor floor), the transparent pass blends that color over /// the floor whenever the under-hall is admitted — angle-dependent /// purple at seams. Dump every DRAWN poly (plane, z-span, surface, and /// the surface's ColorValue) of the under-hall + its under-level /// neighbors. /// [Theory] [InlineData(0x8A02011Eu)] [InlineData(0x8A020119u)] [InlineData(0x8A02011Du)] [InlineData(0x8A020122u)] [InlineData(0x8A02011Fu)] public void UnderHall_DrawnPolys_SurfaceColors(uint cellId) { var datDir = ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var loaded = LoadCell(dats, cellId); if (loaded is null) { _out.WriteLine($"0x{cellId:X8} NOT FOUND"); return; } var (cell, cs) = loaded.Value; var world = WorldTransform(cell); _out.WriteLine($"=== 0x{cellId:X8} drawn polys ==="); foreach (var (id, poly) in cs.Polygons) { if (!WouldDraw(poly, cell)) continue; var w = WorldVerts(cs, poly, world); if (w.Count < 3) continue; var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0])); float minZ = float.MaxValue, maxZ = float.MinValue; foreach (var v in w) { minZ = MathF.Min(minZ, v.Z); maxZ = MathF.Max(maxZ, v.Z); } uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface]; var surface = dats.Get(surfaceId); string surfInfo = surface is null ? $"0x{surfaceId:X8} MISS" : $"0x{surfaceId:X8} type={surface.Type} color=0x{surface.ColorValue:X8} origTex=0x{(uint)surface.OrigTextureId:X8} lum={surface.Luminosity:F2} transl={surface.Translucency:F2}"; _out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{minZ:F2},{maxZ:F2}] " + $"verts={poly.VertexIds.Count} surf={surfInfo}"); } } /// /// The transit-lag question (#176/#177 root-cause fork): production /// [cell-transit] lines fire 0.1–0.6 m PAST the portal plane. Is that /// (a) dat-real — the cells' CellBSP volumes OVERLAP past the plane, so /// retail's point_in_cell (same dat, same walk) keeps the old cell too — /// or (b) our membership's bug? Probe raw CellBSP containment for both /// cells of each seam across the portal plane. /// [Theory] [InlineData(0x8A02016Eu, 0x8A02017Au, 85.00f, -40f, -5.0f)] [InlineData(0x8A020182u, 0x8A020183u, 98.333f, -40f, -7.5f)] public void SeamCells_CellBspContainment_AcrossPortalPlane( uint cellAId, uint cellBId, float planeX, float y, float z) { var datDir = ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var la = LoadCell(dats, cellAId); var lb = LoadCell(dats, cellBId); Assert.NotNull(la); Assert.NotNull(lb); var (ca, csa) = la!.Value; var (cb, csb) = lb!.Value; Matrix4x4.Invert(WorldTransform(ca), out var invA); Matrix4x4.Invert(WorldTransform(cb), out var invB); _out.WriteLine($"=== CellBSP containment across plane x={planeX:F2} " + $"(A=0x{cellAId:X8}, B=0x{cellBId:X8}) ==="); for (float dx = -0.6f; dx <= 0.65f; dx += 0.05f) { var world = new Vector3(planeX + dx, y, z); bool inA = csa.CellBSP?.Root is not null && Physics.PointInCellBspViaBspQuery(csa.CellBSP.Root, Vector3.Transform(world, invA)); bool inB = csb.CellBSP?.Root is not null && Physics.PointInCellBspViaBspQuery(csb.CellBSP.Root, Vector3.Transform(world, invB)); _out.WriteLine($" x=plane{(dx >= 0 ? "+" : "")}{dx:F2} inA={(inA ? "Y" : "-")} inB={(inB ? "Y" : "-")}" + (inA && inB ? " <<< OVERLAP" : !inA && !inB ? " <<< NEITHER" : "")); } } private static class Physics { // Thin forwarder so this Rendering-side test reads clearly; the walk // is the production BSPQuery.PointInsideCellBsp. public static bool PointInCellBspViaBspQuery( DatReaderWriter.Types.CellBSPNode node, Vector3 localPoint) => AcDream.Core.Physics.BSPQuery.PointInsideCellBsp(node, localPoint); } /// /// (b) #177: which cell owns the stair-step geometry? Histogram of DRAWN /// visual polys by normal class + the z-ladder of horizontal polys /// (stair steps show as a ladder of small floor polys at stepped /// z-levels). Also lists every portal with its plane orientation — /// is the 0x0182↔0x0183 connection a floor-portal or a wall-portal? /// [Theory] [InlineData(0x8A020182u)] [InlineData(0x8A020183u)] public void StairTransit_GeometryOwnerAndPortalOrientation(uint cellId) { var datDir = ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var loaded = LoadCell(dats, cellId); Assert.NotNull(loaded); var (cell, cs) = loaded!.Value; var world = WorldTransform(cell); int floors = 0, ceilings = 0, walls = 0, inclined = 0; var floorZLevels = new SortedDictionary(); // z rounded to 0.1 m → count foreach (var (id, poly) in cs.Polygons) { var w = WorldVerts(cs, poly, world); if (w.Count < 3) continue; var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0])); float az = MathF.Abs(n.Z); if (az > 0.85f) { // Horizontal poly — bucket by mean z. float meanZ = 0; foreach (var v in w) meanZ += v.Z; meanZ /= w.Count; int zKey = (int)MathF.Round(meanZ * 10f); floorZLevels.TryGetValue(zKey, out var c); floorZLevels[zKey] = c + 1; if (n.Z > 0) floors++; else ceilings++; } else if (az < 0.25f) walls++; else { inclined++; float minZ = float.MaxValue, maxZ = float.MinValue; foreach (var v in w) { minZ = MathF.Min(minZ, v.Z); maxZ = MathF.Max(maxZ, v.Z); } _out.WriteLine($" INCLINED poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{minZ:F2},{maxZ:F2}] " + $"verts={poly.VertexIds.Count} stip={poly.Stippling} DRAWN={WouldDraw(poly, cell)}"); } } _out.WriteLine($"=== 0x{cellId:X8} poly histogram: floors={floors} ceilings={ceilings} walls={walls} inclined={inclined} ==="); _out.WriteLine(" horizontal-poly z-ladder (z → count): " + string.Join(" ", System.Linq.Enumerable.Select(floorZLevels, kv => $"{kv.Key / 10f:F1}:{kv.Value}"))); foreach (var p in cell.CellPortals) { if (!cs.Polygons.TryGetValue((ushort)p.PolygonId, out var poly)) continue; var w = WorldVerts(cs, poly, world); if (w.Count < 3) continue; var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0])); string orient = MathF.Abs(n.Z) > 0.85f ? "HORIZONTAL (floor/ceiling portal)" : MathF.Abs(n.Z) < 0.25f ? "vertical (wall portal)" : "INCLINED portal"; var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue); foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); } _out.WriteLine($" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] {orient} " + $"n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{min.Z:F2},{max.Z:F2}]"); } } }