diff --git a/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs b/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
new file mode 100644
index 0000000..0f113d8
--- /dev/null
+++ b/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
@@ -0,0 +1,249 @@
+// IndoorCellStencilPipeline.cs — Phase A8 indoor-cell visibility culling.
+//
+// Ports WorldBuilder's RenderInsideOut stencil mechanism for acdream's
+// modern GL pipeline. Closes issue #78 (outdoor stabs visible through
+// indoor walls) and the cellar-stairs artifact (outdoor terrain visible
+// inside cottage cellars).
+//
+// Algorithm (per WB VisibilityManager.cs:73-239):
+// Step 1: stencil ref=1 marked everywhere portal polygons cover
+// (color/depth writes off, depth=Always, CullFace off).
+// Step 2: gl_FragDepth=1.0 punched into stencil=1 regions
+// (color off, depth on, depth=Always).
+// Step 3: (caller) draw indoor entities with stencil OFF.
+// Step 4: (caller) draw terrain + outdoor entities with
+// glStencilFunc(Equal, 1, 0x01), stencil read-only.
+//
+// Retail equivalent: PView::DrawCells at
+// docs/research/named-retail/acclient_2013_pseudo_c.txt:432709 uses
+// screen-space polygon-clip scissor instead of stencil. Same observable
+// behavior; the stencil approach matches modern GL pipeline conventions.
+
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Pure-math triangle-fan generation from a list of cells with
+/// . Extracted as a static class so
+/// the vertex-list construction is unit-testable without a GL context.
+///
+public static class PortalMeshBuilder
+{
+ ///
+ /// Builds a flat triangle-fan vertex array in world space from every
+ /// exit portal ( == 0xFFFF) on
+ /// every cell in . Inner portals are skipped
+ /// — they don't open to outdoors, so stencil-marking them would let
+ /// outdoor geometry bleed into adjacent rooms (incorrect).
+ ///
+ public static Vector3[] BuildTriangles(IReadOnlyCollection cells)
+ {
+ // Pre-count to size the output exactly.
+ int triCount = 0;
+ foreach (var cell in cells)
+ {
+ for (int p = 0; p < cell.Portals.Count; p++)
+ {
+ if (cell.Portals[p].OtherCellId != 0xFFFF) continue;
+ if (p >= cell.PortalPolygons.Count) continue;
+ var poly = cell.PortalPolygons[p];
+ if (poly.Length < 3) continue;
+ triCount += (poly.Length - 2) * 3;
+ }
+ }
+
+ if (triCount == 0) return Array.Empty();
+
+ var output = new Vector3[triCount];
+ int outIdx = 0;
+ foreach (var cell in cells)
+ {
+ var xform = cell.WorldTransform;
+ for (int p = 0; p < cell.Portals.Count; p++)
+ {
+ if (cell.Portals[p].OtherCellId != 0xFFFF) continue;
+ if (p >= cell.PortalPolygons.Count) continue;
+ var poly = cell.PortalPolygons[p];
+ if (poly.Length < 3) continue;
+
+ // Triangle-fan from vertex 0.
+ var v0 = Vector3.Transform(poly[0], xform);
+ for (int i = 1; i < poly.Length - 1; i++)
+ {
+ output[outIdx++] = v0;
+ output[outIdx++] = Vector3.Transform(poly[i], xform);
+ output[outIdx++] = Vector3.Transform(poly[i + 1], xform);
+ }
+ }
+ }
+
+ return output;
+ }
+}
+
+///
+/// GL pipeline owner. Holds the portal stencil shader and a dynamic
+/// VBO/VAO for per-frame portal triangle uploads. Public methods are
+/// called in the per-frame render path when the camera is inside an
+/// EnvCell. When outside, this object is dormant — no GL work.
+///
+public sealed unsafe class IndoorCellStencilPipeline : IDisposable
+{
+ private readonly GL _gl;
+ private readonly Shader _shader;
+ private readonly uint _vao;
+ private readonly uint _vbo;
+ private int _vboCapacityVerts;
+ private int _lastVertexCount;
+ private int _uViewProjectionLoc;
+ private int _uWriteFarDepthLoc;
+
+ public IndoorCellStencilPipeline(GL gl, string vertPath, string fragPath)
+ {
+ _gl = gl;
+ _shader = new Shader(gl, vertPath, fragPath);
+ _uViewProjectionLoc = _gl.GetUniformLocation(_shader.Program, "uViewProjection");
+ _uWriteFarDepthLoc = _gl.GetUniformLocation(_shader.Program, "uWriteFarDepth");
+
+ _vao = _gl.GenVertexArray();
+ _vbo = _gl.GenBuffer();
+ AllocateVbo(1024);
+ ConfigureVao();
+ }
+
+ ///
+ /// Builds the per-frame portal triangle array from
+ /// and uploads it to . Returns the vertex count
+ /// (0 means no exit portals — caller should skip stencil setup entirely).
+ ///
+ public int UploadPortalMesh(IReadOnlyCollection cells)
+ {
+ var verts = PortalMeshBuilder.BuildTriangles(cells);
+ _lastVertexCount = verts.Length;
+ if (_lastVertexCount == 0) return 0;
+
+ if (_lastVertexCount > _vboCapacityVerts)
+ {
+ _vboCapacityVerts = Math.Max(_lastVertexCount * 2, 1024);
+ AllocateVbo(_vboCapacityVerts);
+ }
+
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
+ fixed (Vector3* p = verts)
+ {
+ _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0,
+ (nuint)(_lastVertexCount * sizeof(Vector3)), p);
+ }
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
+ return _lastVertexCount;
+ }
+
+ ///
+ /// Steps 1+2 of WB's RenderInsideOut: mark stencil ref=1 wherever
+ /// portal polygons cover, then write gl_FragDepth=1.0 into those
+ /// regions. Leaves the GL state set up for the caller to invoke
+ /// .
+ ///
+ public void MarkAndPunch(Matrix4x4 viewProjection)
+ {
+ if (_lastVertexCount == 0) return;
+
+ _gl.Enable(EnableCap.StencilTest);
+ _gl.ClearStencil(0);
+ _gl.Clear(ClearBufferMask.StencilBufferBit);
+
+ // Step 1: stencil mark.
+ _gl.ColorMask(false, false, false, false);
+ _gl.DepthMask(false);
+ _gl.DepthFunc(DepthFunction.Always);
+ _gl.Disable(EnableCap.CullFace);
+ _gl.Enable(EnableCap.DepthClamp); // portal polys can be at any Z
+ _gl.StencilFunc(StencilFunction.Always, 1, 0xFFu);
+ _gl.StencilMask(0x01u);
+ _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
+
+ _shader.Use();
+ var vp = viewProjection;
+ _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp);
+ _gl.Uniform1(_uWriteFarDepthLoc, 0);
+
+ _gl.BindVertexArray(_vao);
+ _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_lastVertexCount);
+
+ // Step 2: far-depth punch.
+ _gl.ColorMask(false, false, false, false);
+ _gl.DepthMask(true);
+ _gl.DepthFunc(DepthFunction.Always);
+ _gl.StencilFunc(StencilFunction.Equal, 1, 0x01u);
+ _gl.StencilMask(0x00u); // read-only
+ _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
+
+ _gl.Uniform1(_uWriteFarDepthLoc, 1);
+ _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_lastVertexCount);
+
+ _gl.BindVertexArray(0);
+ _gl.Disable(EnableCap.DepthClamp);
+ _gl.Enable(EnableCap.CullFace);
+
+ // Leave a clean state for the indoor-entities pass: stencil
+ // disabled, color+depth on, depth=Less, no cull change.
+ _gl.ColorMask(true, true, true, true);
+ _gl.DepthFunc(DepthFunction.Less);
+ _gl.Disable(EnableCap.StencilTest);
+ }
+
+ ///
+ /// Step 4 of WB's RenderInsideOut: enable stencil read-only with
+ /// ref=1, so subsequent terrain + outdoor entity draws are gated
+ /// to portal silhouette regions only.
+ ///
+ public void EnableOutdoorPass()
+ {
+ if (_lastVertexCount == 0) return;
+ _gl.Enable(EnableCap.StencilTest);
+ _gl.StencilFunc(StencilFunction.Equal, 1, 0x01u);
+ _gl.StencilMask(0x00u);
+ _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
+ }
+
+ ///
+ /// Restores stencil-off state. Call after all outdoor passes complete
+ /// so subsequent rendering (particles, sky, UI) is unaffected.
+ ///
+ public void DisableStencil()
+ {
+ _gl.Disable(EnableCap.StencilTest);
+ _gl.StencilMask(0xFFu); // restore default write mask
+ }
+
+ public void Dispose()
+ {
+ _shader.Dispose();
+ _gl.DeleteVertexArray(_vao);
+ _gl.DeleteBuffer(_vbo);
+ }
+
+ private void AllocateVbo(int capacityVerts)
+ {
+ _vboCapacityVerts = capacityVerts;
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
+ _gl.BufferData(BufferTargetARB.ArrayBuffer,
+ (nuint)(capacityVerts * sizeof(Vector3)),
+ null, BufferUsageARB.DynamicDraw);
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
+ }
+
+ private void ConfigureVao()
+ {
+ _gl.BindVertexArray(_vao);
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
+ _gl.EnableVertexAttribArray(0);
+ _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false,
+ (uint)sizeof(Vector3), (void*)0);
+ _gl.BindVertexArray(0);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs b/tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs
new file mode 100644
index 0000000..2f5d2d5
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs
@@ -0,0 +1,151 @@
+// Phase A8 — portal mesh triangle-fan generation tests.
+//
+// Pure-math coverage of PortalMeshBuilder.BuildTriangles — the part of
+// IndoorCellStencilPipeline that converts a list of LoadedCell with
+// PortalPolygons + WorldTransform into a flat Vector3[] of triangles
+// in world space. The GL/upload portion is exercised at runtime only.
+
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.Rendering;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering;
+
+public class IndoorCellStencilPipelineTests
+{
+ [Fact]
+ public void BuildTriangles_NoCells_ReturnsEmpty()
+ {
+ var verts = PortalMeshBuilder.BuildTriangles(new List());
+ Assert.Empty(verts);
+ }
+
+ [Fact]
+ public void BuildTriangles_SkipsInnerPortals()
+ {
+ // Two portals on one cell: one exit (OtherCellId=0xFFFF, should be
+ // included), one inner (OtherCellId=0x0102, should be skipped).
+ var cell = new LoadedCell
+ {
+ WorldTransform = Matrix4x4.Identity,
+ Portals = new()
+ {
+ new CellPortalInfo(0xFFFF, 100, 0), // exit — included
+ new CellPortalInfo(0x0102, 101, 0), // inner — skipped
+ },
+ ClipPlanes = new() { default, default },
+ PortalPolygons = new()
+ {
+ new[]
+ {
+ new Vector3(0, 0, 0),
+ new Vector3(1, 0, 0),
+ new Vector3(1, 1, 0),
+ },
+ new[]
+ {
+ new Vector3(10, 0, 0),
+ new Vector3(11, 0, 0),
+ new Vector3(11, 1, 0),
+ },
+ },
+ };
+
+ var verts = PortalMeshBuilder.BuildTriangles(new List { cell });
+
+ // Only the exit polygon (3 verts → 1 triangle → 3 vertices).
+ Assert.Equal(3, verts.Length);
+ Assert.Equal(new Vector3(0, 0, 0), verts[0]);
+ Assert.Equal(new Vector3(1, 0, 0), verts[1]);
+ Assert.Equal(new Vector3(1, 1, 0), verts[2]);
+ }
+
+ [Fact]
+ public void BuildTriangles_TriangulatesAsFan()
+ {
+ // 4-vertex quad → fan = 2 triangles → 6 vertices.
+ // Quad: (0,0,0), (1,0,0), (1,1,0), (0,1,0).
+ // Fan from vertex 0: (0,1,2), (0,2,3).
+ var cell = new LoadedCell
+ {
+ WorldTransform = Matrix4x4.Identity,
+ Portals = new() { new CellPortalInfo(0xFFFF, 100, 0) },
+ ClipPlanes = new() { default },
+ PortalPolygons = new()
+ {
+ new[]
+ {
+ new Vector3(0, 0, 0),
+ new Vector3(1, 0, 0),
+ new Vector3(1, 1, 0),
+ new Vector3(0, 1, 0),
+ },
+ },
+ };
+
+ var verts = PortalMeshBuilder.BuildTriangles(new List { cell });
+
+ Assert.Equal(6, verts.Length);
+ // Triangle 1: (0,1,2)
+ Assert.Equal(new Vector3(0, 0, 0), verts[0]);
+ Assert.Equal(new Vector3(1, 0, 0), verts[1]);
+ Assert.Equal(new Vector3(1, 1, 0), verts[2]);
+ // Triangle 2: (0,2,3)
+ Assert.Equal(new Vector3(0, 0, 0), verts[3]);
+ Assert.Equal(new Vector3(1, 1, 0), verts[4]);
+ Assert.Equal(new Vector3(0, 1, 0), verts[5]);
+ }
+
+ [Fact]
+ public void BuildTriangles_AppliesWorldTransform()
+ {
+ // Identity cell-local triangle, translated by WorldTransform.
+ var translate = Matrix4x4.CreateTranslation(new Vector3(100, 200, 300));
+ var cell = new LoadedCell
+ {
+ WorldTransform = translate,
+ Portals = new() { new CellPortalInfo(0xFFFF, 100, 0) },
+ ClipPlanes = new() { default },
+ PortalPolygons = new()
+ {
+ new[]
+ {
+ new Vector3(0, 0, 0),
+ new Vector3(1, 0, 0),
+ new Vector3(0, 1, 0),
+ },
+ },
+ };
+
+ var verts = PortalMeshBuilder.BuildTriangles(new List { cell });
+
+ Assert.Equal(3, verts.Length);
+ Assert.Equal(new Vector3(100, 200, 300), verts[0]);
+ Assert.Equal(new Vector3(101, 200, 300), verts[1]);
+ Assert.Equal(new Vector3(100, 201, 300), verts[2]);
+ }
+
+ [Fact]
+ public void BuildTriangles_SkipsEmptyOrDegeneratePolygons()
+ {
+ var cell = new LoadedCell
+ {
+ WorldTransform = Matrix4x4.Identity,
+ Portals = new()
+ {
+ new CellPortalInfo(0xFFFF, 100, 0),
+ new CellPortalInfo(0xFFFF, 101, 0),
+ },
+ ClipPlanes = new() { default, default },
+ PortalPolygons = new()
+ {
+ System.Array.Empty(), // empty
+ new[] { new Vector3(0, 0, 0), new Vector3(1, 0, 0) }, // degenerate (2 verts)
+ },
+ };
+
+ var verts = PortalMeshBuilder.BuildTriangles(new List { cell });
+ Assert.Empty(verts);
+ }
+}