diff --git a/src/AcDream.App/Rendering/Walk/WalkPView.cs b/src/AcDream.App/Rendering/Walk/WalkPView.cs new file mode 100644 index 00000000..3e5f7984 --- /dev/null +++ b/src/AcDream.App/Rendering/Walk/WalkPView.cs @@ -0,0 +1,448 @@ +using System.Numerics; + +namespace AcDream.App.Rendering.Walk; + +/// +/// Campaign FW1 — retail PView's interior flood, transcribed from the +/// flood-read appendix (docs/research/2026-08-30-fw-flood-pseudocode-appendix.md) +/// plus the raw bodies re-read for AddToCell @0x005a4d90, AdjustDrawList +/// @0x005a4e90, AdjustCellPlace @0x005a5010, FixCellList @0x005a5250, +/// AdjustCellView @0x005a5770, and ConstructView @0x005a57b0. +/// +/// PORTAL-FLAG CONVENTION (resolved 2026-08-30 — the PDB names mislead): +/// inflag (+4) == 1 marks a portal whose polygon FACES the viewer as +/// a visible surface (it feeds max_indist); inflag == 0 with +/// seen (+0) == 1 marks an OPENING on the viewer's look-through side +/// (InitCell's side == portal_side arm — the same side condition +/// ConstructView(CBldPortal) REQUIRES for building look-ins). +/// ClipPortals' traversal gate seen != 0 && inflag != 1 +/// therefore selects exactly the armed openings; the entry portal is forced +/// {seen=1, inflag=1} so the flood never walks back out of it. Transcribe; +/// do not "fix" the names. +/// +public sealed class WalkPView +{ + private static int _masterTimestamp; + + private readonly struct TodoEntry(WalkCell cell, float dist) + { + public readonly WalkCell Cell = cell; + public readonly float Dist = dist; + } + + public readonly WalkPortalView OutsideView = new(); + public readonly List CellDrawList = new(); + private readonly List _todo = new(); + private readonly WalkPortalView _otherPortalScratch = new(); // retail's static temp_view + private Vector2[] _activeViewVerts = new Vector2[32]; + private int _activeViewVertCount; + private readonly WalkScreenPoint[] _projectScratch = new WalkScreenPoint[64]; + private readonly WalkScreenPoint[] _clipScratch = new WalkScreenPoint[64]; + + /// Retail PView.draw_landscape: exit portals raise + /// only when set. + public bool DrawLandscape = true; + + /// Retail Render::PortalList as ClipPortals installs it — + /// the last processed cell's top view (consumed by the landscape draw). + public WalkPortalView? PortalList { get; private set; } + + // ------------------------------------------------------------------ + // ConstructView (CEnvCell overload) @0x005a57b0 — the flood. + // ------------------------------------------------------------------ + public void ConstructView(WalkCell seed, int throughPortalIndex, IWalkFrameContext ctx) + { + OutsideView.ResetForPush(); // outside_view.view_count = 0 (counters only) + _masterTimestamp++; + _todo.Clear(); + CellDrawList.Clear(); + InitCell(seed, throughPortalIndex, ctx); + InsCellTodoList(seed, 0f); + while (_todo.Count > 0) + { + WalkCell cell = _todo[^1].Cell; + _todo.RemoveAt(_todo.Count - 1); + CellDrawList.Add(cell); + cell.TopView.CellViewDone = true; + if (ClipPortals(cell, 0, ctx)) + AddViewToPortals(cell, ctx); + } + } + + // ------------------------------------------------------------------ + // InitCell @0x005a4b70 (Ghidra-verified raw re-read 2026-08-30). + // ------------------------------------------------------------------ + public bool InitCell(WalkCell cell, int entryPortalIndex, IWalkFrameContext ctx) + { + WalkPortalView top = cell.TopView; + if (top.ViewCount == 0) return false; + + Vector3 viewpoint = ctx.ViewpointIn(cell); + top.CellViewDone = false; + top.ViewTimestamp = _masterTimestamp; + if (top.PortalFlags.Length < cell.Portals.Length) + top.PortalFlags = new WalkPortalFlags[cell.Portals.Length]; + + float maxDistSquared = 0f; + bool anyLookThrough = false; // retail var_4 (uninitialized there; benign — init false) + + for (int i = 0; i < cell.Portals.Length; i++) + { + ref WalkPortalFlags flags = ref top.PortalFlags[i]; + WalkPolygon poly = cell.PortalPolygons[cell.Portals[i].PolygonIndex]; + + if (i == entryPortalIndex && !flags.InView) + { + // Entered-through portal: forced facing + armed — never re-traversed. + flags.InView = true; + flags.Seen = true; + } + else + { + flags.Seen = false; + float d = Vector3.Dot(poly.Plane.Normal, viewpoint) + poly.Plane.D; + if (d <= WalkVisibilityMath.Epsilon && d >= -WalkVisibilityMath.Epsilon) + { + flags.InView = false; // IN_PLANE: neither surface nor opening + anyLookThrough = true; + } + else + { + int side = d > WalkVisibilityMath.Epsilon ? 0 : 1; + if (side == cell.Portals[i].PortalSide) + { + flags.InView = false; // viewer on the look-through side (opening) + anyLookThrough = true; + } + else + { + flags.InView = true; // portal polygon faces the viewer + } + } + } + + if (flags.InView) + { + foreach (Vector3 v in poly.Vertices) + { + float dx = viewpoint.X - v.X; + float dy = viewpoint.Y - v.Y; + float dz = viewpoint.Z - v.Z; + float d2 = dx * dx + dy * dy + dz * dz; + if (d2 > maxDistSquared) maxDistSquared = d2; + } + } + } + top.MaxInDistSquared = maxDistSquared; + + // Fixup: arm every opening (retail runs this once per view with a + // vestigial set_view per pass; the flag writes are idempotent). + if (anyLookThrough && top.ViewCount > 0) + { + for (int j = 0; j < cell.Portals.Length; j++) + { + ref WalkPortalFlags flags = ref top.PortalFlags[j]; + if (!flags.InView && !flags.Seen) flags.Seen = true; + } + } + + top.UpdateCount = top.ViewCount; + return true; + } + + // ------------------------------------------------------------------ + // InsCellTodoList @0x005a4f50 — descending by dist from index 0; + // END = nearest; pop-from-END = nearest-first (the Ghidra-corrected + // polarity: the sink stops under the first STRICTLY greater key). + // ------------------------------------------------------------------ + public void InsCellTodoList(WalkCell cell, float dist) + { + int pos = _todo.Count; + while (pos > 0 && !(dist < _todo[pos - 1].Dist)) + pos--; + _todo.Insert(pos, new TodoEntry(cell, dist)); + } + + // ------------------------------------------------------------------ + // ClipPortals @0x005a5520. + // ------------------------------------------------------------------ + public bool ClipPortals(WalkCell cell, int startView, IWalkFrameContext ctx) + { + WalkPortalView top = cell.TopView; + PortalList = top; // set BEFORE any early-out (retail) + if (cell.Portals.Length <= 0) return false; + + // Pass 1: which portals are live; resolve+cache neighbors. + bool anyLive = false; + for (int j = 0; j < cell.Portals.Length; j++) + { + ref WalkPortalFlags flags = ref top.PortalFlags[j]; + if (!flags.Seen || flags.InView) continue; + ref WalkCellPortal portal = ref cell.Portals[j]; + if (cell.CachedNeighbors[j] is null && portal.OtherCellId != 0xFFFFFFFFu) + { + cell.CachedNeighbors[j] = ctx.GetVisible(portal.OtherCellId); + if (cell.CachedNeighbors[j] is null) continue; // not loaded: silently dead + } + anyLive = true; + } + if (!anyLive) return false; + + // Pass 2: clip every live portal against each view in the window. + for (int i = startView; i < top.ViewCount; i++) + { + SetView(top, i); + for (int j = 0; j < cell.Portals.Length; j++) + { + ref WalkPortalFlags flags = ref top.PortalFlags[j]; + if (!flags.Seen || flags.InView) continue; + ref WalkCellPortal portal = ref cell.Portals[j]; + int n = GetClip( + cell, portal.PortalSide, + cell.PortalPolygons[portal.PolygonIndex], + doClip: true, ctx, _clipScratch); + if (n == 0) continue; + + if (portal.OtherCellId == 0xFFFFFFFFu) + { + if (DrawLandscape) + { + if (ctx.ClipLandscape) + WalkCopyView.Append( + OutsideView, _clipScratch.AsSpan(0, n), + ctx.Rays, ctx.WorldViewpoint); + else + WalkCopyView.AppendFullViewportQuad( + OutsideView, ctx.Rays, ctx.WorldViewpoint, + ctx.ViewportWidth, ctx.ViewportHeight); + } + } + else if (cell.CachedNeighbors[j] is WalkCell neighbor) + { + if (!portal.ExactMatch && portal.OtherPortalId >= 0) + { + n = OtherPortalClip(cell, j, n, ctx); + SetView(top, i); // restore after the far-frame excursion + if (n == 0) continue; + } + if (neighbor.NumView != 0) + WalkCopyView.Append( + neighbor.TopView, _clipScratch.AsSpan(0, n), + ctx.Rays, ctx.WorldViewpoint); + } + } + } + return true; + } + + // ------------------------------------------------------------------ + // OtherPortalClip @0x005a5400 — the double clip for non-exact_match + // portals: snapshot the near-clipped poly as a temp view, then re-clip + // against the FAR cell's own portal polygon with INVERTED sidedness + // (a literal == 0 test, not an XOR). + // ------------------------------------------------------------------ + private int OtherPortalClip( + WalkCell cell, int portalIndex, int count, IWalkFrameContext ctx) + { + _otherPortalScratch.ResetForPush(); + if (!WalkCopyView.Append( + _otherPortalScratch, _clipScratch.AsSpan(0, count), + ctx.Rays, ctx.WorldViewpoint)) + return 0; + ref WalkCellPortal portal = ref cell.Portals[portalIndex]; + WalkCell far = cell.CachedNeighbors[portalIndex] + ?? throw new InvalidOperationException( + "OtherPortalClip requires a resolved neighbor (ClipPortals pass 1 contract)."); + ref WalkCellPortal farPortal = ref far.Portals[portal.OtherPortalId]; + SetView(_otherPortalScratch, 0); + return GetClip( + far, farPortal.PortalSide == 0 ? 1 : 0, + far.PortalPolygons[farPortal.PolygonIndex], + doClip: true, ctx, _clipScratch); + } + + // ------------------------------------------------------------------ + // AddViewToPortals @0x005a52d0. + // ------------------------------------------------------------------ + public void AddViewToPortals(WalkCell cell, IWalkFrameContext ctx) + { + for (int j = 0; j < cell.Portals.Length; j++) + { + ref WalkCellPortal portal = ref cell.Portals[j]; + WalkCell? neighbor = cell.CachedNeighbors[j]; + ref WalkPortalFlags flags = ref cell.TopView.PortalFlags[j]; + if (neighbor is null || flags.InView || !flags.Seen || neighbor.NumView == 0) + continue; + WalkPortalView neighborTop = neighbor.TopView; + if (neighborTop.ViewCount == 0) continue; + + if (neighborTop.UpdateCount == 0) + { + // First touch this flood: schedule the neighbor. + if (InitCell(neighbor, ToEntryIndex(portal.OtherPortalId), ctx)) + InsCellTodoList(neighbor, neighborTop.MaxInDistSquared); + } + else if (neighborTop.UpdateCount != neighborTop.ViewCount) + { + // Duplicate reach with NEW views since last processed. + AddToCell(neighbor, ToEntryIndex(portal.OtherPortalId)); + if (neighborTop.CellViewDone) + FixCellList(neighbor, cell, ctx); + neighborTop.UpdateCount = neighborTop.ViewCount; // fresh re-read after recursion + } + else + { + continue; // nothing new; NO SetOtherSeen either + } + + if (portal.OtherPortalId >= 0) // full-width signed test (−1 sentinel skips) + SetOtherSeen(cell, j); + } + } + + /// Retail passes other_portal_id as a ZERO-EXTENDED 16-bit read: + /// −1 becomes 0xFFFF, InitCell/AddToCell's never-matching sentinel. + private static int ToEntryIndex(int otherPortalId) + => otherPortalId < 0 ? 0xFFFF : otherPortalId; + + // ------------------------------------------------------------------ + // AddToCell @0x005a4d90 — the duplicate-reach flag refresh over the + // NEW views window only. + // ------------------------------------------------------------------ + public void AddToCell(WalkCell cell, int entryPortalIndex) + { + WalkPortalView top = cell.TopView; + for (int i = top.UpdateCount; i < top.ViewCount; i++) + { + // retail set_view(top.view, i) here is side-effect-only (vestigial) + for (int j = 0; j < cell.Portals.Length; j++) + { + ref WalkPortalFlags flags = ref top.PortalFlags[j]; + if (j == entryPortalIndex && !flags.InView) flags.InView = true; + if (!flags.InView && !flags.Seen) flags.Seen = true; + } + } + } + + // ------------------------------------------------------------------ + // SetOtherSeen @0x005a4e30: fill the neighbor's backlink and arm its + // back-portal when that portal faces the neighbor's viewer. + // ------------------------------------------------------------------ + public void SetOtherSeen(WalkCell cell, int portalIndex) + { + WalkCell? neighbor = cell.CachedNeighbors[portalIndex]; + if (neighbor is null) return; + int backIndex = cell.Portals[portalIndex].OtherPortalId; + ref WalkCellPortal backPortal = ref neighbor.Portals[backIndex]; + if (neighbor.CachedNeighbors[backIndex] is null) + neighbor.CachedNeighbors[backIndex] = cell; + ref WalkPortalFlags backFlags = ref neighbor.TopView.PortalFlags[backIndex]; + if (backFlags.InView) backFlags.Seen = true; + } + + // ------------------------------------------------------------------ + // FixCellList @0x005a5250 = AdjustCellPlace + AdjustCellView. + // ------------------------------------------------------------------ + public void FixCellList(WalkCell moved, WalkCell reachedThrough, IWalkFrameContext ctx) + { + AdjustCellPlace(moved, reachedThrough); + AdjustCellView(moved, ctx); + } + + // AdjustCellPlace @0x005a5010: re-place, then recurse through the + // reaching cell's armed facing portals. + private void AdjustCellPlace(WalkCell moved, WalkCell reachedThrough) + { + WalkPortalView top = reachedThrough.TopView; + if (!AdjustDrawList(moved, reachedThrough)) return; + for (int i = 0; i < reachedThrough.Portals.Length; i++) + { + ref WalkPortalFlags flags = ref top.PortalFlags[i]; + if (flags.Seen && flags.InView && reachedThrough.CachedNeighbors[i] is WalkCell next) + AdjustCellPlace(reachedThrough, next); + } + } + + // AdjustDrawList @0x005a4e90: if `moved` appears in the draw list BEFORE + // `reachedThrough`, insert `reachedThrough` at `moved`'s slot (shifting + // the range up) so the walk-from-the-end draws `reachedThrough` earlier + // (farther). Appends `reachedThrough` when absent. Returns true when a + // move happened. + private bool AdjustDrawList(WalkCell moved, WalkCell reachedThrough) + { + for (int i = 0; i < CellDrawList.Count; i++) + { + uint id = CellDrawList[i].CellId; + if (id == reachedThrough.CellId) break; // already earlier: nothing to do + if (id != moved.CellId) continue; + + int at = i; + while (at < CellDrawList.Count && CellDrawList[at].CellId != reachedThrough.CellId) + at++; + if (at == CellDrawList.Count) + CellDrawList.Add(reachedThrough); // conceptual append (grown below by the shift) + for (int k = at; k > i; k--) + CellDrawList[k] = CellDrawList[k - 1]; + CellDrawList[i] = reachedThrough; + return true; + } + return false; + } + + // AdjustCellView @0x005a5770: incremental re-flood over the new-views + // window only (the update_count watermark). + private void AdjustCellView(WalkCell cell, IWalkFrameContext ctx) + { + if (ClipPortals(cell, cell.TopView.UpdateCount, ctx)) + AddViewToPortals(cell, ctx); + } + + // ------------------------------------------------------------------ + // set_view @0x0054d0e0 (the slice GetClip consumes: the installed + // view's screen vertices). + // ------------------------------------------------------------------ + public void SetView(WalkPortalView portalView, int polyIndex) + { + WalkViewPoly poly = portalView.View.Polys[polyIndex]; + if (_activeViewVerts.Length < poly.VertexCount) + _activeViewVerts = new Vector2[poly.VertexCount]; + for (int k = 0; k < poly.VertexCount; k++) + _activeViewVerts[k] = portalView.View.Vertices[poly.VertexIndex + k].Point; + _activeViewVertCount = poly.VertexCount; + } + + // ------------------------------------------------------------------ + // PView::GetClip @0x005a4320: project, order by sidedness, optionally + // clip against the installed view. Returns the surviving count (0 on + // full rejection — the caller's pre-zero contract). + // ------------------------------------------------------------------ + public int GetClip( + WalkCell cell, int side, WalkPolygon polygon, bool doClip, + IWalkFrameContext ctx, Span output) + { + int n = polygon.Vertices.Length; + Matrix4x4 objectToClip = ctx.ObjectToClip(cell); + for (int i = 0; i < n; i++) + { + _projectScratch[i] = WalkScreenClip.TransformToScreen( + polygon.Vertices[i], objectToClip, ctx.ViewportWidth, ctx.ViewportHeight); + } + // Sidedness != POSITIVE reverses the winding (IN_PLANE takes the + // reversed branch too, though flood callers never pass it). + if (side != 0) + { + for (int i = 0; i < n / 2; i++) + (_projectScratch[i], _projectScratch[n - 1 - i]) + = (_projectScratch[n - 1 - i], _projectScratch[i]); + } + if (!doClip) + { + _projectScratch.AsSpan(0, n).CopyTo(output); + return n; + } + return WalkScreenClip.ClipAgainstView( + _projectScratch.AsSpan(0, n), + _activeViewVerts.AsSpan(0, _activeViewVertCount), + output); + } +} diff --git a/src/AcDream.App/Rendering/Walk/WalkWorld.cs b/src/AcDream.App/Rendering/Walk/WalkWorld.cs new file mode 100644 index 00000000..84a30759 --- /dev/null +++ b/src/AcDream.App/Rendering/Walk/WalkWorld.cs @@ -0,0 +1,89 @@ +using System.Numerics; + +namespace AcDream.App.Rendering.Walk; + +/// A cell-local portal polygon with its plane (retail +/// CPolygon: vertices + plane @+0x20). +public sealed class WalkPolygon +{ + public Vector3[] Vertices = []; + public WalkPlane Plane; +} + +/// Retail CCellPortal (stride 0x18). The exit-to-landscape +/// sentinel is OtherCellId == 0xFFFFFFFF; OtherPortalId == -1 +/// means "no reciprocal". +public struct WalkCellPortal +{ + public uint OtherCellId; + public int PolygonIndex; + public int PortalSide; + public int OtherPortalId; + public bool ExactMatch; +} + +/// +/// The flood's cell model (retail CEnvCell as the walk sees it). +/// Retail stores the walk state ON the cell (num_view / portal_view stack / +/// cached neighbor pointers); this port keeps the same placement so the +/// transcription stays line-by-line — production adapters construct these +/// from the committed cell registry (FW3 wiring). +/// +public sealed class WalkCell +{ + public uint CellId; + public WalkCellPortal[] Portals = []; + public WalkPolygon[] PortalPolygons = []; + public uint[] StabList = []; + + // ---- walk state (retail: fields on CEnvCell) ---- + public int NumView; + public readonly List PortalViews = new(); + public WalkCell?[] CachedNeighbors = []; + + public WalkPortalView TopView => PortalViews[NumView - 1]; + + /// CEnvCell::curr_view_push @0x005a5090: push one + /// view-recursion level (lazy slot, exactly three counters reset). + public void PushView() + { + while (PortalViews.Count <= NumView) + PortalViews.Add(new WalkPortalView()); + PortalViews[NumView].ResetForPush(); + NumView++; + if (CachedNeighbors.Length < Portals.Length) + CachedNeighbors = new WalkCell?[Portals.Length]; + } + + public void PopView() => NumView--; +} + +/// +/// The per-frame context retail keeps in globals (Render::FrameCurrent +/// after positionPush(3, cell.pos), the projection state, the visible +/// cell registry, and the cliplandscape toggle — .data default 1). +/// +public interface IWalkFrameContext +{ + /// The eye position in the cell's local frame (retail: the + /// pushed frame's viewer.viewpoint). + Vector3 ViewpointIn(WalkCell cell); + + /// Object(cell-local)→clip matrix for projecting the cell's + /// portal polygons (retail: the pushed frame composed with + /// WorldToView·ViewToClip). + Matrix4x4 ObjectToClip(WalkCell cell); + + /// CEnvCell::GetVisible: the committed/visible cell + /// registry. Returning null skips the portal silently (retail behavior — + /// but implementations should count the miss for the fail-loud rule). + WalkCell? GetVisible(uint cellId); + + IWalkRayCaster Rays { get; } + Vector3 WorldViewpoint { get; } + float ViewportWidth { get; } + float ViewportHeight { get; } + + /// Retail global cliplandscape (.data @0x00820f4c = 1). + bool ClipLandscape => true; +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkPViewFloodTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkPViewFloodTests.cs new file mode 100644 index 00000000..18f4e79f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkPViewFloodTests.cs @@ -0,0 +1,192 @@ +using System.Numerics; +using AcDream.App.Rendering.Walk; + +namespace AcDream.App.Tests.Rendering.Walk; + +public sealed class WalkPViewFloodTests +{ + private sealed class TestContext : IWalkFrameContext + { + private sealed class Caster : IWalkRayCaster + { + public Vector3 RayThrough(float screenX, float screenY) + => new(screenX, screenY, 100f); + } + + public readonly Dictionary Cells = new(); + private readonly Matrix4x4 _viewProj; + + 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; + } + + // All test cells sit at identity transforms: cell-local == world. + 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; + } + + 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 through the quad: for z=-2 facing +z, N=(0,0,1), D=2 (eye at + // origin sits on the POSITIVE side: d = +2). + Plane = new WalkPlane(new Vector3(0, 0, facingViewer ? 1f : -1f), facingViewer ? -z : z), + }; + + private static WalkCell Cell( + TestContext ctx, uint id, params (WalkCellPortal Portal, WalkPolygon Polygon)[] portals) + { + var cell = new WalkCell + { + CellId = id, + Portals = portals.Select(p => p.Portal).ToArray(), + PortalPolygons = portals.Select(p => p.Polygon).ToArray(), + }; + cell.PushView(); // add_views/stab-list stand-in: one pushed slot + ctx.Cells[id] = cell; + return cell; + } + + private static WalkPView SeedAndFlood(TestContext ctx, WalkCell seed) + { + var pview = new WalkPView(); + WalkCopyView.AppendFullViewportQuad( + seed.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight); + pview.ConstructView(seed, 0xFFFF, ctx); + return pview; + } + + [Fact] + public void Flood_traverses_a_look_through_portal_into_the_neighbor() + { + var ctx = new TestContext(); + // Eye on the positive side of the portal plane (d=+2 → side 0); + // PortalSide=0 → side == PortalSide → an OPENING (look-through). + WalkCell seed = Cell(ctx, 0x100, + (new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 }, + Quad(-2f))); + WalkCell neighbor = Cell(ctx, 0x101, + (new WalkCellPortal { OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0 }, + Quad(-2f))); + + WalkPView pview = SeedAndFlood(ctx, seed); + + Assert.Equal(new[] { 0x100u, 0x101u }, pview.CellDrawList.Select(c => c.CellId)); + Assert.Equal(1, neighbor.TopView.ViewCount); // the clipped view arrived + Assert.True(neighbor.TopView.CellViewDone); // and the neighbor was processed + } + + [Fact] + public void Facing_portal_is_not_traversed_but_feeds_the_distance_key() + { + var ctx = new TestContext(); + // PortalSide=1 while the eye computes side 0 → the portal FACES the + // viewer (a visible surface, not an opening). + WalkCell seed = Cell(ctx, 0x100, + (new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0 }, + Quad(-2f))); + Cell(ctx, 0x101, + (new WalkCellPortal { OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 }, + Quad(-2f))); + + WalkPView pview = SeedAndFlood(ctx, seed); + + Assert.Equal(new[] { 0x100u }, pview.CellDrawList.Select(c => c.CellId)); + // max_indist = squared distance to the farthest facing-portal vertex: + // (±0.5, ±0.5, −2) from the origin → 0.25 + 0.25 + 4. + Assert.Equal(4.5f, seed.TopView.MaxInDistSquared, 3); + } + + [Fact] + public void Exit_portal_raises_the_outside_view_only_when_landscape_is_drawn() + { + var ctx = new TestContext(); + WalkCell seed = Cell(ctx, 0x100, + (new WalkCellPortal { OtherCellId = 0xFFFFFFFF, PolygonIndex = 0, PortalSide = 0, OtherPortalId = -1 }, + Quad(-2f))); + + WalkPView pview = SeedAndFlood(ctx, seed); + Assert.Equal(1, pview.OutsideView.ViewCount); + + // draw_landscape == 0 discards exit views entirely. + seed.PopView(); + seed.PushView(); + var noLandscape = new WalkPView { DrawLandscape = false }; + WalkCopyView.AppendFullViewportQuad( + seed.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight); + noLandscape.ConstructView(seed, 0xFFFF, ctx); + Assert.Equal(0, noLandscape.OutsideView.ViewCount); + } + + [Fact] + public void Flood_never_walks_back_through_the_entry_portal() + { + var ctx = new TestContext(); + // Two cells looking into each other; both sides traversable by + // geometry. Without the entry-portal force, the flood would ping-pong. + WalkCell a = Cell(ctx, 0x100, + (new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 }, + Quad(-2f))); + WalkCell b = Cell(ctx, 0x101, + (new WalkCellPortal { OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 }, + Quad(-2f))); + + WalkPView pview = SeedAndFlood(ctx, a); + + Assert.Equal(2, pview.CellDrawList.Count); + Assert.Equal(2, pview.CellDrawList.Select(c => c.CellId).Distinct().Count()); + } + + [Fact] + public void Deeper_chain_floods_in_nearest_first_pop_order() + { + var ctx = new TestContext(); + // seed → mid (portal at z=-2) → far (portal at z=-4): the draw list + // appends in pop order (nearest first), so the end-first draw walk + // is far-to-near. + WalkCell seed = Cell(ctx, 0x100, + (new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 }, + Quad(-2f))); + WalkCell mid = Cell(ctx, 0x101, + (new WalkCellPortal { OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0 }, + Quad(-2f)), + (new WalkCellPortal { OtherCellId = 0x102, PolygonIndex = 1, PortalSide = 0, OtherPortalId = 0 }, + Quad(-4f, half: 0.4f))); + WalkCell far = Cell(ctx, 0x102, + (new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 1 }, + Quad(-4f, half: 0.4f))); + + WalkPView pview = SeedAndFlood(ctx, seed); + + Assert.Equal( + new[] { 0x100u, 0x101u, 0x102u }, + pview.CellDrawList.Select(c => c.CellId)); + Assert.Equal(1, far.TopView.ViewCount); + } + + [Fact] + public void Unloaded_neighbor_is_silently_skipped() + { + var ctx = new TestContext(); + WalkCell seed = Cell(ctx, 0x100, + (new WalkCellPortal { OtherCellId = 0x0DEAD, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 }, + Quad(-2f))); + + WalkPView pview = SeedAndFlood(ctx, seed); + + Assert.Equal(new[] { 0x100u }, pview.CellDrawList.Select(c => c.CellId)); + } +}