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

53 KiB
Raw Blame History

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.csViewPolygon (2D NDC convex polygon + bbox) and CellView (set of view polygons + union bbox). Data model; mirrors retail view_poly/view_type.
  • ScreenPolygonClip.csIntersect(subject, clip): 2D convex-polygon intersection (SutherlandHodgman). Port of retail ACRender::polyClipFinish behavior.
  • PortalProjection.csProjectToNdc(localPoly, cellToWorld, viewProj): project a portal polygon to NDC with in-front-of-camera (homogeneous-w) clipping to prevent inversion.
  • PortalVisibilityBuilder.csBuild(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
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

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

// 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 &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
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

// 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.csRenderInsideOutAcdream (~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 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):

// 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 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:

// 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
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.

// 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) 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.