Attribution (dat-evidenced, supersedes the misplaced-cell hypothesis):
the phantom staircase is the Holtburg MEETING HALL (AAB3 building[0],
model 0x010014C3 at AAB3-local (36,84,116)), NOT an A9B3 building - the
user stood at the A9B3/AAB3 boundary (cell-transit trail in
issue112-gate1.log) and clicked through the hall to the NPC behind it.
The hall's interior stair cells (0x100..0x106, ring climbing z 116->124.5
to the deck hatch) have geometry coincident with the shell's west wall
(both at local x=29.0). Our outdoor per-building flood admits them with
CORRECT tight clip regions (4-6 planes, door-aperture NDC boxes -
Issue113MeetingHallFloodTests proves it), but DrawEnvCellShells drew them
WHOLE: mesh_modern.vert writes gl_ClipDistance from the routed CellClip
slot, and gl_ClipDistance is ignored unless GL_CLIP_DISTANCEi is enabled -
which no caller ever did for the shell pass (born inert in 1405dd8).
Interior staircase painted across the exterior wall; unpickable because
it is cell geometry, not an entity.
Retail oracle: cell geometry IS clipped to the accumulated portal view -
Render::set_view (:343750) installs the view polygon edge planes,
DrawEnvCell submits every cell polygon with planeMask=0xffffffff (:427922)
through ACRender::polyClipFinish. Characters/meshes are NOT poly-clipped
(viewconeCheck path) - entity routing stays cleared, comment scoped.
Fix: enable GL_CLIP_DISTANCE0..7 around exactly the shell pass
(self-contained per feedback_render_self_contained_gl_state; no early-outs
between set and restore). Slot-0 fallback slices (>8-plane regions) still
draw pass-all - the assembler's scissor fallback remains unimplemented and
documented; the new flood test pins 0 such slices at the hall.
Refuted along the way (full evidence in Issue113PhantomStairsDumpTests):
- ONE misplaced interior EnvCell unifying #113+#112+collision gaps: all 17
A9B3 cottage cells share an identical dat Position (nothing to misplace);
the #112 gap is a real 20cm doorway micro-gap 0.23m outside threshold
cell 0x104 (straddles its exterior portal plane at foot radius 0.48);
missing object collision remains #99/A6.P4.
- A9B3 dat content near the spot: no stair geometry in shell (balcony at
z119 + turret roof only), cells (flat 116/118.8), statics, or stabs.
Tests: Core 1389 green (+6 dump facts) / App 224 (+1 flood replay) /
UI 420 / Net 294; pre-existing 4 #99-era failures unchanged.
Visual gate pending: user re-check of the hall west face vs retail.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
240 lines
11 KiB
C#
240 lines
11 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
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<DatEnvCell>(cellId)
|
|
?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found");
|
|
var environment = dats.Get<DatEnvironment>(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<CellPortalInfo>();
|
|
var clipPlanes = new List<PortalClipPlane>();
|
|
var portalPolygons = new List<Vector3[]>();
|
|
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<Vector3>();
|
|
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<Vector3>();
|
|
}
|
|
portalPolygons.Add(polyVerts);
|
|
}
|
|
|
|
uint lbPrefix = cellId & 0xFFFF0000u;
|
|
var visibleCells = new List<uint>();
|
|
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<uint, LoadedCell> LoadHall(DatCollection dats)
|
|
{
|
|
var cells = new Dictionary<uint, LoadedCell>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<uint, LoadedCell?> 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<string>();
|
|
|
|
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<string>();
|
|
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).");
|
|
}
|
|
}
|