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 + } +}