From 612400f998617516ceb6414e3a17fadcfc8b2651 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 29 May 2026 10:58:00 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Phase=20A8.F=20implementation=20plan=20?= =?UTF-8?q?=E2=80=94=20retail=20portal-frame=20visibility=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 tasks (0-9): strip diag flags; GL-free CPU layer (ViewPolygon/CellView, ScreenPolygonClip, PortalProjection, PortalVisibilityBuilder) with TDD; stencil NDC entry; RenderInsideOut rewrite + Job-A/B decouple + three wire-ins; visual gate. Bite-sized steps, complete code, retail anchors. Co-Authored-By: Claude Opus 4.7 --- ...05-29-phase-a8f-portal-frame-visibility.md | 1212 +++++++++++++++++ 1 file changed, 1212 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-29-phase-a8f-portal-frame-visibility.md diff --git a/docs/superpowers/plans/2026-05-29-phase-a8f-portal-frame-visibility.md b/docs/superpowers/plans/2026-05-29-phase-a8f-portal-frame-visibility.md new file mode 100644 index 0000000..3d0f554 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-phase-a8f-portal-frame-visibility.md @@ -0,0 +1,1212 @@ +# 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 + +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.