acdream/docs/superpowers/plans/2026-05-29-phase-a8f-portal-frame-visibility.md

1225 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (SutherlandHodgman). 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<Vector2>()).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;
/// <summary>One convex polygon in NDC screen space (xy in [-1,1]), plus its bounding rect.</summary>
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;
}
/// <summary>A cell's accumulated clip region: a set of convex view polygons + the union bounding rect.</summary>
public sealed class CellView
{
public readonly List<ViewPolygon> 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;
/// <summary>A region covering the entire NDC viewport — the camera cell's seed region
/// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814).</summary>
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
SutherlandHodgman: 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;
/// <summary>Intersect two convex polygons given CCW. Returns the clipped
/// vertices (CCW) or an array with &lt;3 verts when the intersection is empty.</summary>
public static Vector2[] Intersect(IReadOnlyList<Vector2> subject, IReadOnlyList<Vector2> clip)
{
if (subject == null || clip == null || subject.Count < 3 || clip.Count < 3)
return System.Array.Empty<Vector2>();
var output = new List<Vector2>(subject);
for (int i = 0; i < clip.Count; i++)
{
if (output.Count < 3) return System.Array.Empty<Vector2>();
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<Vector2>();
}
// Keep the part of `poly` on the left of directed edge a->b (CCW inside).
private static List<Vector2> ClipByEdge(List<Vector2> poly, Vector2 a, Vector2 b)
{
var result = new List<Vector2>(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;
/// <summary>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.</summary>
public static Vector2[] ProjectToNdc(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
{
if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector2>();
Matrix4x4 m = cellToWorld * viewProj;
// To clip space (keep w).
var clip = new List<Vector4>(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<Vector2>();
// 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<Vector4> ClipAgainstW(List<Vector4> poly)
{
var result = new List<Vector4>(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<uint, LoadedCell> 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<uint, LoadedCell> { [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<uint, LoadedCell> { [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<uint, LoadedCell> { [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;
/// <summary>Per-frame output of the portal-frame BFS.</summary>
public sealed class PortalVisibilityFrame
{
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
public CellView OutsideView { get; } = new();
/// <summary>Per-cell accumulated clip region, keyed by full cell id. Used for
/// per-cell geometry clipping (wire-in #2).</summary>
public Dictionary<uint, CellView> CellViews { get; } = new();
/// <summary>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.</summary>
public Dictionary<uint, CellView> 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;
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
/// <param name="buildingMembership">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.</param>
public static PortalVisibilityFrame Build(
LoadedCell cameraCell,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
Func<uint, bool>? 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<uint, int>();
var queue = new Queue<LoadedCell>();
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<ViewPolygon>();
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<uint, CellView> 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
/// <summary>
/// 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.
/// </summary>
public void MarkAndPunchNdc(System.Collections.Generic.IReadOnlyList<ViewPolygon> 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<uint> { cellId });
gl.Disable(EnableCap.StencilTest);
}
else
{
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent,
new System.Collections.Generic.HashSet<uint> { 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<ViewPolygon>)` + `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.