53 KiB
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. 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) andCellView(set of view polygons + union bbox). Data model; mirrors retailview_poly/view_type.ScreenPolygonClip.cs—Intersect(subject, clip): 2D convex-polygon intersection (Sutherland–Hodgman). Port of retailACRender::polyClipFinishbehavior.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; producesPortalVisibilityFrame { OutsideView, CellViews, CrossBuildingViews }. Port of retailPView::ConstructView/ClipPortals/AddViewToPortals.
Modified:
src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs— addMarkAndPunchNdc(region)(NDC-polygon stencil mark + far-depth punch). The flatPortalMeshBuilder.BuildTrianglespath is superseded.src/AcDream.App/Rendering/GameWindow.cs— rewriteRenderInsideOutAcdream(~11012) to drive from the builder, restore WB's unconditional-exterior structure, and apply the three wire-ins. RemoveACDREAM_A8_DIAG_*(Task 0).src/AcDream.App/RuntimeOptions.cs— removeA8Diag*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(theA8Diag*properties, ~18 occurrences) -
Modify:
src/AcDream.App/Rendering/GameWindow.cs(diagDisableStep*locals + their use sites inRenderInsideOutAcdream, ~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 fromRuntimeOptions.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 inRenderInsideOutAcdream
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
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
// 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
// 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
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
// 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
// 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 <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
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 >= WEpsclip in the Step-3 code below was a bug — it leaves a clipped vertex at the eye singularity (w≈1e-4) sox/wblows up (~±7852), relocating the inversion instead of preventing it. The shipped code clips against the in-front-of-camera half-spacew + z >= 0(commita28a176; comments corrected in9ec8330). That predicate is convention-agnostic: acdream's cameras useMatrix4x4.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: noglClipControl/glDepthRangeremap anywhere in the codebase). The straddle test bound was relaxed[-10,10]→[-50,50]and a 4th downstream-intersection test added. Task 4 requirement:ProjectToNdcpreserves input winding (NOT normalized CCW) — the builder MUST apply the portal-side test and feed camera-facing (CCW) portals to the CCW-onlyScreenPolygonClip, 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
// 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
// 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
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)
// 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
// 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
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:
/// <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
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:
// 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
currentEnvCellIdsabove 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):
// 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
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 insideRenderInsideOutAcdream. -
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:
// 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
MarkRegionStencilOnlytoIndoorCellStencilPipeline
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
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 inRenderInsideOutAcdream(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.
// 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
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:
$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
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)returningVector2[](Task 2) →PortalProjection.ProjectToNdc(localPoly, cellToWorld, viewProj)returningVector2[](Task 3) →PortalVisibilityBuilder.Build(cameraCell, lookup, viewProj, buildingMembership)returningPortalVisibilityFrame {OutsideView, CellViews, CrossBuildingViews}(Task 4) →MarkAndPunchNdc(IReadOnlyList<ViewPolygon>)+MarkRegionStencilOnly(...)(Tasks 5,7) → consumed inRenderInsideOutAcdream(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.