acdream/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
Erik 9aaf97e785 Revert "Campaign V slice V4a" - it lost world multisampling
This reverts ceec3bc4. Two independent reasons, either sufficient.

The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.

The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.

This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.

The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.

Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.

Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:29:28 +02:00

1299 lines
65 KiB
C#

// PortalVisibilityBuilder.cs
//
// Phase A8.F: recursive portal-clip visibility (the builder). Port of retail
// PView::ConstructView (decomp:433750) -> ClipPortals (433572) -> AddViewToPortals
// (433446). Walks the portal graph from the camera cell, accumulating a per-cell
// screen-space CellView; exit portals union their clipped region into OutsideView.
// GL-free; unit-tested without a GPU context.
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>Per-frame output of the portal-frame BFS.</summary>
public sealed class PortalVisibilityFrame
{
private const int MaxRetainedCellViews = 512;
internal const int MaxRetainedBuildCollectionCapacity = 512;
internal const int CapacityTrimIdleFrames = 120;
private readonly Stack<CellView> _cellViewPool = new();
private readonly PortalPolygonVertexStore _polygonVertices = new();
private int _cellViewsUnderusedFrames;
private int _crossBuildingViewsUnderusedFrames;
private int _queuedUnderusedFrames;
private int _drawListedUnderusedFrames;
private int _processedViewCountsUnderusedFrames;
private int _orderedVisibleCellsUnderusedFrames;
private int _todoUnderusedFrames;
internal PortalPolygonVertexStore PolygonVertices => _polygonVertices;
internal int PolygonVertexAllocationCount => _polygonVertices.AllocationCount;
internal int RetainedPolygonVertexArrayCount => _polygonVertices.RetainedArrayCount;
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
public CellView OutsideView { get; private set; } = new();
/// <summary>Per-cell accumulated clip region, keyed by full cell id (wire-in #2).</summary>
public Dictionary<uint, CellView> CellViews { get; } = new();
/// <summary>Visible interior cells in the exact order they were first dequeued from the
/// distance-priority work list — closest-first (Phase U.2a). Mirrors retail's
/// PView::cell_draw_list, appended in PView::ConstructView (decomp:433783) as each cell pops
/// off the nearest-vertex-sorted cell_todo_list (InsCellTodoList 433183). Deduplicated: a cell
/// appears exactly once, on its first dequeue. The camera cell is always first.</summary>
public List<uint> OrderedVisibleCells { get; } = new();
/// <summary>Entry clip regions for other buildings reached through our portals, keyed by the
/// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5).</summary>
public Dictionary<uint, CellView> CrossBuildingViews { get; } = new();
// Build scratch belongs to the frame so a caller that reuses a frame also reuses the large
// hash tables and the 128-entry convergence trace. This is especially important outdoors,
// where retail runs one small ConstructView flood per nearby building every frame.
internal HashSet<uint> QueuedScratch { get; } = new();
internal HashSet<uint> DrawListedScratch { get; } = new();
internal Dictionary<uint, int> ProcessedViewCountsScratch { get; } = new();
internal uint[] PropagationChainScratch { get; } = new uint[128];
internal List<(LoadedCell Cell, float Distance)> TodoScratch { get; } = new();
// A build can recurse while an outer portal's clipped region is still live, so one mutable
// singleton is unsafe. Leases reuse Lists by recursion lifetime rather than by total portal-call
// count: ordinary sequential portals need one List, while nested AdjustCellView propagation gets
// one per active depth. Dispose returns scratch on every continue/exception path.
private readonly Stack<List<ViewPolygon>> _clipRegionPool = new();
private const int MaxRetainedClipRegionLists = 132; // recursion tripwire (128) + nested side work
private const int MaxRetainedClipRegionCapacity = 256;
internal int ClipRegionScratchCount => _clipRegionPool.Count;
internal int RetainedCellViewCount => _cellViewPool.Count;
internal int CellViewMapCapacity => CellViews.EnsureCapacity(0);
internal int CrossBuildingViewMapCapacity => CrossBuildingViews.EnsureCapacity(0);
internal int QueuedCapacity => QueuedScratch.EnsureCapacity(0);
internal int DrawListedCapacity => DrawListedScratch.EnsureCapacity(0);
internal int ProcessedViewCountCapacity => ProcessedViewCountsScratch.EnsureCapacity(0);
internal ClipRegionScratchLease RentClipRegionScratch()
{
List<ViewPolygon> region = _clipRegionPool.Count != 0
? _clipRegionPool.Pop()
: new List<ViewPolygon>(2);
region.Clear();
return new ClipRegionScratchLease(this, region);
}
private void ReturnClipRegionScratch(List<ViewPolygon> region)
{
region.Clear();
if (region.Capacity <= MaxRetainedClipRegionCapacity
&& _clipRegionPool.Count < MaxRetainedClipRegionLists)
_clipRegionPool.Push(region);
}
internal readonly ref struct ClipRegionScratchLease
{
private readonly PortalVisibilityFrame _owner;
public List<ViewPolygon> Region { get; }
internal ClipRegionScratchLease(PortalVisibilityFrame owner, List<ViewPolygon> region)
{
_owner = owner;
Region = region;
}
public void Dispose() => _owner.ReturnClipRegionScratch(Region);
}
internal void ResetForBuild()
{
int cellViewCount = CellViews.Count;
int crossBuildingViewCount = CrossBuildingViews.Count;
int queuedCount = QueuedScratch.Count;
int drawListedCount = DrawListedScratch.Count;
int processedViewCount = ProcessedViewCountsScratch.Count;
int orderedVisibleCellCount = OrderedVisibleCells.Count;
int todoCount = TodoScratch.Count;
if (OutsideView.IsRetainable)
OutsideView.Reset();
else
OutsideView = new CellView();
ReturnCellViews(CellViews);
ReturnCellViews(CrossBuildingViews);
CellViews.Clear();
OrderedVisibleCells.Clear();
CrossBuildingViews.Clear();
QueuedScratch.Clear();
DrawListedScratch.Clear();
ProcessedViewCountsScratch.Clear();
TodoScratch.Clear();
_polygonVertices.ResetUsage();
// These collections live as long as RetailPViewRenderer. A fixed
// capacity cutoff is unsafe here: a legitimate large portal flood can
// exceed it every frame, causing us to discard and rebuild the same
// warmed tables forever. Retire high-water storage only after it has
// remained at least 50% underused for a sustained idle window. Once a
// collection is right-sized for its recurring workload it stays warm.
TrimIfCold(CellViews, cellViewCount, ref _cellViewsUnderusedFrames);
TrimIfCold(
CrossBuildingViews,
crossBuildingViewCount,
ref _crossBuildingViewsUnderusedFrames);
TrimIfCold(QueuedScratch, queuedCount, ref _queuedUnderusedFrames);
TrimIfCold(DrawListedScratch, drawListedCount, ref _drawListedUnderusedFrames);
TrimIfCold(
ProcessedViewCountsScratch,
processedViewCount,
ref _processedViewCountsUnderusedFrames);
TrimIfCold(
OrderedVisibleCells,
orderedVisibleCellCount,
ref _orderedVisibleCellsUnderusedFrames);
TrimIfCold(TodoScratch, todoCount, ref _todoUnderusedFrames);
}
internal ViewPolygon CopyPolygon(ReadOnlySpan<Vector2> vertices)
{
if (vertices.Length < 3)
return default;
Vector2[] owned = _polygonVertices.Rent(vertices.Length);
vertices.CopyTo(owned);
return new ViewPolygon(owned);
}
internal CellView RentCellView(bool fullScreen = false)
{
CellView result = _cellViewPool.Count != 0
? _cellViewPool.Pop()
: new CellView();
if (fullScreen)
result.SetFullScreen();
else
result.Reset();
return result;
}
private void ReturnCellViews(Dictionary<uint, CellView> views)
{
foreach (CellView view in views.Values)
{
view.Reset();
if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews)
_cellViewPool.Push(view);
}
}
private static void TrimIfCold<TKey, TValue>(
Dictionary<TKey, TValue> values,
int usedCount,
ref int underusedFrames)
where TKey : notnull
{
int capacity = values.EnsureCapacity(0);
if (!ShouldTrim(capacity, usedCount, ref underusedFrames))
return;
if (capacity > MaxRetainedBuildCollectionCapacity)
values.TrimExcess();
}
private static void TrimIfCold<T>(
HashSet<T> values,
int usedCount,
ref int underusedFrames)
{
int capacity = values.EnsureCapacity(0);
if (!ShouldTrim(capacity, usedCount, ref underusedFrames))
return;
if (capacity > MaxRetainedBuildCollectionCapacity)
values.TrimExcess();
}
private static void TrimIfCold<T>(
List<T> values,
int usedCount,
ref int underusedFrames)
{
int capacity = values.Capacity;
if (!ShouldTrim(capacity, usedCount, ref underusedFrames))
return;
if (capacity > MaxRetainedBuildCollectionCapacity)
values.Capacity = 0;
}
private static bool ShouldTrim(int capacity, int usedCount, ref int underusedFrames)
{
if (capacity <= MaxRetainedBuildCollectionCapacity
|| (long)usedCount * 2L > capacity)
{
underusedFrames = 0;
return false;
}
underusedFrames++;
if (underusedFrames < CapacityTrimIdleFrames)
return false;
underusedFrames = 0;
return true;
}
}
public static class PortalVisibilityBuilder
{
// Side-classification epsilon. Retail's is F_EPSILON = 0.000199999995
// (const @0x007c8c70; PView::InitCell Ghidra 0x005a4b70). T2 (BR-4)
// attempted the retail value and the CornerFloodReplay gate REFUTED it:
// retail's tight epsilon works because retail's viewer cell transits the
// INSTANT the eye crosses a portal plane (the sweep's curr_cell), so the
// side test never sees a stale root more than F_EPSILON past a plane. Our
// root can lag the eye by up to ~1 cm at pressed corners (the harness's
// fixed-root sweep models this), and 0.01 is that documented root-lag
// tolerance — NOT a retail constant. Tighten to F_EPSILON only together
// with eye-exact viewer-cell tracking verification (the #108-membership
// family) + the cdstW near-clip pin.
private const float PortalSideEpsilon = 0.01f;
// Retail F_EPSILON proper — used where the semantic is knife-edge
// REJECTION (ConstructView(CBldPortal) Sidedness IN_PLANE → return 0,
// Ghidra 0x005a59a0), which must NOT inherit the root-lag tolerance above
// (a 1 cm-wide in-plane band would reject look-in seeds whenever the eye
// stands near a doorway plane).
private const float SeedInPlaneEpsilon = 0.0002f;
// TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the
// local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell.
private static readonly bool s_pvDump =
Environment.GetEnvironmentVariable("ACDREAM_A8_DUMP_PV") == "1";
private static readonly Dictionary<uint, int> s_pvDumpCount = new();
/// <summary>
/// #120 observable: total convergence-tripwire firings across both the
/// interior <see cref="Build"/> and the exterior look-in propagation.
/// The tripwire firing means the in-place growth's fixpoint invariant
/// broke (T2/BR-4) — tests reset this and assert it stays 0.
/// </summary>
public static int ConvergenceTripwireCount;
/// <summary>
/// #120 self-attribution dump: the growth-recursion path that exceeded
/// the tripwire, as a per-cell frequency summary plus the chain tail —
/// the cycle's structure (e.g. 0174↔0175 ping-pong vs a 3-cycle lap)
/// reads directly off the output.
/// </summary>
private static void DumpPropagationChain(uint[] chain, int depth, uint rootCellId, Vector3 eye)
{
int n = Math.Min(depth, chain.Length);
var freq = new Dictionary<uint, int>();
for (int i = 0; i < n; i++)
{
freq.TryGetValue(chain[i], out int c);
freq[chain[i]] = c + 1;
}
var summary = new System.Text.StringBuilder(256);
foreach (var kvp in freq)
summary.Append(System.FormattableString.Invariant($" 0x{kvp.Key:X8}x{kvp.Value}"));
var tail = new System.Text.StringBuilder(256);
for (int i = Math.Max(0, n - 24); i < n; i++)
tail.Append(System.FormattableString.Invariant($" 0x{chain[i] & 0xFFFFu:X4}"));
Console.WriteLine(System.FormattableString.Invariant(
$"[pv-ERROR] chain root=0x{rootCellId:X8} eye=({eye.X:F3},{eye.Y:F3},{eye.Z:F3}) cells:{summary}"));
Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}");
}
/// <summary>The +Z world lift applied to DRAWN cell shells (z-fighting vs
/// terrain; applied in GameWindow's cell registration). The visibility
/// graph stays in PHYSICS (unlifted) space — feeding the lift into portal
/// planes broke horizontal-portal side tests (#119-residual, f35cb8b).
/// Draw-space consumers of portal polygons (the OutsideView color gate
/// here, the seal/punch depth fans in GameWindow) must apply this lift so
/// they meet the drawn shell's aperture edge — the unlifted gate left a
/// 2 cm background strip under the drawn lintel (#130).</summary>
public const float ShellDrawLiftZ = 0.02f;
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
/// <param name="buildingMembership">Optional: true if a cell id is in the camera building's cell
/// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of
/// continuing the in-building BFS. Pass null to treat all reachable cells as in-building.</param>
/// <param name="drawLiftZ">World +Z applied ONLY to the exit-portal projection feeding
/// <see cref="PortalVisibilityFrame.OutsideView"/> (a draw-space region; see
/// <see cref="ShellDrawLiftZ"/>). Flood admission, side tests, and CellViews are unaffected.
/// Production passes <see cref="ShellDrawLiftZ"/>; tests replaying visibility semantics pass 0.</param>
public static PortalVisibilityFrame Build(
LoadedCell cameraCell,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
Func<uint, bool>? buildingMembership = null,
float drawLiftZ = 0f,
PortalVisibilityFrame? reuseFrame = null)
{
var frame = reuseFrame ?? new PortalVisibilityFrame();
frame.ResetForBuild();
if (cameraCell == null) return frame;
// Interior portals never cross landblocks (same invariant as CellVisibility.GetVisibleCells);
// building-boundary crossings are handled separately via the buildingMembership escape hatch.
uint lbMask = cameraCell.CellId & 0xFFFF0000u;
frame.CellViews[cameraCell.CellId] = frame.RentCellView(fullScreen: true);
// Render unification (outdoor-as-cell, 2026-06-07): when the root IS the synthetic outdoor
// node, the landscape is visible FULL-SCREEN, so seed OutsideView with the full-screen NDC
// quad. ClipFrameAssembler turns that into a full-screen OutsideView slice, so DrawInside's
// DrawLandscapeThroughOutsideView draws terrain/sky/scenery/weather as the node's "shell" —
// the very same callback that already draws the doorway slice when an INTERIOR root reaches
// outdoors. Keyed on the explicit IsOutdoorNode flag (set by OutdoorCellNode.Build), NOT a
// cell-id heuristic: production EnvCell ids are >= 0x100 but test fixtures use low interior
// ids, so an id test would misfire. An interior root never sets this flag, so the indoor
// exit-portal path (OtherCellId==0xFFFF below) still owns the doorway OutsideView region.
if (cameraCell.IsOutdoorNode)
frame.OutsideView.Add(frame.CopyPolygon(FullScreenQuad));
// Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first;
// each cell carries the camera→nearest-portal-vertex distance that put it on the list
// (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The
// camera cell seeds at distance 0 (retail InsCellTodoList(this, arg2, 0f) at 433758) so it
// always pops first.
List<(LoadedCell Cell, float Distance)> todo = frame.TodoScratch;
InsertTodo(todo, cameraCell, 0f);
// Fixpoint termination replacing the old MaxReprocessPerCell hard cap. This mirrors the
// retail portal_view slice offset 0x44 (last-incorporated view-poly watermark) vs 0x38
// (current view_count) decision in AddViewToPortals (433446): a cell is INSERTED into the
// todo list exactly once — on first discovery (retail's ecx_5==0 branch calls
// InsCellTodoList; the ecx_5!=eax_2 growth branch calls AddToCell IN PLACE and never
// re-enqueues). Later growth into an already-discovered cell is unioned into its CellView but
// does NOT re-enqueue it — the `cell_view_done` guarantee (ConstructView sets it at 433784
// the instant a cell is popped). Enqueue-once across the cell set is the hard termination
// guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The
// camera cell is pre-marked so a portal looping back to it can never re-enqueue it.
HashSet<uint> queued = frame.QueuedScratch;
queued.Add(cameraCell.CellId);
HashSet<uint> drawListed = frame.DrawListedScratch;
Dictionary<uint, int> processedViewCounts = frame.ProcessedViewCountsScratch;
var trace = PortalBuildTrace.Start(cameraCell, cameraPos);
// [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn
// + reciprocal pre/post region counts, emitted as one summary line at end of Build. Inert when off.
bool churnProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled;
int churnReenqueues = 0;
var churnReciprocal = churnProbe ? new System.Text.StringBuilder(256) : null;
bool pvDump = false;
if (s_pvDump)
{
lock (s_pvDumpCount)
{
s_pvDumpCount.TryGetValue(cameraCell.CellId, out int dc);
if (dc < 2) { s_pvDumpCount[cameraCell.CellId] = dc + 1; pvDump = true; }
}
if (pvDump)
{
Console.WriteLine($"[pv-dump] camCell=0x{cameraCell.CellId:X8} portals={cameraCell.Portals.Count} polyLists={cameraCell.PortalPolygons.Count} vp[M11={viewProj.M11:F3} M22={viewProj.M22:F3} M33={viewProj.M33:F3} M34={viewProj.M34:F3} M43={viewProj.M43:F3} M44={viewProj.M44:F3}]");
// Camera-cell portal census (A8.F triage 2026-05-29): report, for EVERY
// portal, the exact inputs the BFS guards read — BEFORE the guards run, so
// a portal the loop silently `continue`s past is still visible here. An
// empty OUTSIDEVIEW can then be traced to the precise gate: polyLen<3 (empty
// polygon from EnvCellLandblockBuildBuilder), interiorSide=false (camera back-facing the
// portal — a legitimately-empty result, not a bug), or (if both OK) a
// downstream projection/clip failure shown by the EXIT-PROJ/EXIT-CLIP lines.
for (int ci = 0; ci < cameraCell.Portals.Count; ci++)
{
int plen = ci < cameraCell.PortalPolygons.Count
? (cameraCell.PortalPolygons[ci]?.Length ?? -1) : -2;
bool hasPlane = ci < cameraCell.ClipPlanes.Count;
bool interiorSide = !hasPlane || CameraOnInteriorSide(cameraCell, ci, cameraPos);
var n = hasPlane ? cameraCell.ClipPlanes[ci].Normal : Vector3.Zero;
Console.WriteLine($"[pv-dump] CAMPORTAL[{ci}] other=0x{cameraCell.Portals[ci].OtherCellId:X4} polyLen={plen} hasPlane={hasPlane} interiorSide={interiorSide} planeN=({n.X:F3},{n.Y:F3},{n.Z:F3})");
}
}
}
// T2 (BR-4): retail's growth propagation is IN PLACE, never by re-enqueue
// — PView::AddViewToPortals (Ghidra 0x005a52d0, pc:433446): first
// discovery enqueues via InsCellTodoList; growth into a cell whose
// cell_view_done is set calls AdjustCellView (pc:433741-433745), which
// re-clips ONLY the new views (the update_count watermark) through that
// cell's portals immediately. Our processedViewCounts IS that watermark,
// so in-place propagation = call ProcessCellPortals on the grown
// neighbour; it processes exactly the new tail and recurses further
// growth. Termination is physical: recursion fires only when AddRegion
// added a DISTINCT polygon (CanonicalKey dedup) that survived the 1-px
// vertex merge — the finite fixpoint floor that replaced the old
// MaxReprocessPerCell=16 drift cap (deleted). The depth tripwire below
// is a loud failsafe, not control flow: it firing means the convergence
// invariant broke and must be fixed, not tuned.
const int RecursionTripwire = 128;
// #120 self-attribution: the recursion path (cell id per depth), so a
// tripwire firing names the growth CYCLE instead of just the tip.
// Harness sweeps (CornerFloodReplayTests *Converges tests) could not
// reproduce the T5 firing — production-only ingredients (full lookup
// graph / real camera path) are suspected; this dump pins them on the
// next natural occurrence.
uint[] propagationChain = frame.PropagationChainScratch;
void ProcessCellPortals(LoadedCell cell, int depth)
{
if (depth >= RecursionTripwire)
{
System.Threading.Interlocked.Increment(ref ConvergenceTripwireCount);
Console.WriteLine($"[pv-ERROR] in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate");
DumpPropagationChain(propagationChain, depth, cameraCell.CellId, cameraPos);
return;
}
propagationChain[depth] = cell.CellId;
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
{
trace?.Add($"proc cell=0x{cell.CellId:X8} skip=no-view");
return;
}
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
int endCount = currentView.Polygons.Count;
if (processedCount >= endCount)
{
trace?.Add($"proc cell=0x{cell.CellId:X8} skip=processed processed={processedCount} views={endCount}");
return;
}
trace?.Add($"proc cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} depth={depth}");
processedViewCounts[cell.CellId] = endCount;
for (int i = 0; i < cell.Portals.Count; i++)
{
var portal = cell.Portals[i];
if (i >= cell.PortalPolygons.Count)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=no-poly-slot");
continue;
}
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=degenerate-poly len={(poly?.Length ?? -1)}");
continue;
}
bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF;
// (R-A2b Phase 1 pin, throwaway) Log the side-test inputs for EVERY portal so a back-portal
// traversal (cell=0x..0173 p->0x0171) can be attributed to the side test.
// Strip with the rest of the [pv-trace] apparatus.
if (trace != null)
{
bool camInterior = i >= cell.ClipPlanes.Count || CameraOnInteriorSide(cell, i, cameraPos);
float sideD = (i < cell.ClipPlanes.Count && cell.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f)
? Vector3.Dot(cell.ClipPlanes[i].Normal, Vector3.Transform(cameraPos, cell.InverseWorldTransform)) + cell.ClipPlanes[i].D
: float.NaN;
trace.Add($"sidechk cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} camInterior={camInterior} D={(float.IsNaN(sideD) ? "na" : sideD.ToString("F2"))}");
}
// Portal-side test (retail PView::InitCell side test, decomp:432962): only traverse a portal
// the camera is on the INTERIOR side of. Retail culls the back-facing portal (the doorway just
// flooded through) by this test ALONE — there is NO eye-in-opening bypass. R-A2b: the old
// `&& !eyeInsideOpening` bypass let a back portal within 1.75 m through, forming the
// 0171<->0173 flood cycle -> re-enqueue churn -> the doorway flap (pinned in flap-sidechk.log:
// back portals show camInterior=False eyeIn=True).
if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos))
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side");
if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}");
continue;
}
// Retail PView::ClipPortals calls GetClip(..., finish=1): transform to
// homogeneous clip space, clip at the eye, then clip against the current
// portal_view region before the divide. Do the same here; the old early
// ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways.
using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease =
frame.RentClipRegionScratch();
List<ViewPolygon> clippedRegion = clippedRegionLease.Region;
ClipPortalAgainstView(
frame,
poly,
cell.WorldTransform,
viewProj,
currentView.Polygons,
processedCount,
endCount - processedCount,
clippedRegion,
out int clipVerts);
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})");
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
// Empty clip = no flood through this portal, period — retail's empty-GetClip rule
// (polyClipFinish <3 survivors → reject; ClipPortals adds no view). The
// EyeInsidePortalOpening rescue that used to substitute the current view here was
// the documented compensation for ProjectToClip's old EyePlaneW=1e-4 divergence
// from polyClipFinish's exact W=0 clip; with the W=0 port (2026-06-11, pseudocode
// at docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md) an eye-crossing
// portal projects to its true half-region and the rescue is DELETED.
if (clippedRegion.Count == 0)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=clip-empty clipVerts={clipVerts}");
continue;
}
if (portal.OtherCellId == 0xFFFF)
{
if (pvDump)
{
Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipVerts={clipVerts} clipPolys={clippedRegion.Count}");
Console.WriteLine($"[pv-dump] local=[{string.Join(" ", System.Array.ConvertAll(poly, v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]");
foreach (var cp in clippedRegion)
Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]");
}
// Exit portal -> outdoors visible through this (clipped) opening.
// OutsideView gates DRAWN color (terrain/sky/scissor), and the
// shell that rasterizes this aperture draws +drawLiftZ above
// the physics transform — project the region in the SAME
// lifted space or terrain stops a lift-height short of the
// drawn lintel (#130 strip). Flood semantics keep the
// unlifted clippedRegion path above.
int outsideCount;
if (drawLiftZ == 0f)
{
AddRegion(frame.OutsideView, clippedRegion);
outsideCount = clippedRegion.Count;
}
else
{
using PortalVisibilityFrame.ClipRegionScratchLease outsideRegionLease =
frame.RentClipRegionScratch();
List<ViewPolygon> outsideRegion = outsideRegionLease.Region;
ClipPortalAgainstView(
frame,
poly,
cell.WorldTransform * Matrix4x4.CreateTranslation(0f, 0f, drawLiftZ),
viewProj,
currentView.Polygons,
processedCount,
endCount - processedCount,
outsideRegion,
out _);
AddRegion(frame.OutsideView, outsideRegion);
outsideCount = outsideRegion.Count;
}
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideCount} clipVerts={clipVerts}");
continue;
}
uint neighbourId = lbMask | portal.OtherCellId;
// Cross-building boundary: route to CrossBuildingViews, don't continue in-building BFS.
// (Cross-building entry is retail's CBldPortal/AddToCell channel, not OtherPortalClip;
// the reciprocal clip below is interior-cell-to-cell only, matching the OtherPortalClip
// call inside ConstructView at decomp:433692.)
if (buildingMembership != null && !buildingMembership(neighbourId))
{
var xview = GetOrCreate(frame, frame.CrossBuildingViews, neighbourId);
bool grewCross = AddRegion(xview, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}");
continue;
}
var neighbour = lookup(neighbourId);
if (neighbour == null)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=lookup-miss polys={clippedRegion.Count}");
continue;
}
// Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip
// decomp:433524). The portal opening seen from THIS cell may be wider than the
// SAME opening seen from the neighbour (skewed/oblique apertures), so retail
// re-clips the already-near-side-clipped region against the neighbour's matching
// (reciprocal) portal polygon — the propagated region is the intersection of the
// opening "seen from A" AND "seen from B". This can only TIGHTEN, never widen, and
// degrades to the prior near-side-only region when the reciprocal is unresolvable
// (over-include is the safe default). The reciprocal is the portal at index
// `portal.OtherPortalId` in the NEIGHBOUR's portal list — retail's direct back-link
// (arg2->other_portal_id, 433557), NOT a scan for the first OtherCellId match. The
// direct index is what lets a cell with TWO portals to the same neighbour clip each
// opening against its OWN reciprocal instead of the first one. Mutates clippedRegion
// in place before the union below.
// T2 (BR-4): reciprocal-empty culls — retail OtherPortalClip
// returning nothing means the opening is invisible from the
// neighbour's side; the old eye-in-opening restore was part of
// the deleted rescue.
int preReciprocalCount = clippedRegion.Count;
ApplyReciprocalClip(
frame, clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
if (churnProbe)
churnReciprocal!.Append(System.FormattableString.Invariant(
$" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]"));
if (clippedRegion.Count == 0)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=reciprocal-empty pre={preReciprocalCount} otherPortal={portal.OtherPortalId}");
continue;
}
// Union the clipped region into the neighbour's accumulated view.
var nview = GetOrCreate(frame, frame.CellViews, neighbourId);
bool grew = AddRegion(nview, clippedRegion);
bool inserted = false;
bool inPlace = false;
float dist = float.NaN;
if (grew)
{
// First discovery → enqueue once (retail InsCellTodoList in
// the ecx_5==0 branch). Distance = camera→nearest portal-
// opening vertex (retail InitCell min-vertex distance,
// pc:432988-433004).
if (queued.Add(neighbourId))
{
dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
InsertTodo(todo, neighbour, dist);
inserted = true;
}
// Growth into an already-POPPED cell → retail AdjustCellView:
// process only the new views, immediately, in place. A cell
// discovered but still pending in the todo list needs nothing
// — its pop processes everything to date via the watermark.
else if (drawListed.Contains(neighbourId))
{
inPlace = true;
if (churnProbe) churnReenqueues++;
ProcessCellPortals(neighbour, depth + 1);
}
}
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} addCell polys={clippedRegion.Count} clipVerts={clipVerts} recip={preReciprocalCount}->{clippedRegion.Count} grew={grew} queued={inserted} inPlace={inPlace} dist={(float.IsNaN(dist) ? "na" : dist.ToString("F2"))}");
}
}
while (todo.Count > 0)
{
LoadedCell cell = PopNearest(todo);
// Single pop per cell (enqueue-once) IS the cell's closest-first
// draw position (retail appends to cell_draw_list once per pop,
// pc:433783). Note: retail also RE-SORTS the draw list when a
// late-grown cell's dependency order changes (AdjustCellPlace,
// pc:433247); we keep first-pop order — under T1's whole-cell
// far→near draws + depth testing, order affects only transparent-
// pass compositing in exotic chains (documented residual for T5).
if (drawListed.Add(cell.CellId))
frame.OrderedVisibleCells.Add(cell.CellId);
trace?.Add($"pop cell=0x{cell.CellId:X8} drawPos={frame.OrderedVisibleCells.Count - 1}");
ProcessCellPortals(cell, 0);
}
if (pvDump)
Console.WriteLine($"[pv-dump] OUTSIDEVIEW polys={frame.OutsideView.Polygons.Count} bfsCellViews={frame.CellViews.Count} crossBldg={frame.CrossBuildingViews.Count}");
// Phase U.4c flap probe (ACDREAM_PROBE_FLAP) — read-only per-frame snapshot of the
// root cell's per-portal side-test + projection + the frame's exit/visible counts.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
EmitFlapProbe(cameraCell, cameraPos, viewProj, frame);
trace?.Emit(frame);
if (churnProbe)
{
// T2: pops are enqueue-once now; churnReenqueues counts retail-style
// IN-PLACE propagations (AdjustCellView equivalents) instead.
Console.WriteLine(System.FormattableString.Invariant(
$"[portal-churn] root=0x{cameraCell.CellId:X8} cells={frame.OrderedVisibleCells.Count} inPlaceProps={churnReenqueues}") + churnReciprocal);
}
return frame;
}
/// <summary>
/// Build a portal visibility frame for an OUTDOOR viewer looking into one or more
/// outside-facing cell portals. This is the reciprocal of <see cref="Build"/>:
/// the seed view is the projected exit-portal opening instead of a full-screen
/// camera cell. It keeps the same retail distance-priority traversal and
/// neighbour reciprocal clipping once inside the building.
/// </summary>
/// <param name="seedRegion">Optional NDC region the seed apertures clip against —
/// retail's GetClip runs under the CURRENTLY INSTALLED view (PView::GetClip
/// 0x005a4320): full screen when the viewer is outdoors, the accumulated
/// outside (doorway) view when a building is looked into from an interior
/// root (#124). Null = full screen (the outdoor-root behavior).</param>
public static PortalVisibilityFrame BuildFromExterior(
IEnumerable<LoadedCell> candidateCells,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity,
IReadOnlyList<ViewPolygon>? seedRegion = null,
PortalVisibilityFrame? reuseFrame = null)
{
var frame = reuseFrame ?? new PortalVisibilityFrame();
frame.ResetForBuild();
List<(LoadedCell Cell, float Distance)> todo = frame.TodoScratch;
HashSet<uint> queued = frame.QueuedScratch;
HashSet<uint> drawListed = frame.DrawListedScratch;
Dictionary<uint, int> processedViewCounts = frame.ProcessedViewCountsScratch;
foreach (var cell in candidateCells)
{
if (cell is null) continue;
for (int i = 0; i < cell.Portals.Count; i++)
{
var portal = cell.Portals[i];
if (portal.OtherCellId != 0xFFFF)
continue;
if (i >= cell.PortalPolygons.Count)
continue;
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3)
continue;
// Exterior peering starts from the OUTSIDE face of an exit portal.
// If the camera is on the cell-interior side, the normal indoor
// DrawInside path owns this portal instead. T2 (BR-4): a seed
// portal the eye is IN-PLANE with (|dist| <= F_EPSILON) rejects
// OUTRIGHT — retail ConstructView(CBldPortal) returns 0 on
// Sidedness IN_PLANE (Ghidra 0x005a59a0); no degenerate view is
// ever built from a knife-edge aperture.
if (i < cell.ClipPlanes.Count)
{
if (CameraOnInteriorSide(cell, i, cameraPos))
continue;
if (EyeInPlaneOfPortal(cell, i, cameraPos))
continue;
}
float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
if (seedDistance > maxSeedDistance)
continue;
using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease =
frame.RentClipRegionScratch();
List<ViewPolygon> clippedRegion = clippedRegionLease.Region;
IReadOnlyList<ViewPolygon> activeSeedRegion = seedRegion ?? FullScreenRegion;
ClipPortalAgainstView(
frame,
poly,
cell.WorldTransform,
viewProj,
activeSeedRegion,
0,
activeSeedRegion.Count,
clippedRegion,
out _);
// T2 (BR-4): empty clip = no seed, no exceptions (retail's
// empty-GetClip rule; the full-screen substitute rescue is
// deleted — see Build()).
if (clippedRegion.Count == 0)
continue;
var seedView = GetOrCreate(frame, frame.CellViews, cell.CellId);
bool grew = AddRegion(seedView, clippedRegion);
if (grew && queued.Add(cell.CellId))
InsertTodo(todo, cell, seedDistance);
}
}
// T2 (BR-4): in-place growth propagation — mirrors Build()'s
// ProcessCellPortals (retail AdjustCellView via the watermark); the
// re-enqueue + MaxReprocessPerCell cap and the eye-in-opening rescues
// are deleted (empty clip culls, period).
const int RecursionTripwire = 128;
uint[] propagationChain = frame.PropagationChainScratch; // #120 self-attribution — see Build()
void ProcessCellPortals(LoadedCell cell, int depth)
{
if (depth >= RecursionTripwire)
{
System.Threading.Interlocked.Increment(ref ConvergenceTripwireCount);
Console.WriteLine($"[pv-ERROR] look-in in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate");
DumpPropagationChain(propagationChain, depth, 0u, cameraPos);
return;
}
propagationChain[depth] = cell.CellId;
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
return;
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
int endCount = currentView.Polygons.Count;
if (processedCount >= endCount)
return;
processedViewCounts[cell.CellId] = endCount;
uint lbMask = cell.CellId & 0xFFFF0000u;
for (int i = 0; i < cell.Portals.Count; i++)
{
if (i >= cell.PortalPolygons.Count)
continue;
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3)
continue;
var portal = cell.Portals[i];
if (portal.OtherCellId == 0xFFFF)
continue; // already outdoors; exterior terrain was drawn by the caller.
// R-A2b: cull back portals by the side test alone — see Build().
if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos))
continue;
using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease =
frame.RentClipRegionScratch();
List<ViewPolygon> clippedRegion = clippedRegionLease.Region;
ClipPortalAgainstView(
frame,
poly,
cell.WorldTransform,
viewProj,
currentView.Polygons,
processedCount,
endCount - processedCount,
clippedRegion,
out _);
if (clippedRegion.Count == 0)
continue;
uint neighbourId = lbMask | portal.OtherCellId;
var neighbour = lookup(neighbourId);
if (neighbour == null)
continue;
ApplyReciprocalClip(
frame, clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
if (clippedRegion.Count == 0)
continue;
var nview = GetOrCreate(frame, frame.CellViews, neighbourId);
bool grew = AddRegion(nview, clippedRegion);
if (grew)
{
if (queued.Add(neighbourId))
{
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
InsertTodo(todo, neighbour, dist);
}
else if (drawListed.Contains(neighbourId))
{
ProcessCellPortals(neighbour, depth + 1);
}
}
}
}
while (todo.Count > 0)
{
LoadedCell cell = PopNearest(todo);
if (drawListed.Add(cell.CellId))
frame.OrderedVisibleCells.Add(cell.CellId);
ProcessCellPortals(cell, 0);
}
return frame;
}
/// <summary>
/// Retail per-building flood — <c>PView::ConstructView(CBldPortal*, …)</c> (decomp:433827),
/// reached from <c>BSPPORTAL::portal_draw_portals_only</c> (0x53d870) → <c>DrawPortal</c>
/// (0x5a5ab0) during the terrain BSP walk. Floods ONE building's cells from its outside-facing
/// entrance portal(s). Identical machinery to <see cref="BuildFromExterior"/>, but the CONTRACT is
/// per-building: the caller passes exactly one building's cells, so the seed is that building's
/// FINITE entrance opening (bounded flood depth → the stable ~2-cell view retail draws per visible
/// building, measured live §3.4). This differs from the synthetic outdoor node's single unified
/// flood whose full-screen-ish seed reaches variable depth into a building as the eye moves — the
/// 2↔6 oscillation. Robustness is validated by the conformance test, not assumed.
/// </summary>
public static PortalVisibilityFrame ConstructViewBuilding(
IEnumerable<LoadedCell> buildingCells,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity,
IReadOnlyList<ViewPolygon>? seedRegion = null,
PortalVisibilityFrame? reuseFrame = null)
=> BuildFromExterior(
buildingCells,
cameraPos,
lookup,
viewProj,
maxSeedDistance,
seedRegion,
reuseFrame);
// The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute.
private static readonly Vector2[] FullScreenQuad =
{ new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
private static readonly ViewPolygon[] FullScreenRegion =
{ new ViewPolygon(FullScreenQuad) };
private static void ClipPortalAgainstView(
PortalVisibilityFrame frame,
Vector3[] localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj,
IReadOnlyList<ViewPolygon> viewPolygons,
int viewStart,
int viewCount,
List<ViewPolygon> clippedRegion,
out int clipVertexCount)
{
using PortalProjection.ClipPolygonLease portalClip =
PortalProjection.ProjectToClipLease(localPoly, cellToWorld, viewProj);
clipVertexCount = portalClip.Count;
if (portalClip.Count < 3)
return;
int viewEnd = viewStart + viewCount;
for (int i = viewStart; i < viewEnd; i++)
{
ViewPolygon vp = viewPolygons[i];
if (vp.IsEmpty)
continue;
var clipped = PortalProjection.ClipToRegion(
portalClip.Span, vp.Vertices, frame.PolygonVertices);
if (clipped.Length >= 3)
clippedRegion.Add(new ViewPolygon(clipped));
}
}
private const int PortalTraceEmitLimit = 160;
private static readonly object s_portalTraceLock = new();
private static readonly Dictionary<uint, string> s_portalTraceLastSignature = new();
private static int s_portalTraceEmits;
private sealed class PortalBuildTrace
{
private readonly uint _rootCellId;
private readonly Vector3 _eye;
private readonly List<string> _lines = new();
private PortalBuildTrace(uint rootCellId, Vector3 eye)
{
_rootCellId = rootCellId;
_eye = eye;
}
public static PortalBuildTrace? Start(LoadedCell root, Vector3 eye)
{
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
return null;
if (!IsHoltburgIndoorProbeCell(root.CellId))
return null;
return new PortalBuildTrace(root.CellId, eye);
}
public void Add(string line)
{
if (_lines.Count < 96)
_lines.Add(line);
}
public void Emit(PortalVisibilityFrame frame)
{
string signature = BuildSignature(frame);
lock (s_portalTraceLock)
{
if (s_portalTraceEmits >= PortalTraceEmitLimit)
return;
if (s_portalTraceLastSignature.TryGetValue(_rootCellId, out var last) &&
string.Equals(last, signature, StringComparison.Ordinal))
return;
s_portalTraceLastSignature[_rootCellId] = signature;
s_portalTraceEmits++;
}
Console.WriteLine($"[pv-trace] root=0x{_rootCellId:X8} eye=({_eye.X:F2},{_eye.Y:F2},{_eye.Z:F2}) {signature}");
foreach (var line in _lines)
Console.WriteLine("[pv-trace] " + line);
}
}
private static bool IsHoltburgIndoorProbeCell(uint cellId)
{
if ((cellId & 0xFFFF0000u) != 0xA9B40000u)
return false;
uint low = cellId & 0xFFFFu;
return low >= 0x016F && low <= 0x0175;
}
private static string BuildSignature(PortalVisibilityFrame frame)
{
var sb = new System.Text.StringBuilder(160);
sb.Append("outPolys=").Append(frame.OutsideView.Polygons.Count);
sb.Append(" cells=[");
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
{
if (i != 0) sb.Append(',');
sb.Append("0x").Append((frame.OrderedVisibleCells[i] & 0xFFFFu).ToString("X4"));
}
sb.Append("] views=[");
bool first = true;
foreach (var kvp in frame.CellViews)
{
if (!first) sb.Append(',');
first = false;
sb.Append("0x").Append((kvp.Key & 0xFFFFu).ToString("X4")).Append(':').Append(kvp.Value.Polygons.Count);
}
sb.Append(']');
return sb.ToString();
}
// Phase U.4c flap probe. One [flap] line per Build: the root cell's per-portal
// signed distance D (eye→portal plane), traverse/cull decision, and NDC projection
// vertex count, plus the frame's OutsideView polygon count + visible-cell count.
// `localEye` is the eye in root-local space — its component along an interior portal
// plane reveals when the eye has crossed past that plane (the stale-root region that
// makes the side test cull a still-needed portal). Read-only recompute; no effect on
// the returned frame. Throwaway apparatus — strip with the probe.
private static void EmitFlapProbe(
LoadedCell cameraCell, Vector3 cameraPos, Matrix4x4 viewProj, PortalVisibilityFrame frame)
{
var localEye = Vector3.Transform(cameraPos, cameraCell.InverseWorldTransform);
var sb = new System.Text.StringBuilder(220);
sb.Append("[flap] root=0x").Append(cameraCell.CellId.ToString("X8"));
sb.Append(" eye=(").Append(cameraPos.X.ToString("F2")).Append(',')
.Append(cameraPos.Y.ToString("F2")).Append(',').Append(cameraPos.Z.ToString("F2")).Append(')');
sb.Append(" localEye=(").Append(localEye.X.ToString("F2")).Append(',')
.Append(localEye.Y.ToString("F2")).Append(',').Append(localEye.Z.ToString("F2")).Append(')');
for (int i = 0; i < cameraCell.Portals.Count; i++)
{
var portal = cameraCell.Portals[i];
float d = float.NaN;
bool side = true;
if (i < cameraCell.ClipPlanes.Count && cameraCell.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f)
{
var pl = cameraCell.ClipPlanes[i];
d = Vector3.Dot(pl.Normal, localEye) + pl.D;
side = CameraOnInteriorSide(cameraCell, i, cameraPos);
}
// Replicate the walk's faithful path exactly (ProjectToClip → ClipToRegion(FullScreen)) so
// proj/clip mean the same as production: proj = clip-space verts in front of the eye,
// clip = verts surviving the screen-region clip. clip=0 with proj>=3 ⇒ the portal is
// genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands.
int projN = -1, clipN = -1;
string ndcText = "";
if (i < cameraCell.PortalPolygons.Count)
{
var poly = cameraCell.PortalPolygons[i];
if (poly != null && poly.Length >= 3)
{
var clip = PortalProjection.ProjectToClip(poly, cameraCell.WorldTransform, viewProj);
projN = clip.Length;
if (clip.Length >= 3)
{
var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad);
clipN = ndc.Length;
var ns = new System.Text.StringBuilder(48);
foreach (var v in ndc) ns.Append('(').Append(v.X.ToString("F1")).Append(',').Append(v.Y.ToString("F1")).Append(')');
ndcText = ns.ToString();
}
}
}
sb.Append(" | p").Append(i).Append("->0x").Append(portal.OtherCellId.ToString("X4"));
sb.Append(" D=").Append(float.IsNaN(d) ? "na" : d.ToString("F2"));
sb.Append(side ? " TRV" : " CULL");
sb.Append(" proj=").Append(projN).Append(" clip=").Append(clipN);
if (ndcText.Length > 0) sb.Append(" ndc=").Append(ndcText);
}
sb.Append(" || outPolys=").Append(frame.OutsideView.Polygons.Count);
sb.Append(" vis=").Append(frame.OrderedVisibleCells.Count);
Console.WriteLine(sb.ToString());
}
// Mirrors CellVisibility's portal-side test (InsideSide convention).
// In-plane (|dot| <= PortalSideEpsilon) counts as interior-side — retail
// InitCell leaves the in-plane case a CANDIDATE for cell portals (Ghidra
// 0x005a4b70); building/exterior SEED portals additionally reject in-plane
// via EyeInPlaneOfPortal (retail ConstructView(CBldPortal) IN_PLANE → 0).
private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos)
{
var plane = cell.ClipPlanes[portalIndex];
if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
return plane.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon;
}
// T2 (BR-4): retail ConstructView(CBldPortal)'s Sidedness IN_PLANE reject
// (Ghidra 0x005a59a0): |eye·N + d| <= F_EPSILON → the building/exterior
// portal contributes nothing this frame (knife-edge aperture). Uses the
// true retail epsilon, NOT the side test's root-lag tolerance.
private static bool EyeInPlaneOfPortal(LoadedCell cell, int portalIndex, Vector3 cameraPos)
{
var plane = cell.ClipPlanes[portalIndex];
if (plane.Normal.LengthSquared() < 1e-8f) return false;
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
return MathF.Abs(dot) <= SeedInPlaneEpsilon;
}
// Reverse vertex order in place if the polygon is wound clockwise (signed area < 0).
private static void EnsureCcw(Vector2[] poly)
{
float area2 = 0f;
for (int i = 0; i < poly.Length; i++)
{
var p = poly[i]; var q = poly[(i + 1) % poly.Length];
area2 += p.X * q.Y - q.X * p.Y;
}
if (area2 < 0f) Array.Reverse(poly);
}
// Phase U.2b — reciprocal OtherPortalClip (retail PView::OtherPortalClip decomp:433524).
// Resolves the neighbour's reciprocal back-portal by DIRECT INDEX (`otherPortalId`), projects
// that reciprocal polygon through the NEIGHBOUR's world transform to NDC, and intersects it into
// every polygon of `clippedRegion` (already clipped against the near-side opening + current
// view). The net region is "opening seen from the near cell" ∩ "opening seen from the
// neighbour" — a strict tightening that prevents over-inclusion through skewed apertures.
//
// `otherPortalId` is the near-side portal's reciprocal back-link, straight from the dat's
// CellPortal.OtherPortalId. Retail indexes the neighbour's portal array with it directly —
// `portals->portal[arg2->other_portal_id ...]` at 005a54b2/005a54f6 — rather than scanning for
// the first OtherCellId match. A scan picks the FIRST back-portal for EVERY near-side portal to
// the same neighbour, so a cell with two openings into one neighbour clips both against the same
// (first) reciprocal — hiding the second opening when the apertures are disjoint (under-inclusion
// bug #102 M-4). The direct index gives each opening its own reciprocal.
//
// GUARDS — degrade to over-include (leave `clippedRegion` untouched), NEVER clip against a
// guessed polygon: the index is out of range, OR the indexed polygon is missing/degenerate
// (< 3 verts), OR it projects entirely behind the camera. Over-inclusion is the safe default;
// mis-resolution is the bug this method exists to remove. PortalPolygons is in lockstep with
// Portals, so index `otherPortalId` selects the reciprocal polygon. NEVER throws.
// Dat CellPortal flags bit 0 (DatReaderWriter.Enums.PortalFlags.ExactMatch; retail
// CCellPortal.exact_match at +0x14, acclient.h:32300).
private const ushort PortalFlagExactMatch = 0x0001;
private static void ApplyReciprocalClip(
PortalVisibilityFrame frame,
List<ViewPolygon> clippedRegion, ushort otherPortalId, ushort portalFlags,
LoadedCell neighbour, Matrix4x4 viewProj)
{
if (clippedRegion.Count == 0) return;
// Retail skips OtherPortalClip entirely for exact-match portals — both cells share
// the SAME opening polygon, so re-clipping against the reciprocal can only re-derive
// the near-side clip: PView::ClipPortals decomp:433689
// `if (exact_match != 0 || other_portal_id < 0) goto propagate-without-reciprocal`.
if ((portalFlags & PortalFlagExactMatch) != 0) return;
// Direct back-link index (retail arg2->other_portal_id). Out-of-range → over-include.
if (otherPortalId >= neighbour.PortalPolygons.Count) return;
Vector3[]? reciprocalPoly = neighbour.PortalPolygons[otherPortalId];
if (reciprocalPoly == null || reciprocalPoly.Length < 3) return; // missing/degenerate → over-include
// §4 corner/doorway fix (2026-06-10): the reciprocal clip now runs the SAME homogeneous
// pipeline as the forward clip — retail PView::OtherPortalClip (decomp:433524-433563) routes
// the reciprocal polygon through the very same GetClip(finish=1) → ACRender::polyClipFinish
// homogeneous clipper as the near-side portal; there is no divide-first special case.
//
// HISTORY: this used to be ProjectToNdc + 2D ScreenPolygonClip.Intersect, justified by "the
// reciprocal is a back-portal one hop away — never near the eye". That assumption is FALSE
// exactly at doorways/corners: the reciprocal IS the same opening whose plane the eye presses
// against (2-60 cm). ProjectToNdc's MinW=0.05 eye-clip + side-plane clip + divide is knife-edge
// there — 2 cm eye moves flipped its output between "covers the region" and a duplicated-vertex
// hairline, which CellView.Add's snap-dedup then rejected → the neighbour room dropped from the
// flood for isolated frames → the corner/transition background strobe (CornerFloodReplayTests
// pins this deterministically; the glitch steps die with this change). The old path's other
// rationale — per-round float drift defeating the exact-match CellView dedup — is obsolete:
// CanonicalKey's 1e-3-grid snap dedup (2026-06-06) absorbs re-clip drift by construction.
using PortalProjection.ClipPolygonLease reciprocalClip =
PortalProjection.ProjectToClipLease(
reciprocalPoly,
neighbour.WorldTransform,
viewProj);
if (reciprocalClip.Count < 3) return; // reciprocal entirely behind the eye → no constraint (over-include)
// Intersect the reciprocal opening into each near-side polygon; drop any that fall away.
// ClipToRegion(subject=homogeneous reciprocal, region=near-side NDC polygon) = the same
// region-edge homogeneous Sutherland-Hodgman the forward hop uses (polyClipFinish port).
for (int k = clippedRegion.Count - 1; k >= 0; k--)
{
var tightened = PortalProjection.ClipToRegion(
reciprocalClip.Span,
clippedRegion[k].Vertices,
frame.PolygonVertices);
if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened);
else clippedRegion.RemoveAt(k);
}
}
private static CellView GetOrCreate(
PortalVisibilityFrame frame,
Dictionary<uint, CellView> map,
uint key)
{
if (!map.TryGetValue(key, out var v))
{
v = frame.RentCellView();
map[key] = v;
}
return v;
}
private static bool AddRegion(CellView view, List<ViewPolygon> region)
{
bool grew = false;
foreach (var poly in region)
grew |= view.Add(poly);
return grew;
}
// Camera→nearest-vertex distance for a portal polygon, in world space. Mirrors the per-portal
// min-distance loop retail runs in PView::InitCell (decomp:432988-433004) to key the todo list:
// it walks the portal's vertices, transforms each to world space, and keeps the smallest
// straight-line distance to the camera viewpoint. Keying on the portal opening (not the cell
// origin) is both retail-faithful and robust to cells whose WorldPosition was never populated.
private static float NearestPortalVertexDistance(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 cameraPos)
{
float best = float.MaxValue;
for (int i = 0; i < localPoly.Length; i++)
{
var world = Vector3.Transform(localPoly[i], worldTransform);
float d2 = Vector3.DistanceSquared(world, cameraPos);
if (d2 < best) best = d2;
}
return best == float.MaxValue ? 0f : MathF.Sqrt(best);
}
/// <summary>
/// Distance-sorted work list for the portal BFS, ported from retail PView::cell_todo_list +
/// InsCellTodoList (decomp:433183). Insertion keeps the list ordered so the NEAREST cell sits at
/// the tail; <see cref="PopNearest"/> removes the tail — giving closest-first traversal exactly
/// as ConstructView's pop-from-(cell_todo_num-1) does (433767-433769). The insertion only shifts
/// entries strictly farther than the newcomer (retail's flag test breaks on the first
/// not-greater entry), so an equal-distance newcomer lands at the tail and pops FIRST —
/// LIFO on ties, matching retail's break-on-first-not-greater + pop-from-tail.
/// </summary>
private static void InsertTodo(
List<(LoadedCell Cell, float Distance)> items,
LoadedCell cell,
float distance)
{
int index = items.Count;
while (index > 0 && items[index - 1].Distance < distance)
index--;
items.Insert(index, (cell, distance));
}
private static LoadedCell PopNearest(List<(LoadedCell Cell, float Distance)> items)
{
int last = items.Count - 1;
LoadedCell cell = items[last].Cell;
items.RemoveAt(last);
return cell;
}
}