using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Numerics; using AcDream.App.Rendering; using DatReaderWriter; using DatReaderWriter.Options; using Xunit; using Xunit.Abstractions; using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; using DatEnvironment = DatReaderWriter.DBObjs.Environment; namespace AcDream.App.Tests.Rendering; /// /// #113 phantom exterior staircase (2026-06-10): the Holtburg meeting hall /// (AAB3 building[0], model 0x010014C3 at AAB3-local (36,84,116)) shows its /// INTERIOR stair cells painted across the west exterior wall when viewed from /// outside. Attribution: retail clips drawn cell geometry to the accumulated /// portal aperture (Render::set_view portal scissor + polyClipFinish with /// planeMask=0xffffffff, decomp :343750/:427922); our DrawEnvCellShells routes /// per-cell clip planes via ClipFrameAssembler — but a slice whose aperture /// polygon exceeds the 8-plane budget falls back to slot 0 ("the renderer uses /// scissor for passes that need that fallback", ClipFrameAssembler.cs:13-15) /// and the shell pass never implemented that scissor → the cell draws fully /// unclipped. This harness replays the production outdoor per-building flood /// (PortalVisibilityBuilder.ConstructViewBuilding, the R-A2 path) + the /// production assembler from the user's click-time viewpoint and pins which /// hall cells draw and with what clip. /// public class Issue113MeetingHallFloodTests { private readonly ITestOutputHelper _out; public Issue113MeetingHallFloodTests(ITestOutputHelper output) => _out = output; private const uint Landblock = 0xAAB30000u; private const uint EnvironmentFilePrefix = 0x0D000000u; 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; } // Mirrors CornerFloodReplayTests.LoadCell (GameWindow.BuildLoadedCell shape). private static LoadedCell LoadCell(DatCollection dats, uint cellId) { var envCell = dats.Get(cellId) ?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found"); var environment = dats.Get(EnvironmentFilePrefix | envCell.EnvironmentId) ?? throw new InvalidOperationException($"Environment 0x{envCell.EnvironmentId:X8} not found"); if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct) || cellStruct is null) throw new InvalidOperationException($"CellStruct {envCell.CellStructure} missing"); var cellTransform = Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * Matrix4x4.CreateTranslation(envCell.Position.Origin); Matrix4x4.Invert(cellTransform, out var inverse); var boundsMin = new Vector3(float.MaxValue); var boundsMax = new Vector3(float.MinValue); foreach (var kvp in cellStruct.VertexArray.Vertices) { var v = kvp.Value; var pos = new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z); boundsMin = Vector3.Min(boundsMin, pos); boundsMax = Vector3.Max(boundsMax, pos); } if (boundsMin.X == float.MaxValue) { boundsMin = Vector3.Zero; boundsMax = Vector3.Zero; } var portals = new List(); var clipPlanes = new List(); var portalPolygons = new List(); var centroid = (boundsMin + boundsMax) * 0.5f; foreach (var portal in envCell.CellPortals) { portals.Add(new CellPortalInfo( portal.OtherCellId, portal.PolygonId, (ushort)portal.Flags, portal.OtherPortalId)); if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly) && poly.VertexIds.Count >= 3 && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0) && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1) && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2)) { var p0 = new Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z); var p1 = new Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z); var p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z); var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0)); float d = -Vector3.Dot(normal, p0); float centroidDot = Vector3.Dot(normal, centroid) + d; clipPlanes.Add(new PortalClipPlane { Normal = normal, D = d, InsideSide = centroidDot >= 0 ? 0 : 1, }); } else { clipPlanes.Add(default); } Vector3[] polyVerts = Array.Empty(); if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPoly) && portalPoly.VertexIds.Count >= 3) { polyVerts = new Vector3[portalPoly.VertexIds.Count]; bool allResolved = true; for (int vi = 0; vi < portalPoly.VertexIds.Count; vi++) { if (cellStruct.VertexArray.Vertices.TryGetValue( (ushort)portalPoly.VertexIds[vi], out var pv)) polyVerts[vi] = new Vector3(pv.Origin.X, pv.Origin.Y, pv.Origin.Z); else { allResolved = false; break; } } if (!allResolved) polyVerts = Array.Empty(); } portalPolygons.Add(polyVerts); } uint lbPrefix = cellId & 0xFFFF0000u; var visibleCells = new List(); if (envCell.VisibleCells is not null) foreach (var lowId in envCell.VisibleCells) visibleCells.Add(lbPrefix | lowId); return new LoadedCell { CellId = cellId, WorldPosition = envCell.Position.Origin, WorldTransform = cellTransform, InverseWorldTransform = inverse, LocalBoundsMin = boundsMin, LocalBoundsMax = boundsMax, Portals = portals, ClipPlanes = clipPlanes, PortalPolygons = portalPolygons, VisibleCells = visibleCells, SeenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside), }; } private static Dictionary LoadHall(DatCollection dats) { var cells = new Dictionary(); for (uint low = 0x0100u; low <= 0x0106u; low++) { uint id = Landblock | low; cells[id] = LoadCell(dats, id); } return cells; } private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt) { var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ); var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f); return view * proj; } /// /// Replay the production outdoor flood + assembler from the user's /// click-time viewpoint (player AAB3-local (24.4, 83.4, 116), chase eye /// behind at eye height, looking at the hall's west door). Prints every /// hall cell that enters the drawn set with its slice slot / plane count / /// NDC AABB. The #113 defect signature: a hall cell drawn with a slice /// that has Slot==0 AND zero planes (the assembler's scissor-fallback /// contract) — DrawEnvCellShells draws that slice fully unclipped, so the /// interior staircase paints across the exterior wall. /// [Fact] public void WestApproach_HallCellSlices_HaveUsableClip() { var datDir = ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var cells = LoadHall(dats); Func lookup = id => cells.TryGetValue(id, out var c) ? c : null; // Chase-camera sweep along the west approach: player walked from the // cottage (west) toward the hall; eye behind the player at z≈117.6, // looking at the hall's west door (local (29.4, 84.0, 117.3)). var lookAt = new Vector3(29.4f, 84.0f, 117.3f); int unclippedDrawn = 0; var report = new List(); for (int i = 0; i <= 20; i++) { float ex = 14f + i * 0.5f; // eye local x 14 → 24 var eye = new Vector3(ex, 83.4f, 117.6f); var vp = ViewProjFor(eye, lookAt); var frame = PortalVisibilityBuilder.ConstructViewBuilding( cells.Values.ToList(), eye, lookup, vp, maxSeedDistance: 48f); var clipFrame = ClipFrame.NoClip(); var assembly = ClipFrameAssembler.Assemble(clipFrame, frame); string flood = string.Join(" ", frame.OrderedVisibleCells.Select(c => $"{c & 0xFFFFu:X3}")); var lineParts = new List(); foreach (uint cellId in frame.OrderedVisibleCells) { if (!assembly.CellIdToViewSlices.TryGetValue(cellId, out var slices)) { // No slices at all → DrawEnvCellShells falls to NoClipSlice → UNCLIPPED. lineParts.Add($"0x{cellId & 0xFFFFu:X3}:NO-SLICES(unclipped)"); unclippedDrawn++; continue; } foreach (var s in slices) { bool unclipped = s.Slot == 0 && s.Planes.Length == 0; if (unclipped) unclippedDrawn++; string tag = unclipped ? "(UNCLIPPED)" : ""; lineParts.Add(FormattableString.Invariant( $"0x{cellId & 0xFFFFu:X3}:slot{s.Slot}/p{s.Planes.Length}[{s.NdcAabb.X:F2},{s.NdcAabb.Y:F2}..{s.NdcAabb.Z:F2},{s.NdcAabb.W:F2}]{tag}")); } } report.Add(FormattableString.Invariant( $"eyeX={ex,5:F1} flood=[{flood}] {string.Join(" ", lineParts)}")); } foreach (var line in report) _out.WriteLine(line); _out.WriteLine($"unclipped-drawn slices across sweep: {unclippedDrawn}"); // Documents the defect when it fires; flips to 0 once the shell pass // implements the assembler's scissor-fallback contract (or exact clip). Assert.True(unclippedDrawn == 0, $"#113: {unclippedDrawn} hall-cell slices would draw fully unclipped " + "(slot 0, no planes, no scissor) — interior stair geometry paints " + "across the exterior wall (the phantom staircase)."); } }