feat(render) Campaign FW1: port the building look-in machinery
WalkBuildingPortals transcribes the portal-only drawing-BSP walk (BSPTREE/BSPNODE::build_draw_portals_only @0x00539860/@0x0053c100, BSPPORTAL::portal_draw_portals_only @0x0053d870 - opposite-child-first = far-to-near emission, IN_PLANE PORT arm emits nothing), PView::DrawPortal @0x005a5ab0 (stab add/remove_views around the look-in, DrawCells on pass-2 success), and the CBldPortal PView::ConstructView overload @0x005a59a0 (side must EQUAL the authored portal_side, clip survival, Visible destination, punch on pass 1, flood recursion on pass 2). Punch surfaces via the pass sink for FW1 conformance; the depth-fan submission itself is FW2. Six tests: BSP emission order both viewer sides, in-plane suppression, pass-1 punch-no-flood, pass-2 flood + DC event, sidedness rejection, unloaded destination skip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
161ffaf4e9
commit
f05b93b5b5
2 changed files with 471 additions and 0 deletions
259
src/AcDream.App/Rendering/Walk/WalkBuilding.cs
Normal file
259
src/AcDream.App/Rendering/Walk/WalkBuilding.cs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering.Walk;
|
||||
|
||||
/// <summary>Retail <c>CBldPortal</c>: 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 (<c>PView::add_views</c>/<c>remove_views</c>).</summary>
|
||||
public struct WalkBldPortal
|
||||
{
|
||||
public int PortalSide;
|
||||
public uint OtherCellId;
|
||||
public int OtherPortalId;
|
||||
public bool ExactMatch;
|
||||
public uint[] StabList;
|
||||
}
|
||||
|
||||
/// <summary>One <c>CPortalPoly</c> reference on a PORT BSP node: the index
|
||||
/// into the building's portal list plus the portal polygon (building-local).</summary>
|
||||
public struct WalkPortalRef
|
||||
{
|
||||
public int PortalIndex;
|
||||
public WalkPolygon Polygon;
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The walk's building model (retail <c>CBuildingObj</c> +
|
||||
/// <c>BuildInfo</c> as the frame walk consumes them).</summary>
|
||||
public sealed class WalkBuilding
|
||||
{
|
||||
/// <summary><c>Position.objcell_id</c> — the id the oracle's BLD events
|
||||
/// carry (CBuildingObj+0x4C).</summary>
|
||||
public uint PositionCellId;
|
||||
|
||||
public WalkBldPortal[] Portals = [];
|
||||
|
||||
/// <summary>The drawing BSP of part 0's GfxObj (portal-only view).</summary>
|
||||
public WalkBspNode? DrawingBsp;
|
||||
|
||||
/// <summary><c>part->gfxobj[deg_level] != 0</c> — a degraded-out slot
|
||||
/// skips the whole building AFTER publishing the portal list.</summary>
|
||||
public bool HasGeometry = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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):
|
||||
/// <c>BSPTREE/BSPNODE::build_draw_portals_only</c> @0x00539860/@0x0053c100,
|
||||
/// <c>BSPPORTAL::portal_draw_portals_only</c> @0x0053d870,
|
||||
/// <c>PView::DrawPortal</c> @0x005a5ab0, and the CBldPortal
|
||||
/// <c>PView::ConstructView</c> overload @0x005a59a0.
|
||||
///
|
||||
/// The invisible-panel primitive (<c>DrawPortalPolyInternal</c> @0x0059bc90
|
||||
/// — punch far-Z / seal own-depth) is a GPU submission and belongs to FW2's
|
||||
/// ordered stream; here it surfaces as the <see cref="IWalkPortalPassSink"/>
|
||||
/// punch callback so FW1 conformance can observe when retail would draw it.
|
||||
/// </summary>
|
||||
public static class WalkBuildingPortals
|
||||
{
|
||||
/// <summary>What the portal passes report outward: punches (pass 1) and
|
||||
/// look-in cell floods (pass 2, the oracle's <c>DC ov=…</c> events).</summary>
|
||||
public interface IWalkPortalPassSink
|
||||
{
|
||||
/// <summary>Pass 1 drew the portal polygon as a far-Z punch
|
||||
/// (<c>DrawPortalPolyInternal(poly, 1)</c>).</summary>
|
||||
void OnPunch(WalkPolygon polygon);
|
||||
|
||||
/// <summary>Pass 2 completed a look-in flood and retail called
|
||||
/// <c>PView::DrawCells</c> — the DC event with the flood's list.</summary>
|
||||
void OnDrawCells(WalkPView pview);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>BSPTREE::build_draw_portals_only</c> @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.
|
||||
/// </summary>
|
||||
public static void BuildDrawPortalsOnly(
|
||||
WalkBspNode? root, int pass, Vector3 viewpointInBuilding,
|
||||
Action<WalkPortalRef, int> emitPortal)
|
||||
{
|
||||
if (root is null || root.IsFail) return;
|
||||
Walk(root, pass, viewpointInBuilding, emitPortal);
|
||||
}
|
||||
|
||||
private static void Walk(
|
||||
WalkBspNode node, int pass, Vector3 viewpoint,
|
||||
Action<WalkPortalRef, int> 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<WalkPortalRef, int> emitPortal)
|
||||
{
|
||||
if (child is null || child.IsFail) return;
|
||||
Walk(child, pass, viewpoint, emitPortal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>PView::DrawPortal</c> @0x005a5ab0 for one emitted portal polygon:
|
||||
/// resolve the CBldPortal, push view slots on its stab cells
|
||||
/// (<c>add_views</c>), 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The CBldPortal <c>PView::ConstructView</c> 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.
|
||||
/// </summary>
|
||||
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<WalkScreenPoint> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public interface IWalkBuildingFrameContext
|
||||
{
|
||||
Vector3 ViewpointInBuilding(WalkBuilding building);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
int ClipBuildingPolygon(
|
||||
WalkBuilding building, WalkPolygon polygon, int side, Span<WalkScreenPoint> output);
|
||||
|
||||
WalkCell? GetVisible(uint cellId);
|
||||
IWalkRayCaster Rays { get; }
|
||||
Vector3 WorldViewpoint { get; }
|
||||
IWalkFrameContext CellContext { get; }
|
||||
}
|
||||
|
|
@ -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<WalkPolygon> Punches = new();
|
||||
public readonly List<uint[]> 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<uint, WalkCell> 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<WalkScreenPoint> output)
|
||||
{
|
||||
Span<WalkScreenPoint> 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<int>();
|
||||
|
||||
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<int>();
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue