diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
index d64c097..1ec315a 100644
--- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
+++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
@@ -21,6 +21,13 @@ public sealed class PortalVisibilityFrame
/// Per-cell accumulated clip region, keyed by full cell id (wire-in #2).
public Dictionary CellViews { get; } = new();
+ /// 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.
+ public List OrderedVisibleCells { get; } = new();
+
/// 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).
public Dictionary CrossBuildingViews { get; } = new();
@@ -28,17 +35,6 @@ public sealed class PortalVisibilityFrame
public static class PortalVisibilityBuilder
{
- // Per-cell re-processing bound. NOTE: this cap is the actual termination mechanism for
- // cyclic portal graphs, NOT merely a safety net — the re-enqueue-on-growth guard below is
- // a near-no-op because CellView.Add never dedupes, so a cell almost always "grows". Retail
- // instead converges via an update_count / set_view(...,i) slice watermark (decomp: AddToCell
- // 433050 esi[0x11], InitCell timestamp, AddViewToPortals 433446). Consequences vs retail:
- // (a) a cell reachable through >4 contributing portals under-counts; (b) duplicate polygons
- // accumulate on cyclic/multi-path graphs (correctness survives — stencil marks are idempotent
- // — but it is per-frame cost). Both bite only on dungeon-scale cyclic/hub graphs; a cottage
- // cellar is a short chain where each cell is visited once. The faithful fixpoint port is filed
- // as a fast-follow (docs/ISSUES.md) before A8.F is relied on for dungeons.
- private const int MaxReprocessPerCell = 4;
private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon
// TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the
@@ -66,9 +62,26 @@ public static class PortalVisibilityBuilder
uint lbMask = cameraCell.CellId & 0xFFFF0000u;
frame.CellViews[cameraCell.CellId] = CellView.FullScreen();
- var processCount = new Dictionary();
- var queue = new Queue();
- queue.Enqueue(cameraCell);
+
+ // 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 seen = new HashSet { cameraCell.CellId };
bool pvDump = false;
if (s_pvDump)
@@ -100,15 +113,16 @@ public static class PortalVisibilityBuilder
}
}
- while (queue.Count > 0)
+ while (todo.Count > 0)
{
- var cell = queue.Dequeue();
+ var cell = todo.PopNearest();
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
continue;
- processCount.TryGetValue(cell.CellId, out int pc);
- if (pc >= MaxReprocessPerCell) continue;
- processCount[cell.CellId] = pc + 1;
+ // `seen` guarantees each cell is inserted into the todo list exactly once, so this single
+ // pop IS the cell's closest-first draw position (retail appends to cell_draw_list once per
+ // pop, 433783) — no per-pop dedup needed, OrderedVisibleCells stays distinct by construction.
+ frame.OrderedVisibleCells.Add(cell.CellId);
for (int i = 0; i < cell.Portals.Count; i++)
{
@@ -177,12 +191,23 @@ public static class PortalVisibilityBuilder
var neighbour = lookup(neighbourId);
if (neighbour == null) continue;
- // Union the clipped region into the neighbour's view; (re)enqueue if it grew.
+ // Union the clipped region into the neighbour's accumulated view.
var nview = GetOrCreate(frame.CellViews, neighbourId);
- int before = nview.Polygons.Count;
foreach (var cp in clippedRegion) nview.Add(cp);
- if (nview.Polygons.Count > before)
- queue.Enqueue(neighbour);
+
+ // Insert the neighbour into the distance-priority list — but ONLY on first discovery
+ // (retail enqueues via InsCellTodoList solely in the ecx_5==0 branch; growth into an
+ // already-seen cell is handled in place, never by re-enqueue). `seen` is the
+ // enqueue-once / `cell_view_done` gate: a neighbour already discovered is never
+ // re-enqueued, which is what bounds cyclic & hub graphs. Distance = camera→nearest
+ // portal-opening vertex in world space (retail InitCell min-vertex distance,
+ // 432988-433004); derived from the portal geometry, so it works even when the cell's
+ // WorldPosition was never populated.
+ if (seen.Add(neighbourId))
+ {
+ float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
+ todo.Insert(neighbour, dist);
+ }
}
}
@@ -219,4 +244,55 @@ public static class PortalVisibilityBuilder
if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; }
return v;
}
+
+ // 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);
+ }
+
+ ///
+ /// 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; 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 ties preserve insertion order (stable, FIFO among equal distances).
+ ///
+ 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, FIFO on ties.
+ 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;
+ }
+ }
}
diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs
index 5994b80..af275ec 100644
--- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
@@ -121,6 +122,71 @@ public class PortalVisibilityBuilderTests
Assert.True(frame.OutsideView.Polygons.Count < 256,
$"OutsideView poly count {frame.OutsideView.Polygons.Count} — termination/dedup regression guard");
}
+
+ // -----------------------------------------------------------------------
+ // Phase U.2a: ordered visible-cell list (closest-first) + grow-watermark
+ // fixpoint termination (replaces MaxReprocessPerCell hard cap).
+ // -----------------------------------------------------------------------
+
+ // Straight chain A -> B -> C, camera in A looking down -Z. Each onward portal
+ // is progressively farther in -Z so the camera-to-portal distance is monotonic,
+ // forcing the priority queue to dequeue A, then B, then C in that order.
+ private static (LoadedCell[] cells, Dictionary lookup) SyntheticChain()
+ {
+ const uint A = 0x0001, B = 0x0002, C = 0x0003;
+ var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0));
+ a.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f)); // portal A->B at z=-2 (nearer)
+ var b = Cell(B, new CellPortalInfo((ushort)C, 0, 0));
+ b.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -5f)); // portal B->C at z=-5 (farther)
+ var c = Cell(C, new CellPortalInfo(0xFFFF, 0, 0));
+ c.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -8f)); // exit window
+ var all = new Dictionary { [A] = a, [B] = b, [C] = c };
+ return (new[] { a, b, c }, all);
+ }
+
+ [Fact] // closest-first ordering
+ public void Build_OrdersVisibleCells_ClosestFirst()
+ {
+ var (cells, lookup) = SyntheticChain();
+ var f = PortalVisibilityBuilder.Build(
+ cells[0], Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
+ Assert.Equal(new uint[] { 0x0001, 0x0002, 0x0003 }, f.OrderedVisibleCells.ToArray());
+ }
+
+ // Hub cell with 4 rooms, each room portal-linked BACK to the hub (a cycle on
+ // every spoke). A naive FIFO with no real fixpoint re-enqueues the hub once per
+ // returning spoke and the rooms once per hub re-process — the watermark must
+ // converge instead, bounding the visible set to {hub + 4 rooms} with no dupes.
+ private static (LoadedCell hub, Dictionary lookup) SyntheticCyclicHub()
+ {
+ const uint HUB = 0x0010;
+ uint[] rooms = { 0x0011, 0x0012, 0x0013, 0x0014 };
+ // Hub has one portal to each room; rooms sit at distinct depths so ordering is deterministic.
+ var hub = Cell(HUB,
+ new CellPortalInfo((ushort)rooms[0], 0, 0), new CellPortalInfo((ushort)rooms[1], 1, 0),
+ new CellPortalInfo((ushort)rooms[2], 2, 0), new CellPortalInfo((ushort)rooms[3], 3, 0));
+ for (int i = 0; i < 4; i++)
+ hub.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f - i)); // -2,-3,-4,-5
+ var all = new Dictionary { [HUB] = hub };
+ for (int i = 0; i < 4; i++)
+ {
+ var room = Cell(rooms[i], new CellPortalInfo((ushort)HUB, 0, 0)); // links back to hub → cycle
+ room.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f - i));
+ all[rooms[i]] = room;
+ }
+ return (hub, all);
+ }
+
+ [Fact] // cyclic graph terminates and bounds the visible set
+ public void Build_CyclicHub_TerminatesAndBounds()
+ {
+ var (hub, lookup) = SyntheticCyclicHub();
+ var f = PortalVisibilityBuilder.Build(
+ hub, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
+ Assert.True(f.OrderedVisibleCells.Count <= 5,
+ $"hub + 4 rooms expected, got {f.OrderedVisibleCells.Count} — fixpoint failed to converge");
+ Assert.Equal(f.OrderedVisibleCells.Count, f.OrderedVisibleCells.Distinct().Count()); // no dup cells
+ }
}
internal static class PortalFrameTestHelper