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:
parent
6d2218cac3
commit
927fd8fde2
3 changed files with 1085 additions and 4 deletions
|
|
@ -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).");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue