fix(render): #113 - enable GL clip distances for the PView shell pass (phantom exterior staircase)

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>
This commit is contained in:
Erik 2026-06-10 16:26:55 +02:00
parent 6d2218cac3
commit 927fd8fde2
3 changed files with 1085 additions and 4 deletions

View file

@ -0,0 +1,240 @@
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).");
}
}