Investigation: retail's growth propagation RECURSES natively too (AddViewToPortals -> FixCellList -> AdjustCellView -> AddViewToPortals, Ghidra 0x005a52d0/0x005a5250/0x005a5770, no depth guard) - the in-place recursion shape is faithful; retail's safety is fast convergence. Our depth-128 firing means slow/non-saturating growth (each lap of a portal cycle nests one recursion level), not necessarily a true infinite loop. Two dat-backed sweeps over the corner-building cell set could NOT reproduce the T5 firing: - PortalPlaneCrossings_InPlacePropagationConverges: +/-6cm eye sweep across every portal plane, seeded from both sides. - InCellDirectionSweep_InPlacePropagationConverges: 3024 builds, in-cell eye grid x 8 yaw x 3 pitch (the walking-and-turning regime). Both pass with 0 firings -> production-only ingredients suspected (full lookup graph - one T5 firing was 0x0162, another building - and/or the real camera path). Armed: PortalVisibilityBuilder.ConvergenceTripwireCount (test observable, both Build + look-in sites) + DumpPropagationChain - on the next firing the log carries root cell, eye, per-cell frequency summary, and the 24-entry chain tail, so the cycle's structure (A<->B ping-pong vs 3-cycle laps) reads directly off the output. Both sweeps stay as regression pins. App tests: 227 green (was 225; +2 pins). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1070 lines
57 KiB
C#
1070 lines
57 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
|
||
{
|
||
/// <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; } = 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();
|
||
}
|
||
|
||
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}");
|
||
}
|
||
|
||
/// <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>
|
||
public static PortalVisibilityFrame Build(
|
||
LoadedCell cameraCell,
|
||
Vector3 cameraPos,
|
||
Func<uint, LoadedCell?> lookup,
|
||
Matrix4x4 viewProj,
|
||
Func<uint, bool>? buildingMembership = null)
|
||
{
|
||
var frame = new PortalVisibilityFrame();
|
||
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] = CellView.FullScreen();
|
||
|
||
// 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(new ViewPolygon((Vector2[])FullScreenQuad.Clone()));
|
||
|
||
// 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.
|
||
var todo = new CellTodoList();
|
||
todo.Insert(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.
|
||
var queued = new HashSet<uint> { cameraCell.CellId };
|
||
var drawListed = new HashSet<uint>();
|
||
var processedViewCounts = new Dictionary<uint, int>();
|
||
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 BuildLoadedCell), 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.
|
||
var propagationChain = new uint[RecursionTripwire];
|
||
|
||
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}");
|
||
|
||
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
|
||
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;
|
||
bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos);
|
||
|
||
// (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 B1 (eyeInsideOpening bypasses the
|
||
// side-cull) vs B2 (CameraOnInteriorSide returns interior where retail's InitCell culls).
|
||
// |D|<=1.75 means eyeInsideOpening is in range. 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} eyeIn={eyeInsideOpening} D={(float.IsNaN(sideD) ? "na" : sideD.ToString("F2"))}");
|
||
}
|
||
|
||
bool sideAllowed = true;
|
||
|
||
// 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). The forward-portal clip-empty void rescue
|
||
// (below, the `clippedRegion.Count == 0` branch) is a SEPARATE path and stays.
|
||
if (i < cell.ClipPlanes.Count
|
||
&& !CameraOnInteriorSide(cell, i, cameraPos))
|
||
{
|
||
sideAllowed = false;
|
||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side eyeIn={eyeInsideOpening}");
|
||
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.
|
||
var clippedRegion = ClipPortalAgainstView(
|
||
poly,
|
||
cell.WorldTransform,
|
||
viewProj,
|
||
activeViewPolygons,
|
||
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}");
|
||
|
||
// T2 (BR-4) attempted to delete this eye-in-opening rescue as
|
||
// non-retail (retail's empty GetClip = no flood, no bypass) and
|
||
// the CornerFloodReplay conformance gate REFUTED the deletion:
|
||
// with the eye pressed at the 0x0172 corner, the 0x0173/0x0171
|
||
// doorway chain clipped EMPTY at every sweep step — our
|
||
// ProjectToClip near-eye behavior (EyePlaneW=1e-4) diverges from
|
||
// retail polyClipFinish's near-W clip at its UNPINNED constant
|
||
// cdstW (comparison doc open question). Until cdstW is read from
|
||
// the binary and our near-eye clip matched to it, this rescue is
|
||
// the documented compensation for that gap: a portal whose
|
||
// opening the eye stands in (≤1.75 m perp + inside the opening)
|
||
// substitutes the current view. Re-attempt the deletion ONLY
|
||
// against the corner harness after pinning cdstW.
|
||
if (clippedRegion.Count == 0)
|
||
{
|
||
if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos))
|
||
{
|
||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=clip-empty side={sideAllowed} eyeIn={eyeInsideOpening} clipVerts={clipVerts}");
|
||
continue;
|
||
}
|
||
foreach (var vp in activeViewPolygons)
|
||
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
|
||
}
|
||
|
||
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.
|
||
AddRegion(frame.OutsideView, clippedRegion);
|
||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts} eyeIn={eyeInsideOpening}");
|
||
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.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(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.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);
|
||
todo.Insert(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)
|
||
{
|
||
var cell = todo.PopNearest();
|
||
|
||
// 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>
|
||
public static PortalVisibilityFrame BuildFromExterior(
|
||
IEnumerable<LoadedCell> candidateCells,
|
||
Vector3 cameraPos,
|
||
Func<uint, LoadedCell?> lookup,
|
||
Matrix4x4 viewProj,
|
||
float maxSeedDistance = float.PositiveInfinity)
|
||
{
|
||
var frame = new PortalVisibilityFrame();
|
||
var todo = new CellTodoList();
|
||
var queued = new HashSet<uint>();
|
||
var drawListed = new HashSet<uint>();
|
||
var processedViewCounts = new Dictionary<uint, int>();
|
||
|
||
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;
|
||
|
||
var clippedRegion = ClipPortalAgainstView(
|
||
poly,
|
||
cell.WorldTransform,
|
||
viewProj,
|
||
FullScreenRegion,
|
||
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.CellViews, cell.CellId);
|
||
bool grew = AddRegion(seedView, clippedRegion);
|
||
|
||
if (grew && queued.Add(cell.CellId))
|
||
todo.Insert(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;
|
||
var propagationChain = new uint[RecursionTripwire]; // #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;
|
||
|
||
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
|
||
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;
|
||
|
||
var clippedRegion = ClipPortalAgainstView(
|
||
poly,
|
||
cell.WorldTransform,
|
||
viewProj,
|
||
activeViewPolygons,
|
||
out _);
|
||
|
||
if (clippedRegion.Count == 0)
|
||
continue;
|
||
|
||
uint neighbourId = lbMask | portal.OtherCellId;
|
||
var neighbour = lookup(neighbourId);
|
||
if (neighbour == null)
|
||
continue;
|
||
|
||
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
|
||
if (clippedRegion.Count == 0)
|
||
continue;
|
||
|
||
var nview = GetOrCreate(frame.CellViews, neighbourId);
|
||
bool grew = AddRegion(nview, clippedRegion);
|
||
|
||
if (grew)
|
||
{
|
||
if (queued.Add(neighbourId))
|
||
{
|
||
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||
todo.Insert(neighbour, dist);
|
||
}
|
||
else if (drawListed.Contains(neighbourId))
|
||
{
|
||
ProcessCellPortals(neighbour, depth + 1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
while (todo.Count > 0)
|
||
{
|
||
var cell = todo.PopNearest();
|
||
|
||
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)
|
||
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance);
|
||
|
||
// 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 List<ViewPolygon> ClipPortalAgainstView(
|
||
Vector3[] localPoly,
|
||
Matrix4x4 cellToWorld,
|
||
Matrix4x4 viewProj,
|
||
IReadOnlyList<ViewPolygon> viewPolygons,
|
||
out int clipVertexCount)
|
||
{
|
||
var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj);
|
||
clipVertexCount = portalClip.Length;
|
||
var clippedRegion = new List<ViewPolygon>();
|
||
if (portalClip.Length < 3)
|
||
return clippedRegion;
|
||
|
||
foreach (var vp in viewPolygons)
|
||
{
|
||
if (vp.IsEmpty)
|
||
continue;
|
||
|
||
var clipped = PortalProjection.ClipToRegion(portalClip, vp.Vertices);
|
||
if (clipped.Length >= 3)
|
||
clippedRegion.Add(new ViewPolygon(clipped));
|
||
}
|
||
|
||
return clippedRegion;
|
||
}
|
||
|
||
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(
|
||
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.
|
||
var reciprocalClip = PortalProjection.ProjectToClip(reciprocalPoly, neighbour.WorldTransform, viewProj);
|
||
if (reciprocalClip.Length < 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, clippedRegion[k].Vertices);
|
||
if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened);
|
||
else clippedRegion.RemoveAt(k);
|
||
}
|
||
}
|
||
|
||
private static CellView GetOrCreate(Dictionary<uint, CellView> map, uint key)
|
||
{
|
||
if (!map.TryGetValue(key, out var v)) { v = new CellView(); 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);
|
||
}
|
||
|
||
// "Eye standing in the opening": the eye is within this perpendicular distance of a portal's
|
||
// plane. Live captures hit two retail-valid degenerate cases: cottage doorway D=0.16 m and
|
||
// cellar->stair portal D=1.41 m, both traversable but ProjectToNdc returned zero vertices. We still
|
||
// require the perpendicular projection to land inside the opening, so side/offscreen portals stay
|
||
// culled; this only covers active portals whose 2D projection collapses near the chase camera.
|
||
private const float EyeStandingPerpDist = 1.75f;
|
||
|
||
/// <summary>
|
||
/// True when the camera eye is "standing in" <paramref name="localPoly"/>'s opening: within
|
||
/// <see cref="EyeStandingPerpDist"/> of the portal plane AND its perpendicular projection onto
|
||
/// that plane falls inside the portal polygon. This is the case where the 2D portal projection
|
||
/// degenerates to empty (the eye is in the doorway plane) yet the neighbour is genuinely visible
|
||
/// — retail's 3D portal clip imposes no constraint there. Used only as the gate that lets such a
|
||
/// portal flood its neighbour with the current view; a degenerate portal the eye is NOT inside
|
||
/// (off-screen / across the room) returns false and stays culled, so the visible set cannot blow up.
|
||
/// </summary>
|
||
private static bool EyeInsidePortalOpening(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 eyeWorld)
|
||
{
|
||
if (localPoly == null || localPoly.Length < 3) return false;
|
||
var p0 = Vector3.Transform(localPoly[0], worldTransform);
|
||
var p1 = Vector3.Transform(localPoly[1], worldTransform);
|
||
var p2 = Vector3.Transform(localPoly[2], worldTransform);
|
||
var n = Vector3.Cross(p1 - p0, p2 - p0);
|
||
float nl = n.Length();
|
||
if (nl < 1e-8f) return false; // degenerate polygon — no plane
|
||
n /= nl;
|
||
float perp = Vector3.Dot(n, eyeWorld - p0);
|
||
if (MathF.Abs(perp) > EyeStandingPerpDist) return false; // eye not close to the portal plane
|
||
|
||
// In-plane 2D basis (u along the first edge, v = n × u). Project the eye + every vertex into
|
||
// it (the perpendicular component drops out of the dot products) and run a point-in-polygon test.
|
||
var u = p1 - p0;
|
||
float ul = u.Length();
|
||
if (ul < 1e-8f) return false;
|
||
u /= ul;
|
||
var v = Vector3.Cross(n, u);
|
||
var rel = eyeWorld - p0;
|
||
var eye2 = new Vector2(Vector3.Dot(rel, u), Vector3.Dot(rel, v));
|
||
var poly2 = new Vector2[localPoly.Length];
|
||
for (int k = 0; k < localPoly.Length; k++)
|
||
{
|
||
var w = Vector3.Transform(localPoly[k], worldTransform) - p0;
|
||
poly2[k] = new Vector2(Vector3.Dot(w, u), Vector3.Dot(w, v));
|
||
}
|
||
return PointInPoly2D(eye2, poly2);
|
||
}
|
||
|
||
// Standard ray-crossing (even-odd) point-in-polygon test.
|
||
private static bool PointInPoly2D(Vector2 p, Vector2[] poly)
|
||
{
|
||
bool inside = false;
|
||
for (int i = 0, j = poly.Length - 1; i < poly.Length; j = i++)
|
||
{
|
||
var a = poly[i];
|
||
var b = poly[j];
|
||
if (((a.Y > p.Y) != (b.Y > p.Y)) &&
|
||
(p.X < (b.X - a.X) * (p.Y - a.Y) / (b.Y - a.Y) + a.X))
|
||
inside = !inside;
|
||
}
|
||
return inside;
|
||
}
|
||
|
||
/// <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 sealed class CellTodoList
|
||
{
|
||
private readonly List<(LoadedCell Cell, float Distance)> _items = new();
|
||
|
||
public int Count => _items.Count;
|
||
|
||
public void Insert(LoadedCell cell, float distance)
|
||
{
|
||
// Find the slot: scan from the tail (nearest) toward the head while existing entries are
|
||
// strictly nearer than `distance`, so the newcomer lands just ABOVE every entry that is
|
||
// farther-or-equal — i.e. nearest-at-tail order, LIFO on ties (an equal-distance
|
||
// newcomer inserts at the tail and pops first).
|
||
int idx = _items.Count;
|
||
while (idx > 0 && _items[idx - 1].Distance < distance)
|
||
idx--;
|
||
_items.Insert(idx, (cell, distance));
|
||
}
|
||
|
||
public LoadedCell PopNearest()
|
||
{
|
||
int last = _items.Count - 1;
|
||
var cell = _items[last].Cell;
|
||
_items.RemoveAt(last);
|
||
return cell;
|
||
}
|
||
}
|
||
}
|