# Phase A8.F — Retail portal-frame visibility port — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Fix the A8 "cellar flap" (outdoor terrain leaking through ground-floor windows when the camera is in a cottage cellar) by porting retail's recursive portal-clip visibility and feeding its recursively-clipped output into the existing A8 stencil pipeline. **Architecture:** A GL-free CPU builder walks the portal graph from the camera cell (BFS), projecting each portal opening to screen space and intersecting it with the inherited clip region; exit-portal regions union into an `OutsideView` (where outdoors may draw, clipped to the portal chain). Enforcement maps onto the existing A8 stencil pipeline (mark + far-depth punch), now fed the recursively-clipped polygons instead of a flat union of all exit portals. Three wire-ins land together (terrain region, per-cell geometry, cross-building) plus the Job-A/Job-B decoupling, behind one visual gate. **Tech Stack:** C# .NET 10, System.Numerics (CPU layer, GL-free, unit-tested), Silk.NET.OpenGL (enforcement). Spec: [docs/superpowers/specs/2026-05-29-phase-a8f-portal-frame-visibility-design.md](../specs/2026-05-29-phase-a8f-portal-frame-visibility-design.md). Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt`. **Fidelity note:** We match retail's *behavior and structure* (the OutsideView regions), not its exact float output. Conformance asserts clipping *relationships* (a deep cell's exit region ⊆ the intersection of its portal-chain openings), per the spec. A correct convex-polygon intersection is therefore faithful; we are not bit-matching retail's `polyClipFinish`. --- ## File structure **New (pure CPU, GL-free — `src/AcDream.App/Rendering/`, beside `CellVisibility`):** - `PortalView.cs` — `ViewPolygon` (2D NDC convex polygon + bbox) and `CellView` (set of view polygons + union bbox). Data model; mirrors retail `view_poly`/`view_type`. - `ScreenPolygonClip.cs` — `Intersect(subject, clip)`: 2D convex-polygon intersection (Sutherland–Hodgman). Port of retail `ACRender::polyClipFinish` behavior. - `PortalProjection.cs` — `ProjectToNdc(localPoly, cellToWorld, viewProj)`: project a portal polygon to NDC with in-front-of-camera (homogeneous-w) clipping to prevent inversion. - `PortalVisibilityBuilder.cs` — `Build(cameraCell, lookup, viewProj)`: the BFS; produces `PortalVisibilityFrame { OutsideView, CellViews, CrossBuildingViews }`. Port of retail `PView::ConstructView`/`ClipPortals`/`AddViewToPortals`. **Modified:** - `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` — add `MarkAndPunchNdc(region)` (NDC-polygon stencil mark + far-depth punch). The flat `PortalMeshBuilder.BuildTriangles` path is superseded. - `src/AcDream.App/Rendering/GameWindow.cs` — rewrite `RenderInsideOutAcdream` (~11012) to drive from the builder, restore WB's unconditional-exterior structure, and apply the three wire-ins. Remove `ACDREAM_A8_DIAG_*` (Task 0). - `src/AcDream.App/RuntimeOptions.cs` — remove `A8Diag*` properties (Task 0). - `docs/plans/2026-04-11-roadmap.md` — A8.F shipped row (Task 9). **New tests (`tests/AcDream.App.Tests/Rendering/`):** - `PortalViewTests.cs`, `ScreenPolygonClipTests.cs`, `PortalProjectionTests.cs`, `PortalVisibilityBuilderTests.cs`. --- ## Task 0: Strip leftover A8 diag flags (baseline cleanup) The A8 batch is already committed (`5dc4140`). This removes the temporary step-disable flags it left behind. **Keep** the `ACDREAM_PROBE_VIS` apparatus. **Files:** - Modify: `src/AcDream.App/RuntimeOptions.cs` (the `A8Diag*` properties, ~18 occurrences) - Modify: `src/AcDream.App/Rendering/GameWindow.cs` (`diagDisableStep*` locals + their use sites in `RenderInsideOutAcdream`, ~6 occurrences) - [ ] **Step 1: Find every flag reference** Run: `rg "A8Diag|ACDREAM_A8_DIAG" src` (expect hits in `RuntimeOptions.cs` and `GameWindow.cs` only) - [ ] **Step 2: Remove the `A8Diag*` properties from `RuntimeOptions.cs`** Delete each `public bool A8DiagDisable... { get; }` property and its `Environment.GetEnvironmentVariable("ACDREAM_A8_DIAG_...")` initializer. Leave all non-`A8Diag` options untouched. - [ ] **Step 3: Remove the `diagDisable*` locals + guards in `RenderInsideOutAcdream`** In `GameWindow.cs`, delete the `bool diagDisableStep... = _options.A8Diag...;` locals (lines ~11029-11034) and unwrap the `if (!diagDisableStepX...)` guards so the guarded draw always runs (the draw bodies stay; only the `if (!diag...)` wrapper and its `else` diagnostic branch are removed). For the Step-4 terrain `FrontFace` block, keep the `gl.FrontFace(Ccw)` / draw / `gl.FrontFace(CW)` sequence; drop only the `diagDisableStep4Terrain` branch. - [ ] **Step 4: Build + test green** Run: `dotnet build` Expected: Build succeeded, 0 errors. Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` Expected: PASS (App baseline ~90). - [ ] **Step 5: Commit** ```bash git add src/AcDream.App/RuntimeOptions.cs src/AcDream.App/Rendering/GameWindow.cs git commit -m "chore(render): Phase A8.F — strip ACDREAM_A8_DIAG_* step-disable flags (keep PROBE_VIS)" ``` --- ## Task 1: `ViewPolygon` + `CellView` data model **Files:** - Create: `src/AcDream.App/Rendering/PortalView.cs` - Test: `tests/AcDream.App.Tests/Rendering/PortalViewTests.cs` - [ ] **Step 1: Write the failing test** ```csharp // tests/AcDream.App.Tests/Rendering/PortalViewTests.cs using System.Numerics; using AcDream.App.Rendering; using Xunit; namespace AcDream.App.Tests.Rendering; public class PortalViewTests { [Fact] public void ViewPolygon_ComputesBoundingRect() { var p = new ViewPolygon(new[] { new Vector2(-0.5f, -0.25f), new Vector2(0.5f, -0.25f), new Vector2(0.0f, 0.75f), }); Assert.Equal(-0.5f, p.MinX, 5); Assert.Equal(0.5f, p.MaxX, 5); Assert.Equal(-0.25f, p.MinY, 5); Assert.Equal(0.75f, p.MaxY, 5); Assert.False(p.IsEmpty); } [Fact] public void ViewPolygon_FewerThanThreeVerts_IsEmpty() { Assert.True(new ViewPolygon(new[] { new Vector2(0, 0), new Vector2(1, 0) }).IsEmpty); Assert.True(new ViewPolygon(System.Array.Empty()).IsEmpty); } [Fact] public void CellView_FullScreen_CoversNdc() { var v = CellView.FullScreen(); Assert.False(v.IsEmpty); Assert.Equal(-1f, v.MinX, 5); Assert.Equal(1f, v.MaxX, 5); Assert.Equal(-1f, v.MinY, 5); Assert.Equal(1f, v.MaxY, 5); } [Fact] public void CellView_Add_GrowsUnionBoundsAndIsEmptyTracks() { var v = new CellView(); Assert.True(v.IsEmpty); v.Add(new ViewPolygon(new[] { new Vector2(0, 0), new Vector2(0.2f, 0), new Vector2(0, 0.2f) })); v.Add(new ViewPolygon(new[] { new Vector2(-0.3f, -0.3f), new Vector2(-0.1f, -0.3f), new Vector2(-0.1f, -0.1f) })); Assert.False(v.IsEmpty); Assert.Equal(-0.3f, v.MinX, 5); Assert.Equal(0.2f, v.MaxX, 5); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalViewTests"` Expected: FAIL (compile error — `ViewPolygon`/`CellView` not defined). - [ ] **Step 3: Implement `PortalView.cs`** ```csharp // src/AcDream.App/Rendering/PortalView.cs // // Phase A8.F: GL-free 2D screen-space (NDC) clip-region data model. // Mirrors retail view_poly (acclient.h:32465) and view_type (acclient.h:32338): // a cell's clip region is a SET of convex polygons in normalized device coords. using System.Collections.Generic; using System.Numerics; namespace AcDream.App.Rendering; /// One convex polygon in NDC screen space (xy in [-1,1]), plus its bounding rect. public readonly struct ViewPolygon { public readonly Vector2[] Vertices; public readonly float MinX, MinY, MaxX, MaxY; public ViewPolygon(Vector2[] vertices) { Vertices = vertices; if (vertices is null || vertices.Length < 3) { MinX = MinY = MaxX = MaxY = 0f; return; } float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue; foreach (var v in vertices) { if (v.X < minX) minX = v.X; if (v.X > maxX) maxX = v.X; if (v.Y < minY) minY = v.Y; if (v.Y > maxY) maxY = v.Y; } MinX = minX; MinY = minY; MaxX = maxX; MaxY = maxY; } public bool IsEmpty => Vertices is null || Vertices.Length < 3; } /// A cell's accumulated clip region: a set of convex view polygons + the union bounding rect. public sealed class CellView { public readonly List Polygons = new(); public float MinX { get; private set; } = float.MaxValue; public float MinY { get; private set; } = float.MaxValue; public float MaxX { get; private set; } = float.MinValue; public float MaxY { get; private set; } = float.MinValue; public bool IsEmpty => Polygons.Count == 0; /// A region covering the entire NDC viewport — the camera cell's seed region /// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814). public static CellView FullScreen() { var v = new CellView(); v.Add(new ViewPolygon(new[] { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f), })); return v; } public void Add(ViewPolygon p) { if (p.IsEmpty) return; Polygons.Add(p); if (p.MinX < MinX) MinX = p.MinX; if (p.MinY < MinY) MinY = p.MinY; if (p.MaxX > MaxX) MaxX = p.MaxX; if (p.MaxY > MaxY) MaxY = p.MaxY; } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalViewTests"` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash git add src/AcDream.App/Rendering/PortalView.cs tests/AcDream.App.Tests/Rendering/PortalViewTests.cs git commit -m "feat(render): Phase A8.F — ViewPolygon + CellView clip-region data model" ``` --- ## Task 2: `ScreenPolygonClip` — 2D convex-polygon intersection Sutherland–Hodgman: clip the subject convex polygon against each edge of the (convex) clip polygon, treating each edge as a half-plane (keep the left side for CCW winding). Port of retail `ACRender::polyClipFinish` behavior. **Files:** - Create: `src/AcDream.App/Rendering/ScreenPolygonClip.cs` - Test: `tests/AcDream.App.Tests/Rendering/ScreenPolygonClipTests.cs` - [ ] **Step 1: Write the failing test** ```csharp // tests/AcDream.App.Tests/Rendering/ScreenPolygonClipTests.cs using System.Numerics; using AcDream.App.Rendering; using Xunit; namespace AcDream.App.Tests.Rendering; public class ScreenPolygonClipTests { // CCW unit square [-1,1]^2. private static Vector2[] Square(float min, float max) => new[] { new Vector2(min, min), new Vector2(max, min), new Vector2(max, max), new Vector2(min, max), }; private static float Area(Vector2[] poly) { if (poly.Length < 3) return 0f; float a = 0f; for (int i = 0; i < poly.Length; i++) { var p = poly[i]; var q = poly[(i + 1) % poly.Length]; a += p.X * q.Y - q.X * p.Y; } return System.MathF.Abs(a) * 0.5f; } [Fact] public void Intersect_FullyContained_ReturnsSubject() { var outer = Square(-1f, 1f); var inner = Square(-0.5f, 0.5f); var r = ScreenPolygonClip.Intersect(inner, outer); Assert.Equal(1.0f, Area(r), 3); // inner area = 1x1 } [Fact] public void Intersect_PartialOverlap_ReturnsOverlapArea() { var a = Square(0f, 2f); var b = Square(1f, 3f); var r = ScreenPolygonClip.Intersect(a, b); Assert.Equal(1.0f, Area(r), 3); // overlap [1,2]^2 = 1 } [Fact] public void Intersect_Disjoint_ReturnsEmpty() { var a = Square(0f, 1f); var b = Square(5f, 6f); var r = ScreenPolygonClip.Intersect(a, b); Assert.True(r.Length < 3); // empty } [Fact] public void Intersect_Sliver_PreservesNarrowOverlap() { // A tall thin clip (the "stairwell") intersected with a wide subject (the "window"). var window = Square(-1f, 1f); var stairwell = new[] { new Vector2(-0.1f, -1f), new Vector2(0.1f, -1f), new Vector2(0.1f, 1f), new Vector2(-0.1f, 1f), }; var r = ScreenPolygonClip.Intersect(window, stairwell); Assert.Equal(0.4f, Area(r), 3); // 0.2 wide x 2 tall Assert.True(System.MathF.Abs(r[0].X) <= 0.1001f); // clipped to stairwell width } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ScreenPolygonClipTests"` Expected: FAIL (compile error — `ScreenPolygonClip` not defined). - [ ] **Step 3: Implement `ScreenPolygonClip.cs`** ```csharp // src/AcDream.App/Rendering/ScreenPolygonClip.cs // // Phase A8.F: 2D convex-polygon intersection (Sutherland-Hodgman). // Ports the BEHAVIOR of retail ACRender::polyClipFinish (the screen-space // portal clipper invoked by PView::GetClip, decomp:432344). Both inputs are // convex (a cell's accumulated view polygon and a projected portal polygon), // so the result is convex. We match retail's clipping relationship, not its // exact float output (see plan fidelity note). using System.Collections.Generic; using System.Numerics; namespace AcDream.App.Rendering; public static class ScreenPolygonClip { private const float Eps = 1e-7f; /// Intersect two convex polygons given CCW. Returns the clipped /// vertices (CCW) or an array with <3 verts when the intersection is empty. public static Vector2[] Intersect(IReadOnlyList subject, IReadOnlyList clip) { if (subject == null || clip == null || subject.Count < 3 || clip.Count < 3) return System.Array.Empty(); var output = new List(subject); for (int i = 0; i < clip.Count; i++) { if (output.Count < 3) return System.Array.Empty(); Vector2 a = clip[i]; Vector2 b = clip[(i + 1) % clip.Count]; output = ClipByEdge(output, a, b); } return output.Count >= 3 ? output.ToArray() : System.Array.Empty(); } // Keep the part of `poly` on the left of directed edge a->b (CCW inside). private static List ClipByEdge(List poly, Vector2 a, Vector2 b) { var result = new List(poly.Count + 1); Vector2 edge = b - a; for (int i = 0; i < poly.Count; i++) { Vector2 cur = poly[i]; Vector2 prev = poly[(i + poly.Count - 1) % poly.Count]; float curSide = Cross(edge, cur - a); // > 0 = left (inside) float prevSide = Cross(edge, prev - a); bool curIn = curSide >= -Eps; bool prevIn = prevSide >= -Eps; if (curIn) { if (!prevIn) result.Add(Intersection(prev, cur, prevSide, curSide)); result.Add(cur); } else if (prevIn) { result.Add(Intersection(prev, cur, prevSide, curSide)); } } return result; } private static float Cross(Vector2 u, Vector2 v) => u.X * v.Y - u.Y * v.X; private static Vector2 Intersection(Vector2 p, Vector2 q, float dp, float dq) { float t = dp / (dp - dq); // dp, dq are signed distances (cross products); opposite signs here return p + t * (q - p); } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ScreenPolygonClipTests"` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash git add src/AcDream.App/Rendering/ScreenPolygonClip.cs tests/AcDream.App.Tests/Rendering/ScreenPolygonClipTests.cs git commit -m "feat(render): Phase A8.F — ScreenPolygonClip 2D convex-polygon intersection" ``` --- ## Task 3: `PortalProjection` — project portal polygon to NDC with near-clip > **Correction (2026-05-29, during execution):** the `w >= WEps` clip in the Step-3 code > below was a bug — it leaves a clipped vertex at the eye singularity (w≈1e-4) so `x/w` blows > up (~±7852), relocating the inversion instead of preventing it. The shipped code clips > against the in-front-of-camera half-space `w + z >= 0` (commit `a28a176`; comments corrected > in `9ec8330`). That predicate is convention-agnostic: acdream's cameras use > `Matrix4x4.CreatePerspectiveFieldOfView` (NDC z ∈ **[0,1]**, not GL [-1,1]); the eye (w=0) > is always excluded so the divide is safe — and **Task 6 needs no projection-convention fix** > (verified: no `glClipControl`/`glDepthRange` remap anywhere in the codebase). The straddle > test bound was relaxed `[-10,10]`→`[-50,50]` and a 4th downstream-intersection test added. > **Task 4 requirement:** `ProjectToNdc` preserves input winding (NOT normalized CCW) — the > builder MUST apply the portal-side test and feed camera-facing (CCW) portals to the CCW-only > `ScreenPolygonClip`, and Task 4's conformance tests should assert a winding/back-face case. Transform a cell-local portal polygon to clip space, clip against the in-front-of-camera plane (`w >= eps`) to prevent inversion when a portal straddles the camera, then perspective-divide to NDC. Ports the projection in retail `PView::GetClip` (decomp:432344); the `w` clip is the homogeneous form of retail's near-plane sidedness math (`PView::ConstructView(CBldPortal)` decomp:433832-433845). **Files:** - Create: `src/AcDream.App/Rendering/PortalProjection.cs` - Test: `tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs` - [ ] **Step 1: Write the failing test** ```csharp // tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs using System.Numerics; using AcDream.App.Rendering; using Xunit; namespace AcDream.App.Tests.Rendering; public class PortalProjectionTests { // A simple GL-style perspective looking down -Z, camera at origin. private static Matrix4x4 ViewProj() { var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY); var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.0f, 1.0f, 0.1f, 1000f); return view * proj; } [Fact] public void Project_QuadInFront_ProducesNdcInsideViewport() { // A 2x2 quad at z=-5 (in front), cell-local == world (identity transform). var poly = new[] { new Vector3(-1, -1, -5), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, -5), }; var r = PortalProjection.ProjectToNdc(poly, Matrix4x4.Identity, ViewProj()); Assert.True(r.Length >= 3); foreach (var v in r) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); } } [Fact] public void Project_QuadFullyBehind_ReturnsEmpty() { var poly = new[] { new Vector3(-1, -1, 5), new Vector3(1, -1, 5), new Vector3(1, 1, 5), new Vector3(-1, 1, 5), }; var r = PortalProjection.ProjectToNdc(poly, Matrix4x4.Identity, ViewProj()); Assert.True(r.Length < 3); } [Fact] public void Project_QuadStraddlingCamera_ClipsWithoutInversion() { // Spans from behind (z=+2) to in front (z=-5). Must clip to the in-front part, // never produce a wildly out-of-range inverted vertex. var poly = new[] { new Vector3(-1, -1, 2), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, 2), }; var r = PortalProjection.ProjectToNdc(poly, Matrix4x4.Identity, ViewProj()); Assert.True(r.Length >= 3); foreach (var v in r) { Assert.InRange(v.X, -10f, 10f); // bounded — no inversion blow-up Assert.InRange(v.Y, -10f, 10f); } } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalProjectionTests"` Expected: FAIL (compile error — `PortalProjection` not defined). - [ ] **Step 3: Implement `PortalProjection.cs`** ```csharp // src/AcDream.App/Rendering/PortalProjection.cs // // Phase A8.F: project a cell-local portal polygon to NDC screen space, clipping // against the in-front-of-camera plane (w >= eps) so a portal straddling the // camera does not invert under the perspective divide. Homogeneous form of the // near-plane sidedness in retail PView::GetClip / ConstructView(CBldPortal) // (decomp:432344 / 433832). using System.Collections.Generic; using System.Numerics; namespace AcDream.App.Rendering; public static class PortalProjection { private const float WEps = 1e-4f; /// Project a cell-local polygon to NDC. Returns CCW NDC xy verts, or /// fewer than 3 verts when the polygon is entirely behind the camera / degenerate. public static Vector2[] ProjectToNdc(IReadOnlyList localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj) { if (localPoly == null || localPoly.Count < 3) return System.Array.Empty(); Matrix4x4 m = cellToWorld * viewProj; // To clip space (keep w). var clip = new List(localPoly.Count); foreach (var lp in localPoly) clip.Add(Vector4.Transform(new Vector4(lp, 1f), m)); // Clip against w >= WEps (in front of camera). clip = ClipAgainstW(clip); if (clip.Count < 3) return System.Array.Empty(); // Perspective divide → NDC xy. var ndc = new Vector2[clip.Count]; for (int i = 0; i < clip.Count; i++) { float w = clip[i].W; ndc[i] = new Vector2(clip[i].X / w, clip[i].Y / w); } return ndc; } // Sutherland-Hodgman against the single plane w = WEps. private static List ClipAgainstW(List poly) { var result = new List(poly.Count + 1); for (int i = 0; i < poly.Count; i++) { Vector4 cur = poly[i]; Vector4 prev = poly[(i + poly.Count - 1) % poly.Count]; float dCur = cur.W - WEps; float dPrev = prev.W - WEps; bool curIn = dCur >= 0f; bool prevIn = dPrev >= 0f; if (curIn) { if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur)); result.Add(cur); } else if (prevIn) { result.Add(Lerp(prev, cur, dPrev, dCur)); } } return result; } private static Vector4 Lerp(Vector4 p, Vector4 q, float dp, float dq) { float t = dp / (dp - dq); return p + t * (q - p); } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalProjectionTests"` Expected: PASS (3 tests). - [ ] **Step 5: Commit** ```bash git add src/AcDream.App/Rendering/PortalProjection.cs tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs git commit -m "feat(render): Phase A8.F — PortalProjection (NDC projection + in-front-of-camera clip)" ``` --- ## Task 4: `PortalVisibilityBuilder` — the recursive clip-frame BFS The heart of the port. BFS from the camera cell carrying a `CellView` per reached cell; per portal, project the opening and intersect with the current cell's region; route exit-portal regions to `OutsideView` and interior-portal regions to the neighbor's accumulator. Re-enqueue a neighbor whose region *grew* (retail's `update_count` re-processing — `PView::ClipPortals`/`AddViewToPortals`, decomp:433572/433446), bounded to guarantee termination. **Verify-against-decomp during execution:** the per-portal `seen`/`inflag` gating and the `OtherPortalClip` neighbor-side clip (decomp:433524) — confirm interior portals also clip against the *neighbor's* matching portal polygon. This plan implements the source-cell clip + union accumulation; add the neighbor-side clip if the conformance test (`Builder_Inn_*`) shows over-inclusion. **Files:** - Create: `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` - Test: `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs` - [ ] **Step 1: Write the failing test (synthetic cellar + multi-cell fixtures)** ```csharp // tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering; using Xunit; namespace AcDream.App.Tests.Rendering; public class PortalVisibilityBuilderTests { // Camera at origin looking down -Z. private static Matrix4x4 ViewProj() { var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY); var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f); return view * proj; } private static Vector3[] Quad(float cx, float cy, float halfW, float halfH, float z) => new[] { new Vector3(cx - halfW, cy - halfH, z), new Vector3(cx + halfW, cy - halfH, z), new Vector3(cx + halfW, cy + halfH, z), new Vector3(cx - halfW, cy + halfH, z), }; // Builds: camera cell (0x0001) with ONE interior portal (narrow, "stairwell") to // cell 0x0002; cell 0x0002 has ONE exit portal (wide, "window"). The window must // appear in OutsideView clipped to the stairwell width. private static (LoadedCell cam, Dictionary all) BuildCellarFixture() { var cam = new LoadedCell { CellId = 0x0001, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Portals = { new CellPortalInfo(0x0002, 0, 0) }, PortalPolygons = { Quad(0f, 0f, 0.1f, 1.0f, -3f) }, // narrow stairwell at z=-3 }; var ground = new LoadedCell { CellId = 0x0002, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Portals = { new CellPortalInfo(0xFFFF, 0, 0) }, PortalPolygons = { Quad(0f, 0f, 1.0f, 1.0f, -6f) }, // wide window at z=-6 }; return (cam, new Dictionary { [0x0001] = cam, [0x0002] = ground }); } [Fact] public void Builder_Cellar_WindowClippedToStairwell_NotFullWindow() { var (cam, all) = BuildCellarFixture(); var frame = PortalVisibilityBuilder.Build(cam, id => all.TryGetValue(id, out var c) ? c : null, ViewProj()); // OutsideView must be non-empty (we CAN see a sliver of daylight up the stairwell)... Assert.False(frame.OutsideView.IsEmpty); // ...but its width must be bounded by the stairwell, NOT the full window. // Stairwell half-width 0.1 at z=-3 projects much narrower than window half-width 1.0 at z=-6. float outsideWidth = frame.OutsideView.MaxX - frame.OutsideView.MinX; float windowOnlyWidth = PortalFrameTestHelper.ProjectedWidth( new[] { new Vector3(-1, 0, -6), new Vector3(1, 0, -6) }, ViewProj()); Assert.True(outsideWidth < windowOnlyWidth * 0.5f, $"OutsideView width {outsideWidth} should be a sliver, far less than full window {windowOnlyWidth}"); } [Fact] public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty() { // Camera cell with a single interior portal to a cell that has NO exit portal. var cam = new LoadedCell { CellId = 0x0001, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Portals = { new CellPortalInfo(0x0002, 0, 0) }, PortalPolygons = { Quad(0, 0, 0.1f, 1f, -3f) }, }; var inner = new LoadedCell { CellId = 0x0002, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, }; var all = new Dictionary { [0x0001] = cam, [0x0002] = inner }; var frame = PortalVisibilityBuilder.Build(cam, id => all.TryGetValue(id, out var c) ? c : null, ViewProj()); Assert.True(frame.OutsideView.IsEmpty); } [Fact] public void Builder_CameraCellWithDirectExit_OutsideViewIsFullWindow() { // Standing in a ground-floor room: camera cell has its OWN window (exit). Daylight unclipped. var cam = new LoadedCell { CellId = 0x0001, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Portals = { new CellPortalInfo(0xFFFF, 0, 0) }, PortalPolygons = { Quad(0, 0, 1f, 1f, -6f) }, }; var all = new Dictionary { [0x0001] = cam }; var frame = PortalVisibilityBuilder.Build(cam, id => all.TryGetValue(id, out var c) ? c : null, ViewProj()); Assert.False(frame.OutsideView.IsEmpty); float w = frame.OutsideView.MaxX - frame.OutsideView.MinX; Assert.True(w > 0.3f, $"direct window should give a wide OutsideView, got {w}"); } } internal static class PortalFrameTestHelper { public static float ProjectedWidth(Vector3[] worldSeg, Matrix4x4 vp) { var a = Vector4.Transform(new Vector4(worldSeg[0], 1f), vp); var b = Vector4.Transform(new Vector4(worldSeg[1], 1f), vp); return System.MathF.Abs(a.X / a.W - b.X / b.W); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalVisibilityBuilderTests"` Expected: FAIL (compile error — `PortalVisibilityBuilder` / `PortalVisibilityFrame` not defined). - [ ] **Step 3: Implement `PortalVisibilityBuilder.cs`** ```csharp // src/AcDream.App/Rendering/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; /// Per-frame output of the portal-frame BFS. public sealed class PortalVisibilityFrame { /// Screen region (NDC) where outdoor terrain/scenery may draw — exit portals /// recursively clipped to their portal chain. The cellar-flap fix. public CellView OutsideView { get; } = new(); /// Per-cell accumulated clip region, keyed by full cell id. Used for /// per-cell geometry clipping (wire-in #2). public Dictionary CellViews { get; } = new(); /// Entry clip regions for other buildings reached through our portals, /// keyed by the neighbour cell id whose OtherCellId left our building's cell set /// (wire-in #3 / Step 5). Empty in the common single-building case. public Dictionary CrossBuildingViews { get; } = new(); } public static class PortalVisibilityBuilder { // Bound on neighbour re-processing (retail uses update_count timestamps). Portal // graphs are small; this guards against cycles while allowing multi-portal unions. private const int MaxReprocessPerCell = 4; /// Resolve a full cell id to its LoadedCell, or null if not loaded. /// Optional: returns 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. public static PortalVisibilityFrame Build( LoadedCell cameraCell, Func lookup, Matrix4x4 viewProj, Func? buildingMembership = null) { var frame = new PortalVisibilityFrame(); if (cameraCell == null) return frame; uint lbMask = cameraCell.CellId & 0xFFFF0000u; frame.CellViews[cameraCell.CellId] = CellView.FullScreen(); var processCount = new Dictionary(); var queue = new Queue(); queue.Enqueue(cameraCell); while (queue.Count > 0) { var cell = queue.Dequeue(); 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; 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; // Project this portal opening to NDC. Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj); if (portalNdc.Length < 3) continue; // Intersect the portal opening with every polygon of the current cell's view. var clippedRegion = new List(); foreach (var vp in currentView.Polygons) { var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices); if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped)); } if (clippedRegion.Count == 0) continue; // portal not visible through this chain var portal = cell.Portals[i]; if (portal.OtherCellId == 0xFFFF) { // Exit portal -> outdoors visible through this (clipped) opening. foreach (var cp in clippedRegion) frame.OutsideView.Add(cp); continue; } uint neighbourId = lbMask | portal.OtherCellId; // Cross-building boundary: route to CrossBuildingViews, don't continue in-building BFS. if (buildingMembership != null && !buildingMembership(neighbourId)) { var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId); foreach (var cp in clippedRegion) xview.Add(cp); continue; } var neighbour = lookup(neighbourId); if (neighbour == null) continue; // Union the clipped region into the neighbour's accumulated view and (re)enqueue. 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); } } return frame; } private static CellView GetOrCreate(Dictionary map, uint key) { if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; } return v; } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalVisibilityBuilderTests"` Expected: PASS (3 tests). If `Builder_Cellar_*` fails on width, inspect projection sign conventions before adjusting (do not loosen the assertion). - [ ] **Step 5: Commit** ```bash git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs git commit -m "feat(render): Phase A8.F — PortalVisibilityBuilder recursive portal-clip BFS" ``` --- ## Task 5: `IndoorCellStencilPipeline.MarkAndPunchNdc` — mark NDC region into stencil Add an entry that marks a `CellView`/`ViewPolygon` set (already in NDC) into stencil bit 1 and far-depth-punches it, reusing the existing shader with an identity view-projection (NDC passthrough). Mirrors the existing `MarkAndPunch` GL state (IndoorCellStencilPipeline.cs:218-265) but takes pre-projected NDC triangles. **Files:** - Modify: `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` - [ ] **Step 1: Add `MarkAndPunchNdc` (no separate unit test — GL; verified by build + Task 9 visual gate)** Add to `IndoorCellStencilPipeline`: ```csharp /// /// Phase A8.F: mark a pre-projected NDC clip region into stencil bit 1 and far-depth-punch it. /// Replaces the flat world-space exit-portal path (PortalMeshBuilder.BuildTriangles) with the /// recursively-clipped region from PortalVisibilityBuilder. Polygons are triangulated (fan) and /// uploaded as NDC verts (z=0, w=1) drawn with an identity view-projection. /// GL state on exit matches MarkAndPunch: stencil disabled, color+depth on, depth=Less. /// public void MarkAndPunchNdc(System.Collections.Generic.IReadOnlyList region) { // Triangulate the region (fan per convex polygon) into NDC Vector3 (z=0). int triVerts = 0; foreach (var p in region) if (!p.IsEmpty) triVerts += (p.Vertices.Length - 2) * 3; if (triVerts == 0) { _lastVertexCount = 0; return; } var verts = new Vector3[triVerts]; int idx = 0; foreach (var p in region) { if (p.IsEmpty) continue; var v0 = new Vector3(p.Vertices[0], 0f); for (int i = 1; i < p.Vertices.Length - 1; i++) { verts[idx++] = v0; verts[idx++] = new Vector3(p.Vertices[i], 0f); verts[idx++] = new Vector3(p.Vertices[i + 1], 0f); } } if (triVerts > _vboCapacityVerts) AllocateVbo(Math.Max(triVerts * 2, 1024)); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); fixed (Vector3* p = verts) _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(triVerts * sizeof(Vector3)), p); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); _lastVertexCount = triVerts; // Same GL state machine as MarkAndPunch, but identity VP (verts are already NDC). var identity = Matrix4x4.Identity; _gl.Enable(EnableCap.StencilTest); _gl.Enable(EnableCap.DepthTest); _gl.ClearStencil(0); _gl.Clear(ClearBufferMask.StencilBufferBit); // Step 1: mark bit 1. _gl.ColorMask(false, false, false, false); _gl.DepthMask(false); _gl.DepthFunc(DepthFunction.Always); _gl.Disable(EnableCap.CullFace); _gl.StencilFunc(StencilFunction.Always, 1, 0xFFu); _gl.StencilMask(0x01u); _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); _shader.Use(); _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&identity); _gl.Uniform1(_uWriteFarDepthLoc, 0); _gl.BindVertexArray(_vao); _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)triVerts); // Step 2: far-depth punch where bit 1 is set. _gl.DepthMask(true); _gl.DepthFunc(DepthFunction.Always); _gl.StencilFunc(StencilFunction.Equal, 1, 0x01u); _gl.StencilMask(0x00u); _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep); _gl.Uniform1(_uWriteFarDepthLoc, 1); _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)triVerts); _gl.BindVertexArray(0); // Clean state for the indoor-entities pass (matches MarkAndPunch exit). _gl.Enable(EnableCap.CullFace); _gl.ColorMask(true, true, true, true); _gl.DepthFunc(DepthFunction.Less); _gl.Disable(EnableCap.StencilTest); } ``` - [ ] **Step 2: Build green** Run: `dotnet build` Expected: Build succeeded. (No unit test — GL path, exercised by Task 9.) - [ ] **Step 3: Commit** ```bash git add src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs git commit -m "feat(render): Phase A8.F — IndoorCellStencilPipeline.MarkAndPunchNdc (clipped-region stencil)" ``` --- ## Task 6: Rewrite `RenderInsideOutAcdream` — Job-A/B decouple + wire-in #1 (terrain) Drive the stencil from `PortalVisibilityBuilder.OutsideView` instead of the flat exit-portal mesh, and restore WB's structure: exterior geometry (terrain/scenery/shells) draws **unconditionally**; only the stencil *state* is gated by whether `OutsideView` is non-empty. (WB `VisibilityManager.RenderInsideOut`: terrain/scenery/static draws at lines 143-154 are OUTSIDE the `if (didInsideStencil)` block; only the state-setup at 130-139 is inside.) **Files:** - Modify: `src/AcDream.App/Rendering/GameWindow.cs` — `RenderInsideOutAcdream` (~11012) and its call site (~7640, to pass what the builder needs). - [ ] **Step 1: Build the frame at the top of `RenderInsideOutAcdream`** Replace the `visiblePortalCells` + `UploadPortalMesh` block (current lines ~11036-11093) with a builder call and the `MarkAndPunchNdc`: ```csharp // Phase A8.F: build the recursively-clipped portal frame from the camera cell. var portalFrame = PortalVisibilityBuilder.Build( cameraCell, id => _cellVisibility.TryGetCell(id, out var c) ? c : null, viewProj, buildingMembership: id => currentEnvCellIds.Contains(id)); // see Step 3 for currentEnvCellIds hoist bool didInsideStencil = !portalFrame.OutsideView.IsEmpty; if (didInsideStencil) { EmitDrawOrderProbe(step: 1, sub: ' '); _indoorStencilPipeline!.MarkAndPunchNdc(portalFrame.OutsideView.Polygons); EmitStencilProbe(op: "mark-clipped"); } ``` Note: `currentEnvCellIds` (the camera-building cell set) is computed later in the current code; hoist its construction above the builder call so `buildingMembership` can use it (Step 3). - [ ] **Step 2: Keep Step 3 (render camera-building cells) as-is** The existing Step 3 block (render `currentEnvCellIds` opaque + transparent, then IndoorPass shells) is unchanged. It already writes the depth that occludes terrain outside the clipped region. - [ ] **Step 3: Hoist `currentEnvCellIds` above the builder call** Move the construction of `currentEnvCellIds` (union of `camBuildings[].EnvCellIds` + `visibleCellIds`, current lines ~11105-11119) to just before the builder call in Step 1, so `buildingMembership` can reference it. Leave its use in Step 3 intact. - [ ] **Step 4: Decouple Step 4 — terrain/scenery/shells draw unconditionally, stencil state gated** Rewrite the Step 4 block (current lines ~11167-11208) so the DRAWS are unconditional and only the stencil STATE is gated (WB structure): ```csharp // Step 4 (WB VisibilityManager.cs:130-154): stencil STATE gated; exterior DRAWS unconditional. if (didInsideStencil) { gl.Enable(EnableCap.StencilTest); gl.StencilFunc(StencilFunction.Equal, 1, 0x01u); gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep); gl.StencilMask(0x00u); } else { gl.Disable(EnableCap.StencilTest); // sealed view (e.g. cellar with no reachable exit) — depth alone occludes } gl.ColorMask(true, true, true, false); gl.DepthMask(true); gl.Enable(EnableCap.CullFace); gl.DepthFunc(DepthFunction.Less); EmitDrawOrderProbe(step: 4, sub: ' '); // Terrain (WB:143). Terrain mesh is CCW; Step 4 culls, so use terrain's own front-face. gl.FrontFace(FrontFaceDirection.Ccw); _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); gl.FrontFace(FrontFaceDirection.CW); _meshShader!.Use(); // Scenery + static objects (WB:148-154). _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum, neverCullLandblockId: playerLb, visibleCellIds: visibleCellIds, animatedEntityIds: null, set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery); _a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats; ``` - [ ] **Step 5: Build green + smoke probe** Run: `dotnet build` Expected: Build succeeded. - [ ] **Step 6: Commit** ```bash git add src/AcDream.App/Rendering/GameWindow.cs git commit -m "feat(render): Phase A8.F — RenderInsideOut driven by clipped OutsideView + Job-A/B decouple" ``` --- ## Task 7: Wire-in #2 — per-cell geometry clipping (depth for opaque; targeted stencil for translucent) Per Q4: opaque cells already get retail's observable result from depth-testing (kept). Apply the per-cell `CellView` via stencil ONLY for translucent cell geometry. This keeps `EnvCellRenderer`'s MDI batching for the opaque pass. **Files:** - Modify: `src/AcDream.App/Rendering/GameWindow.cs` — the Step 3 transparent-cell render inside `RenderInsideOutAcdream`. - [ ] **Step 1: Gate the transparent EnvCell pass per cell by its `CellView`** In Step 3, the opaque pass (`_envCellRenderer.Render(Opaque, currentEnvCellIds)`) stays unchanged (depth handles it). For the transparent pass, iterate cells that have a non-full `CellView` and render each gated by its region: ```csharp // Phase A8.F wire-in #2: translucent cell geometry clipped to its portal-chain region. // Opaque cells rely on depth (retail's observable result, keeps MDI batching). gl.Enable(EnableCap.Blend); gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); gl.DepthMask(false); foreach (var cellId in currentEnvCellIds) { if (portalFrame.CellViews.TryGetValue(cellId, out var cv) && !cv.IsEmpty) { _indoorStencilPipeline!.MarkRegionStencilOnly(cv.Polygons); // bit 1, no depth punch gl.Enable(EnableCap.StencilTest); gl.StencilFunc(StencilFunction.Equal, 1, 0x01u); gl.StencilMask(0x00u); _envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, new System.Collections.Generic.HashSet { cellId }); gl.Disable(EnableCap.StencilTest); } else { _envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, new System.Collections.Generic.HashSet { cellId }); } } gl.DepthMask(true); gl.Disable(EnableCap.Blend); ``` - [ ] **Step 2: Add `MarkRegionStencilOnly` to `IndoorCellStencilPipeline`** Same as `MarkAndPunchNdc` but WITHOUT the far-depth punch step (mark bit 1 only, leave depth untouched), and it does NOT clear the stencil (the caller clears once per cell). Concretely: copy `MarkAndPunchNdc`, keep the upload + Step-1 mark block, delete the Step-2 punch block, and remove the `ClearStencil`/`Clear` (caller-controlled). Leave color/depth restored on exit as in `MarkAndPunchNdc`. - [ ] **Step 3: Build green** Run: `dotnet build` Expected: Build succeeded. - [ ] **Step 4: Commit** ```bash git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs git commit -m "feat(render): Phase A8.F — wire-in #2 per-cell clip for translucent geometry" ``` --- ## Task 8: Wire-in #3 — cross-building visibility via clip regions Drive the existing Step-5 3-bit helpers from `portalFrame.CrossBuildingViews` instead of the env-gated `ACDREAM_A8_STEP5` opt-in. Each cross-building entry region marks where another building's cells may show through our portal chain. **Files:** - Modify: `src/AcDream.App/Rendering/GameWindow.cs` — the Step-5 block in `RenderInsideOutAcdream` (current lines ~11220-11284). - [ ] **Step 1: Replace the env-gate with the builder's cross-building views** Remove the `step5Enabled` env-var gate. Run Step 5 when `portalFrame.CrossBuildingViews` is non-empty. For each `(neighbourCellId, entryView)`, resolve the owning building (via `BuildingRegistry.GetBuildingsContainingCell(neighbourCellId)`), and use the existing `MarkBuildingBit2`/`PunchDepthAtStencil3`/`EnableOtherBuildingPass`/`ResetBit2` sequence — but mark bit 1 from `entryView` (the clipped region) rather than the whole building's exit portals. ```csharp // Step 5 (WB:157-232): other-building cells seen through OUR clipped portal regions. if (didInsideStencil && portalFrame.CrossBuildingViews.Count > 0) { foreach (var (neighbourCellId, entryView) in portalFrame.CrossBuildingViews) { if (entryView.IsEmpty) continue; var buildings = _buildingRegistry?.GetBuildingsContainingCell(neighbourCellId); if (buildings == null) continue; foreach (var b in buildings) { // Mark bit 1 = the clipped entry region (NDC), then the existing 3-bit dance. _indoorStencilPipeline!.MarkRegionStencilOnly(entryView.Polygons); // ... existing MarkBuildingBit2 / PunchDepthAtStencil3 / EnableOtherBuildingPass / // render b.EnvCellIds / ResetBit2 sequence (unchanged from current Step 5) ... } } gl.DepthFunc(DepthFunction.Less); } ``` **Verify-against-decomp during execution:** confirm `BuildingRegistry.GetBuildingsContainingCell` exists with that signature (grep `BuildingRegistry`); if the registry exposes a different accessor, use it. The 3-bit Mark/Punch/Reset sequence is already implemented and correct (IndoorCellStencilPipeline.cs:310-419) — only its bit-1 source changes. - [ ] **Step 2: Build green** Run: `dotnet build` Expected: Build succeeded. - [ ] **Step 3: Commit** ```bash git add src/AcDream.App/Rendering/GameWindow.cs git commit -m "feat(render): Phase A8.F — wire-in #3 cross-building visibility from clip regions" ``` --- ## Task 9: Integration — full suite, probe evidence, visual gate, roadmap + memory **Files:** - Modify: `docs/plans/2026-04-11-roadmap.md` - Run: full build + test + a probe launch + the visual gate. - [ ] **Step 1: Full build + test** Run: `dotnet build` Expected: Build succeeded, 0 errors. Run: `dotnet test` Expected: PASS — App baseline (~90) + the new PortalView/ScreenPolygonClip/PortalProjection/PortalVisibilityBuilder tests; Core baseline maintained. - [ ] **Step 2: Probe-evidence launch (read the log BEFORE asking for the visual gate)** Launch with probes (PowerShell), walk into a Holtburg cottage and down to the cellar: ```powershell $env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1" $env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000" $env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword" $env:ACDREAM_A8_INDOOR_BRANCH="1"; $env:ACDREAM_PROBE_VIS="1" dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "a8f-cellar-gate.log" ``` Read `a8f-cellar-gate.log` and confirm: `[buildings] camBldgs=[0xA]` when in the cottage; `[draworder]` shows steps 1→3→4 per indoor frame; `[stencil] op=mark-clipped verts>0` in the cellar with a SMALL vert count (sliver), not the full-window count. - [ ] **Step 3: Visual gate (user)** User confirms in the running client: - Cottage **cellar**: no flap — at most a daylight sliver up the stairwell, never the full outdoor world through the floor. - Cottage interior + inn: walls/furniture render; windows show correct daylight; multi-room daylight preserved. - Dungeon if reachable (deep portal chain). - No A8 regression: no see-through walls; building shells intact; LiveDynamic entities (player/NPCs/items) present indoors. This is the acceptance test and the only mandatory stop. - [ ] **Step 4: Update roadmap** Add an A8.F "shipped" row to `docs/plans/2026-04-11-roadmap.md` summarizing the port (recursively-clipped OutsideView via PortalVisibilityBuilder; enforcement on the A8 stencil pipeline; cellar flap closed) with the commit range. - [ ] **Step 5: Commit the roadmap update** ```bash git add docs/plans/2026-04-11-roadmap.md git commit -m "docs: Phase A8.F shipped — retail portal-frame visibility (cellar flap closed)" ``` - [ ] **Step 6: Update memory + CLAUDE.md M1.5 line** Confirm `memory/project_indoor_portal_visibility.md` reflects "A8.F shipped"; update the CLAUDE.md "currently working toward M1.5" block to note the cellar flap closed (if A8.F completes the M1.5 indoor-visibility scope). Commit. --- ## Self-review (completed) - **Spec coverage:** Step 0 (flag strip) ✓ Task 0; builder + clip + projection + data model ✓ Tasks 1-4; stencil NDC entry ✓ Task 5; Job-A/B decouple + wire-in #1 ✓ Task 6; wire-in #2 ✓ Task 7; wire-in #3 ✓ Task 8; tests + visual gate + roadmap/memory ✓ Task 9. Near-plane risk addressed by Task 3's straddle test. Q4 fidelity-vs-perf realized in Task 7 (opaque=depth, translucent=stencil). - **Placeholders:** none — every code step has complete code; the two "verify-against-decomp during execution" notes (Task 4 neighbour-side clip, Task 8 registry accessor) are explicit verification steps with grep targets, not deferred implementation. - **Type consistency:** `ViewPolygon`/`CellView` (Task 1) → `ScreenPolygonClip.Intersect(subject, clip)` returning `Vector2[]` (Task 2) → `PortalProjection.ProjectToNdc(localPoly, cellToWorld, viewProj)` returning `Vector2[]` (Task 3) → `PortalVisibilityBuilder.Build(cameraCell, lookup, viewProj, buildingMembership)` returning `PortalVisibilityFrame {OutsideView, CellViews, CrossBuildingViews}` (Task 4) → `MarkAndPunchNdc(IReadOnlyList)` + `MarkRegionStencilOnly(...)` (Tasks 5,7) → consumed in `RenderInsideOutAcdream` (Tasks 6-8). Consistent throughout. **Known execution-time verifications (flagged, not placeholders):** Task 4 neighbour-side `OtherPortalClip` (decomp:433524) if the inn conformance shows over-inclusion; Task 8 `BuildingRegistry.GetBuildingsContainingCell` signature.