diff --git a/src/AcDream.App/Rendering/Walk/WalkBuilding.cs b/src/AcDream.App/Rendering/Walk/WalkBuilding.cs
new file mode 100644
index 00000000..a43727bd
--- /dev/null
+++ b/src/AcDream.App/Rendering/Walk/WalkBuilding.cs
@@ -0,0 +1,259 @@
+using System.Numerics;
+
+namespace AcDream.App.Rendering.Walk;
+
+/// Retail CBldPortal: one building portal — the authored
+/// side gate, the destination interior cell, its reciprocal portal index,
+/// and the stab list of cells whose view stacks are pushed around the
+/// look-in (PView::add_views/remove_views).
+public struct WalkBldPortal
+{
+ public int PortalSide;
+ public uint OtherCellId;
+ public int OtherPortalId;
+ public bool ExactMatch;
+ public uint[] StabList;
+}
+
+/// One CPortalPoly reference on a PORT BSP node: the index
+/// into the building's portal list plus the portal polygon (building-local).
+public struct WalkPortalRef
+{
+ public int PortalIndex;
+ public WalkPolygon Polygon;
+}
+
+/// A node of the building's drawing BSP as the portal-only walk
+/// sees it (retail tags: 'FAIL' leaf = stop, 'PORT' = portal node with
+/// in_portals, anything else = plain splitting node).
+public sealed class WalkBspNode
+{
+ public WalkPlane SplittingPlane;
+ public WalkBspNode? PosNode;
+ public WalkBspNode? NegNode;
+ public bool IsFail;
+ public WalkPortalRef[]? InPortals; // non-null = PORT node
+
+ public bool IsPortal => InPortals is not null;
+}
+
+/// The walk's building model (retail CBuildingObj +
+/// BuildInfo as the frame walk consumes them).
+public sealed class WalkBuilding
+{
+ /// Position.objcell_id — the id the oracle's BLD events
+ /// carry (CBuildingObj+0x4C).
+ public uint PositionCellId;
+
+ public WalkBldPortal[] Portals = [];
+
+ /// The drawing BSP of part 0's GfxObj (portal-only view).
+ public WalkBspNode? DrawingBsp;
+
+ /// part->gfxobj[deg_level] != 0 — a degraded-out slot
+ /// skips the whole building AFTER publishing the portal list.
+ public bool HasGeometry = true;
+}
+
+///
+/// Campaign FW1 — the building look-in machinery, ported from the first
+/// decomp appendix report 3 (docs/research/2026-08-30-fw-walk-pseudocode-appendix.md):
+/// BSPTREE/BSPNODE::build_draw_portals_only @0x00539860/@0x0053c100,
+/// BSPPORTAL::portal_draw_portals_only @0x0053d870,
+/// PView::DrawPortal @0x005a5ab0, and the CBldPortal
+/// PView::ConstructView overload @0x005a59a0.
+///
+/// The invisible-panel primitive (DrawPortalPolyInternal @0x0059bc90
+/// — punch far-Z / seal own-depth) is a GPU submission and belongs to FW2's
+/// ordered stream; here it surfaces as the
+/// punch callback so FW1 conformance can observe when retail would draw it.
+///
+public static class WalkBuildingPortals
+{
+ /// What the portal passes report outward: punches (pass 1) and
+ /// look-in cell floods (pass 2, the oracle's DC ov=… events).
+ public interface IWalkPortalPassSink
+ {
+ /// Pass 1 drew the portal polygon as a far-Z punch
+ /// (DrawPortalPolyInternal(poly, 1)).
+ void OnPunch(WalkPolygon polygon);
+
+ /// Pass 2 completed a look-in flood and retail called
+ /// PView::DrawCells — the DC event with the flood's list.
+ void OnDrawCells(WalkPView pview);
+ }
+
+ ///
+ /// BSPTREE::build_draw_portals_only @0x00539860 + the node/portal
+ /// walkers: dispatch the root, then walk plane-side ordered — the child
+ /// OPPOSITE the viewer first, so portals emit far-to-near. PORT nodes
+ /// emit every in_portal on the POSITIVE and NEGATIVE arms; the IN_PLANE
+ /// arm (|d| ≤ ε) visits the positive child and emits NOTHING.
+ ///
+ public static void BuildDrawPortalsOnly(
+ WalkBspNode? root, int pass, Vector3 viewpointInBuilding,
+ Action emitPortal)
+ {
+ if (root is null || root.IsFail) return;
+ Walk(root, pass, viewpointInBuilding, emitPortal);
+ }
+
+ private static void Walk(
+ WalkBspNode node, int pass, Vector3 viewpoint,
+ Action emitPortal)
+ {
+ while (true)
+ {
+ float d = Vector3.Dot(node.SplittingPlane.Normal, viewpoint) + node.SplittingPlane.D;
+ int side = d > WalkVisibilityMath.Epsilon ? 0
+ : d < -WalkVisibilityMath.Epsilon ? 1 : 2;
+
+ WalkBspNode? next;
+ if (node.IsPortal)
+ {
+ if (side == 0)
+ {
+ Visit(node.NegNode, pass, viewpoint, emitPortal);
+ foreach (WalkPortalRef portal in node.InPortals!)
+ emitPortal(portal, pass);
+ next = node.PosNode;
+ }
+ else if (side == 1)
+ {
+ Visit(node.PosNode, pass, viewpoint, emitPortal);
+ foreach (WalkPortalRef portal in node.InPortals!)
+ emitPortal(portal, pass);
+ next = node.NegNode;
+ }
+ else
+ {
+ Visit(node.PosNode, pass, viewpoint, emitPortal);
+ next = node.NegNode;
+ }
+ }
+ else
+ {
+ if (side == 0)
+ {
+ Visit(node.NegNode, pass, viewpoint, emitPortal);
+ next = node.PosNode;
+ }
+ else
+ {
+ Visit(node.PosNode, pass, viewpoint, emitPortal);
+ next = node.NegNode;
+ }
+ }
+
+ if (next is null || next.IsFail) return;
+ node = next; // retail's tail-continue
+ }
+ }
+
+ private static void Visit(
+ WalkBspNode? child, int pass, Vector3 viewpoint,
+ Action emitPortal)
+ {
+ if (child is null || child.IsFail) return;
+ Walk(child, pass, viewpoint, emitPortal);
+ }
+
+ ///
+ /// PView::DrawPortal @0x005a5ab0 for one emitted portal polygon:
+ /// resolve the CBldPortal, push view slots on its stab cells
+ /// (add_views), run the CBldPortal ConstructView, on pass 2
+ /// success run the look-in DrawCells, then pop the stab views. The
+ /// GPU-state backup/restore and the building-frame re-push are
+ /// submission concerns (FW2); the CPU state here is complete.
+ ///
+ public static bool DrawPortal(
+ WalkPView pview, WalkBuilding building, in WalkPortalRef portalRef,
+ int pass, IWalkBuildingFrameContext ctx, IWalkPortalPassSink sink)
+ {
+ ref readonly WalkBldPortal bldPortal = ref building.Portals[portalRef.PortalIndex];
+ AddViews(bldPortal.StabList, ctx);
+ bool ok = ConstructBuildingView(
+ pview, building, in bldPortal, portalRef.Polygon, pass, ctx, sink);
+ if (ok && pass != 1)
+ sink.OnDrawCells(pview);
+ RemoveViews(bldPortal.StabList, ctx);
+ return ok;
+ }
+
+ ///
+ /// The CBldPortal PView::ConstructView overload @0x005a59a0:
+ /// the viewer's side of the portal plane must EQUAL the authored
+ /// portal_side (IN_PLANE within ±ε fails both gates); the polygon must
+ /// survive the active-view clip with ≥3 points; the destination cell
+ /// must be Visible; the clipped view is appended to its top slot. Pass 1
+ /// punches the polygon; pass ≠ 1 recurses into the interior flood.
+ ///
+ public static bool ConstructBuildingView(
+ WalkPView pview, WalkBuilding building, in WalkBldPortal bldPortal,
+ WalkPolygon polygon, int pass, IWalkBuildingFrameContext ctx,
+ IWalkPortalPassSink sink)
+ {
+ Vector3 viewpoint = ctx.ViewpointInBuilding(building);
+ float d = Vector3.Dot(polygon.Plane.Normal, viewpoint) + polygon.Plane.D;
+ int side = d > WalkVisibilityMath.Epsilon ? 0
+ : d < -WalkVisibilityMath.Epsilon ? 1 : 2;
+ if (bldPortal.PortalSide != 0)
+ {
+ if (side != 1) return false;
+ }
+ else if (side != 0)
+ {
+ return false;
+ }
+
+ Span clipped = stackalloc WalkScreenPoint[64];
+ int n = ctx.ClipBuildingPolygon(building, polygon, side, clipped);
+ if (n == 0) return false;
+
+ WalkCell? cell = ctx.GetVisible(bldPortal.OtherCellId);
+ if (cell is null) return false;
+ if (!WalkCopyView.Append(
+ cell.TopView, clipped[..n], ctx.Rays, ctx.WorldViewpoint))
+ return false;
+
+ if (pass != 2)
+ sink.OnPunch(polygon); // DrawPortalPolyInternal(poly, pass == 1)
+ if (pass != 1)
+ pview.ConstructView(cell, ToEntryIndex(bldPortal.OtherPortalId), ctx.CellContext);
+ return true;
+ }
+
+ private static int ToEntryIndex(int otherPortalId)
+ => otherPortalId < 0 ? 0xFFFF : otherPortalId;
+
+ private static void AddViews(uint[] stabList, IWalkBuildingFrameContext ctx)
+ {
+ foreach (uint id in stabList)
+ ctx.GetVisible(id)?.PushView();
+ }
+
+ private static void RemoveViews(uint[] stabList, IWalkBuildingFrameContext ctx)
+ {
+ foreach (uint id in stabList)
+ ctx.GetVisible(id)?.PopView();
+ }
+}
+
+/// The building half of the frame context: building-local
+/// viewpoint and projection (retail pushes the building's object frame
+/// before the portal pass), plus the shared cell context.
+public interface IWalkBuildingFrameContext
+{
+ Vector3 ViewpointInBuilding(WalkBuilding building);
+
+ /// Project + clip one building-local portal polygon against
+ /// the ACTIVE view (retail: GetClip with do_clip=1 in the building
+ /// frame). Returns the surviving count.
+ int ClipBuildingPolygon(
+ WalkBuilding building, WalkPolygon polygon, int side, Span output);
+
+ WalkCell? GetVisible(uint cellId);
+ IWalkRayCaster Rays { get; }
+ Vector3 WorldViewpoint { get; }
+ IWalkFrameContext CellContext { get; }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingPortalTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingPortalTests.cs
new file mode 100644
index 00000000..631a5917
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingPortalTests.cs
@@ -0,0 +1,212 @@
+using System.Numerics;
+using AcDream.App.Rendering.Walk;
+
+namespace AcDream.App.Tests.Rendering.Walk;
+
+public sealed class WalkBuildingPortalTests
+{
+ private sealed class RecordingSink : WalkBuildingPortals.IWalkPortalPassSink
+ {
+ public readonly List Punches = new();
+ public readonly List DrawCells = new();
+
+ public void OnPunch(WalkPolygon polygon) => Punches.Add(polygon);
+
+ public void OnDrawCells(WalkPView pview)
+ => DrawCells.Add(pview.CellDrawList.Select(c => c.CellId).ToArray());
+ }
+
+ private sealed class Caster : IWalkRayCaster
+ {
+ public Vector3 RayThrough(float screenX, float screenY)
+ => new(screenX, screenY, 100f);
+ }
+
+ private sealed class TestContext : IWalkFrameContext, IWalkBuildingFrameContext
+ {
+ public readonly Dictionary Cells = new();
+ private readonly Matrix4x4 _viewProj;
+ private static readonly Vector2[] RootQuad =
+ [
+ new(0, 480), new(640, 480), new(640, 0), new(0, 0),
+ ];
+
+ public TestContext()
+ {
+ Matrix4x4 view = Matrix4x4.CreateLookAt(
+ Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
+ Matrix4x4 proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f);
+ _viewProj = view * proj;
+ }
+
+ public Vector3 ViewpointIn(WalkCell cell) => Vector3.Zero;
+ public Matrix4x4 ObjectToClip(WalkCell cell) => _viewProj;
+ public WalkCell? GetVisible(uint cellId) => Cells.GetValueOrDefault(cellId);
+ public IWalkRayCaster Rays { get; } = new Caster();
+ public Vector3 WorldViewpoint => Vector3.Zero;
+ public float ViewportWidth => 640f;
+ public float ViewportHeight => 480f;
+
+ public Vector3 ViewpointInBuilding(WalkBuilding building) => Vector3.Zero;
+ public IWalkFrameContext CellContext => this;
+
+ public int ClipBuildingPolygon(
+ WalkBuilding building, WalkPolygon polygon, int side, Span output)
+ {
+ Span projected = stackalloc WalkScreenPoint[polygon.Vertices.Length];
+ for (int i = 0; i < polygon.Vertices.Length; i++)
+ projected[i] = WalkScreenClip.TransformToScreen(
+ polygon.Vertices[i], _viewProj, ViewportWidth, ViewportHeight);
+ if (side != 0)
+ projected.Reverse();
+ return WalkScreenClip.ClipAgainstView(projected, RootQuad, output);
+ }
+ }
+
+ private static WalkPolygon Quad(float z, float half = 0.5f, bool facingViewer = true) => new()
+ {
+ Vertices =
+ [
+ new Vector3(-half, -half, z), new Vector3(half, -half, z),
+ new Vector3(half, half, z), new Vector3(-half, half, z),
+ ],
+ Plane = new WalkPlane(new Vector3(0, 0, facingViewer ? 1f : -1f), facingViewer ? -z : z),
+ };
+
+ private static WalkBspNode PortalNode(WalkPlane plane, params WalkPortalRef[] portals)
+ => new() { SplittingPlane = plane, InPortals = portals };
+
+ // ---- the BSP portal-only walk ----
+
+ [Fact]
+ public void Bsp_walk_emits_the_far_side_first()
+ {
+ var farPortal = new WalkPortalRef { PortalIndex = 0, Polygon = Quad(-4f) };
+ var nearPortal = new WalkPortalRef { PortalIndex = 1, Polygon = Quad(-2f) };
+ // Splitting plane x = 0; viewer at x = +5 (side 0) → NEG child first.
+ var root = new WalkBspNode
+ {
+ SplittingPlane = new WalkPlane(new Vector3(1, 0, 0), 0f),
+ NegNode = PortalNode(new WalkPlane(new Vector3(0, 0, 1), 100f), farPortal),
+ PosNode = PortalNode(new WalkPlane(new Vector3(0, 0, 1), 100f), nearPortal),
+ };
+ var emitted = new List();
+
+ WalkBuildingPortals.BuildDrawPortalsOnly(
+ root, 1, new Vector3(5, 0, 0), (p, _) => emitted.Add(p.PortalIndex));
+ Assert.Equal(new[] { 0, 1 }, emitted);
+
+ emitted.Clear();
+ WalkBuildingPortals.BuildDrawPortalsOnly(
+ root, 1, new Vector3(-5, 0, 0), (p, _) => emitted.Add(p.PortalIndex));
+ Assert.Equal(new[] { 1, 0 }, emitted);
+ }
+
+ [Fact]
+ public void In_plane_portal_node_emits_nothing()
+ {
+ var portal = new WalkPortalRef { PortalIndex = 0, Polygon = Quad(-2f) };
+ // Viewer exactly on the node's splitting plane (|d| <= epsilon).
+ WalkBspNode root = PortalNode(new WalkPlane(new Vector3(1, 0, 0), 0f), portal);
+ var emitted = new List();
+
+ WalkBuildingPortals.BuildDrawPortalsOnly(
+ root, 1, Vector3.Zero, (p, _) => emitted.Add(p.PortalIndex));
+
+ Assert.Empty(emitted);
+ }
+
+ // ---- ConstructView(CBldPortal) + DrawPortal ----
+
+ private static (TestContext ctx, WalkBuilding building, WalkCell interior, WalkPortalRef portalRef)
+ BuildLookInFixture(int portalSide = 0)
+ {
+ var ctx = new TestContext();
+ var interior = new WalkCell
+ {
+ CellId = 0x104,
+ Portals = [new WalkCellPortal
+ {
+ OtherCellId = 0xFFFFFFFF, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0,
+ }],
+ PortalPolygons = [Quad(-2f)],
+ };
+ ctx.Cells[interior.CellId] = interior;
+ var building = new WalkBuilding
+ {
+ PositionCellId = 0xA9B4000Fu,
+ Portals =
+ [
+ new WalkBldPortal
+ {
+ PortalSide = portalSide, OtherCellId = 0x104, OtherPortalId = 0,
+ StabList = [0x104u],
+ },
+ ],
+ };
+ var portalRef = new WalkPortalRef { PortalIndex = 0, Polygon = Quad(-2f) };
+ return (ctx, building, interior, portalRef);
+ }
+
+ [Fact]
+ public void Pass_one_punches_and_appends_the_view_without_flooding()
+ {
+ (TestContext ctx, WalkBuilding building, WalkCell interior, WalkPortalRef portalRef)
+ = BuildLookInFixture();
+ var pview = new WalkPView();
+ var sink = new RecordingSink();
+
+ bool ok = WalkBuildingPortals.DrawPortal(pview, building, portalRef, 1, ctx, sink);
+
+ Assert.True(ok);
+ Assert.Single(sink.Punches);
+ Assert.Empty(sink.DrawCells);
+ Assert.Empty(pview.CellDrawList);
+ Assert.Equal(0, interior.NumView); // stab views popped back
+ }
+
+ [Fact]
+ public void Pass_two_floods_the_interior_and_emits_the_draw_cells_event()
+ {
+ (TestContext ctx, WalkBuilding building, WalkCell interior, WalkPortalRef portalRef)
+ = BuildLookInFixture();
+ var pview = new WalkPView();
+ var sink = new RecordingSink();
+
+ bool ok = WalkBuildingPortals.DrawPortal(pview, building, portalRef, 2, ctx, sink);
+
+ Assert.True(ok);
+ Assert.Empty(sink.Punches); // pass 2 never draws the poly
+ Assert.Single(sink.DrawCells);
+ Assert.Equal(new[] { 0x104u }, sink.DrawCells[0]);
+ }
+
+ [Fact]
+ public void Wrong_viewer_side_rejects_the_look_in()
+ {
+ // portal_side = 1 requires side NEGATIVE; the fixture eye computes
+ // side POSITIVE → rejected: no punch, no view, no flood.
+ (TestContext ctx, WalkBuilding building, WalkCell interior, WalkPortalRef portalRef)
+ = BuildLookInFixture(portalSide: 1);
+ var pview = new WalkPView();
+ var sink = new RecordingSink();
+
+ Assert.False(WalkBuildingPortals.DrawPortal(pview, building, portalRef, 1, ctx, sink));
+ Assert.Empty(sink.Punches);
+ Assert.Empty(sink.DrawCells);
+ }
+
+ [Fact]
+ public void Unloaded_destination_skips_the_punch_silently()
+ {
+ (TestContext ctx, WalkBuilding building, WalkCell interior, WalkPortalRef portalRef)
+ = BuildLookInFixture();
+ ctx.Cells.Remove(0x104); // destination not Visible
+
+ var pview = new WalkPView();
+ var sink = new RecordingSink();
+
+ Assert.False(WalkBuildingPortals.DrawPortal(pview, building, portalRef, 1, ctx, sink));
+ Assert.Empty(sink.Punches); // no fallback seal on the outdoor path
+ }
+}