From 4b401a08ed4db02f4a63ed6e7dc6f43d627ba6f4 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 30 Aug 2026 10:06:09 +0200 Subject: [PATCH] feat(render) Campaign FW1: the frame-walk composition root RetailFrameWalk composes the ported machinery into retail frame shapes: SmartBox::RenderNormalMode rooting (camera-cell low word < 0x100), PView::DrawInside + the DrawCells event half (interior pview, the traces pv=009d4a80), LScape::draw (per-view visibility, blocks far-to-near, per-block cells far-to-near, buildings at their cell turn) and RenderDeviceD3D::DrawBuilding (BLD at entry before the degrade check; two-pass BSP portal walk per active view on the outdoor pview, the traces pv=009d4b08). WalkLandscape ports the block grid + draw_check_blocks/landcell_check visibility (192 m/24 m pitch, viewer-relative, union across views, never downgrade). Six composition tests: outdoor far-to-near emission, degrade-entry event, interior ov=0/ov=1 shapes, rooting, complete view unwind. Co-Authored-By: Claude Fable 5 --- .../Rendering/Walk/RetailFrameWalk.cs | 194 +++++++++++++++ .../Rendering/Walk/WalkLandscape.cs | 220 +++++++++++++++++ .../Rendering/Walk/RetailFrameWalkTests.cs | 224 ++++++++++++++++++ 3 files changed, 638 insertions(+) create mode 100644 src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs create mode 100644 src/AcDream.App/Rendering/Walk/WalkLandscape.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs diff --git a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs new file mode 100644 index 00000000..2f26f277 --- /dev/null +++ b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs @@ -0,0 +1,194 @@ +using System.Numerics; + +namespace AcDream.App.Rendering.Walk; + +/// The composition context: the cell/building halves plus the +/// frame-level state retail keeps in globals (the CY near plane from +/// Render::update_viewpoint, and the active-view installation the +/// building clip consumes). +public interface IRetailFrameWalkContext : IWalkBuildingFrameContext +{ + WalkPlane CyPlane { get; } + + /// Render::set_view at the frame level: install view + /// of as the active + /// clip context for subsequent building-polygon clips. + void SetActiveView(WalkPortalView views, int index); + + float ViewportWidth { get; } + float ViewportHeight { get; } +} + +/// +/// Campaign FW1 — the frame walk root, composing the ported machinery into +/// retail's frame shapes (model: +/// docs/research/2026-08-30-fw-walk-pseudocode.md §1, §3-§5; oracle: +/// docs/research/2026-08-30-fw-walk-oracle/README.md): +/// +/// +/// Rooting (SmartBox::RenderNormalMode @0x00453aa0): the +/// CAMERA cell's low word < 0x100 → outdoor root (default full-screen +/// view + the landscape walk); otherwise → DrawInside. +/// Outdoor (LScape::draw @0x00506330): visibility per view, +/// blocks far-to-near, per-block cells far-to-near, buildings at their +/// cell's turn with the two-pass portal machinery (punches + look-ins on +/// the OUTDOOR pview — the traces' pv=009d4b08 ov=0). +/// Interior (PView::DrawInside @0x005a5860 + +/// DrawCells @0x005a4840): the flood on the INTERIOR pview +/// (pv=009d4a80), then the landscape drawn THROUGH the exit views +/// when any survive (ov>0). +/// +/// +/// Two PView instances exist exactly as the live traces showed. +/// +public sealed class RetailFrameWalk +{ + private readonly WalkPView _interiorPView = new(); + private readonly WalkPView _outdoorPView = new(); + private readonly WalkPortalView _defaultView = new(); + + public WalkPView InteriorPView => _interiorPView; + public WalkPView OutdoorPView => _outdoorPView; + + /// The per-frame root (SmartBox::RenderNormalMode). + /// may be null only when the camera is + /// outdoors. + public void WalkFrame( + uint cameraCellId, WalkCell? cameraCell, WalkLandscape landscape, + IRetailFrameWalkContext ctx, IWalkEventSink sink) + { + if ((cameraCellId & 0xFFFF) < 0x100) + { + // Render::set_default_view @0x0054ef50: the full-screen quad view. + _defaultView.ResetForPush(); + WalkCopyView.AppendFullViewportQuad( + _defaultView, ctx.Rays, ctx.WorldViewpoint, + ctx.ViewportWidth, ctx.ViewportHeight); + DrawLandscape(landscape, _defaultView, ctx, sink); + } + else + { + DrawInside( + cameraCell ?? throw new ArgumentNullException(nameof(cameraCell)), + landscape, ctx, sink); + } + } + + /// PView::DrawInside + the event half of + /// DrawCells. The geometry/object passes emit no walk events. + public void DrawInside( + WalkCell cell, WalkLandscape landscape, + IRetailFrameWalkContext ctx, IWalkEventSink sink) + { + sink.Emit(WalkEvent.DrawInside(cell.CellId)); + cell.PushView(); + AddViews(cell.StabList, ctx); + WalkCopyView.AppendFullViewportQuad( + cell.TopView, ctx.Rays, ctx.WorldViewpoint, + ctx.ViewportWidth, ctx.ViewportHeight); + _interiorPView.ConstructView(cell, 0xFFFF, ctx.CellContext); + + EmitDrawCells(_interiorPView, sink); + if (_interiorPView.OutsideView.ViewCount > 0) + DrawLandscape(landscape, _interiorPView.OutsideView, ctx, sink); + + RemoveViews(cell.StabList, ctx); + cell.PopView(); + } + + /// LScape::draw: visibility per active view, then blocks + /// far-to-near, cells far-to-near, buildings at their cell's turn. + public void DrawLandscape( + WalkLandscape landscape, WalkPortalView activeViews, + IRetailFrameWalkContext ctx, IWalkEventSink sink) + { + sink.Emit(WalkEvent.Landscape()); + landscape.CalcDrawOrder(); + landscape.CheckBlocks(ctx.CyPlane, activeViews); + + for (int i = landscape.BlockDrawCount - 1; i >= 0; i--) + { + WalkLandBlock? block = landscape.Blocks[landscape.BlockDrawList[i]]; + if (block is null || block.InView == WalkBoundingType.Outside) + continue; + int cellCount = block.SideCellCount * block.SideCellCount; + for (int k = 0; k < cellCount; k++) + { + int cellIndex = block.DrawArray[k]; + // DrawSortCell runs for in-view cells (alwaysDrawObjects + // default 0); terrain (DrawLandCell) emits no walk event. + if (block.CellInView[cellIndex] == WalkBoundingType.Outside) + continue; + if (block.CellBuildings[cellIndex] is WalkBuilding building) + DrawBuilding(building, activeViews, ctx, sink); + } + } + } + + /// RenderDeviceD3D::DrawBuilding @0x0059f2a0 at the walk + /// level: the BLD event fires at ENTRY (before the degrade check); the + /// portal pass walks the drawing BSP twice per active view (punch, then + /// look-in + DrawCells on the outdoor pview); the shell pass emits no + /// walk event. + public void DrawBuilding( + WalkBuilding building, WalkPortalView activeViews, + IRetailFrameWalkContext ctx, IWalkEventSink sink) + { + sink.Emit(WalkEvent.Building(building.PositionCellId)); + if (!building.HasGeometry) return; + + int viewCount = Math.Max(activeViews.ViewCount, 0); + var passSink = new PortalPassSink(sink); + Vector3 viewpoint = ctx.ViewpointInBuilding(building); + for (int v = 0; v < viewCount; v++) + { + ctx.SetActiveView(activeViews, v); + WalkBuildingPortals.BuildDrawPortalsOnly( + building.DrawingBsp, 1, viewpoint, + (portalRef, pass) => WalkBuildingPortals.DrawPortal( + _outdoorPView, building, portalRef, pass, ctx, passSink)); + WalkBuildingPortals.BuildDrawPortalsOnly( + building.DrawingBsp, 2, viewpoint, + (portalRef, pass) => WalkBuildingPortals.DrawPortal( + _outdoorPView, building, portalRef, pass, ctx, passSink)); + } + } + + private void EmitDrawCells(WalkPView pview, IWalkEventSink sink) + { + uint[] cells = new uint[pview.CellDrawList.Count]; + for (int i = 0; i < cells.Length; i++) + cells[i] = pview.CellDrawList[i].CellId; + sink.Emit(WalkEvent.DrawCells(pview.OutsideView.ViewCount, cells)); + } + + private void AddViews(uint[] stabList, IRetailFrameWalkContext ctx) + { + foreach (uint id in stabList) + ctx.GetVisible(id)?.PushView(); + } + + private void RemoveViews(uint[] stabList, IRetailFrameWalkContext ctx) + { + foreach (uint id in stabList) + ctx.GetVisible(id)?.PopView(); + } + + private sealed class PortalPassSink(IWalkEventSink sink) + : WalkBuildingPortals.IWalkPortalPassSink + { + public void OnPunch(WalkPolygon polygon) + { + // The far-Z punch is a depth-only GPU submission (FW2's ordered + // stream); the oracle traces do not log it. + } + + public void OnDrawCells(WalkPView pview) + { + uint[] cells = new uint[pview.CellDrawList.Count]; + for (int i = 0; i < cells.Length; i++) + cells[i] = pview.CellDrawList[i].CellId; + sink.Emit(WalkEvent.DrawCells(pview.OutsideView.ViewCount, cells)); + } + } +} diff --git a/src/AcDream.App/Rendering/Walk/WalkLandscape.cs b/src/AcDream.App/Rendering/Walk/WalkLandscape.cs new file mode 100644 index 00000000..01818a63 --- /dev/null +++ b/src/AcDream.App/Rendering/Walk/WalkLandscape.cs @@ -0,0 +1,220 @@ +namespace AcDream.App.Rendering.Walk; + +/// One landblock as the walk sees it (retail CLandBlock): +/// static shape plus the per-frame visibility the landscape pass writes. +public sealed class WalkLandBlock +{ + public int SideCellCount = 8; + public float MaxZ; + public float MinZ; + + /// Per cell index (x * side + y): the building the cell's + /// CSortCell owns, if any. + public WalkBuilding?[] CellBuildings = []; + + // ---- per-frame visibility (draw_check_blocks / landcell_check) ---- + public WalkBoundingType InView; + public WalkBoundingType[] CellInView = []; + + // ---- per-block cell draw order cache (calc_sq_draw_order) ---- + public int[] DrawArray = []; + public int ClosestX = -1; + public int ClosestY = -1; + + public void EnsureCellArrays() + { + int n = SideCellCount * SideCellCount; + if (CellInView.Length < n) CellInView = new WalkBoundingType[n]; + if (DrawArray.Length < n) DrawArray = new int[n]; + if (CellBuildings.Length < n) CellBuildings = new WalkBuilding?[n]; + } +} + +/// The viewer-centered block grid (retail LScape): mid_width +/// × mid_width blocks (retail 11×11), the near-to-far block draw list, and +/// the viewer's grid offsets (calc_draw_order). +public sealed class WalkLandscape +{ + public const float BlockLength = 192f; // 24 m cell × 8 (BN elided this to '0f') + public const float CellLength = 24f; + + public int MidWidth = 11; + public WalkLandBlock?[] Blocks = []; // [x * MidWidth + y] + public int ViewerBlockX; + public int ViewerBlockY; + public int ViewerCellX; // viewer cell & 7 per axis (SqCoord) + public int ViewerCellY; + + public int[] BlockDrawList = []; + public int BlockDrawCount; + + public WalkLandBlock? BlockAt(int gridX, int gridY) + => gridX >= 0 && gridX < MidWidth && gridY >= 0 && gridY < MidWidth + ? Blocks[gridX * MidWidth + gridY] + : null; + + /// LScape::get_block_order + per-block + /// calc_draw_order: rebuild the near-to-far block list and each + /// block's far-to-near cell order for the current viewer placement. + public void CalcDrawOrder() + { + if (BlockDrawList.Length < MidWidth * MidWidth) + BlockDrawList = new int[MidWidth * MidWidth]; + BlockDrawCount = LandWalkOrder.GetBlockOrder( + ViewerBlockX, ViewerBlockY, MidWidth, BlockDrawList); + + for (int x = 0; x < MidWidth; x++) + { + for (int y = 0; y < MidWidth; y++) + { + WalkLandBlock? block = Blocks[x * MidWidth + y]; + if (block is null) continue; + block.EnsureCellArrays(); + LandDirection dir = LandWalkOrder.GetDirection( + x - ViewerBlockX, y - ViewerBlockY); + (int cx, int cy) = LandWalkOrder.ClosestCell( + dir, ViewerCellX, ViewerCellY, block.SideCellCount); + // Retail's early-out: same closest cell keeps the stale order + // (the dir field is NOT compared — keep the quirk). + if (cx == block.ClosestX && cy == block.ClosestY) continue; + block.ClosestX = cx; + block.ClosestY = cy; + LandWalkOrder.FillCellOrderFarToNear( + cx, cy, block.SideCellCount, + block.DrawArray.AsSpan(0, block.SideCellCount * block.SideCellCount)); + } + } + } + + /// + /// LScape::draw_check_blocks @0x00505f80 + landcell_check + /// @0x005050a0: clear all visibility, then for EACH view of the active + /// portal view union block/cell visibility from clip-height interval + /// grids at 192 m block / 24 m cell pitch in viewer-block-relative + /// coordinates. A block/cell marked by an earlier view is never + /// downgraded (blocks are only written non-Outside; cells are skipped + /// once in view). + /// + public void CheckBlocks(in WalkPlane cyPlane, WalkPortalView activeViews) + { + foreach (WalkLandBlock? block in Blocks) + { + if (block is null) continue; + block.EnsureCellArrays(); + block.InView = WalkBoundingType.Outside; + Array.Clear(block.CellInView, 0, block.SideCellCount * block.SideCellCount); + } + + int viewCount = activeViews.ViewCount; + Span boundsScratch = stackalloc float[32]; + // Corner interval grids: 2 rolling rows of (MidWidth+1) corners, + // each corner a plane-classification vector. + int cornerRow = MidWidth + 1; + var intervals = new float[2 * cornerRow][]; + for (int i = 0; i < intervals.Length; i++) intervals[i] = new float[32]; + + int v = 0; + while (true) + { + WalkPlane[] edgePlanes; + int edgeCount; + bool last; + if (viewCount == 0) + { + edgePlanes = []; + edgeCount = 0; + last = true; + } + else + { + WalkViewPoly poly = activeViews.View.Polys[v]; + edgePlanes = new WalkPlane[poly.VertexCount]; + for (int k = 0; k < poly.VertexCount; k++) + edgePlanes[k] = activeViews.View.Vertices[poly.VertexIndex + k].Plane; + edgeCount = poly.VertexCount; + v++; + last = v == viewCount; + } + + // Seed the west column (grid x = 0) into parity row 0. + for (int j = 0; j <= MidWidth; j++) + WalkVisibilityMath.FillClipHeights( + (0 - ViewerBlockX) * BlockLength, + (j - ViewerBlockY) * BlockLength, + cyPlane, edgePlanes, intervals[j]); + for (int bx = 0; bx < MidWidth; bx++) + { + int westRow = (bx & 1) * cornerRow; + int eastRow = ((bx - 1) & 1) * cornerRow; + for (int j = 0; j <= MidWidth; j++) + WalkVisibilityMath.FillClipHeights( + (bx + 1 - ViewerBlockX) * BlockLength, + (j - ViewerBlockY) * BlockLength, + cyPlane, edgePlanes, intervals[eastRow + j]); + for (int by = 0; by < MidWidth; by++) + { + WalkLandBlock? block = Blocks[bx * MidWidth + by]; + if (block is null) continue; + WalkBoundingType bt = WalkVisibilityMath.BlockCheck( + intervals[westRow + by], intervals[westRow + by + 1], + intervals[eastRow + by], intervals[eastRow + by + 1], + edgeCount, block.MaxZ, block.MinZ); + if (bt != WalkBoundingType.Outside) + { + block.InView = bt; + LandCellCheck(block, bx, by, cyPlane, edgePlanes); + } + } + } + if (last) return; + } + } + + // landcell_check @0x005050a0: non-8×8 far blocks mark all cells PARTIAL; + // ENTIRELY_INSIDE blocks mark all cells 2; else per-cell corner grids at + // 24 m pitch, skipping cells already in view (union across views). + private void LandCellCheck( + WalkLandBlock block, int bx, int by, + in WalkPlane cyPlane, WalkPlane[] edgePlanes) + { + int n = block.SideCellCount; + if (n != 8) + { + for (int i = 0; i < n * n; i++) + block.CellInView[i] = WalkBoundingType.PartiallyInside; + return; + } + if (block.InView == WalkBoundingType.EntirelyInside) + { + for (int i = 0; i < n * n; i++) + block.CellInView[i] = WalkBoundingType.EntirelyInside; + return; + } + float x0 = (bx - ViewerBlockX) * BlockLength; + float y0 = (by - ViewerBlockY) * BlockLength; + int cornerRow = n + 1; + var grid = new float[2 * cornerRow][]; + for (int i = 0; i < grid.Length; i++) grid[i] = new float[32]; + for (int j = 0; j <= n; j++) + WalkVisibilityMath.FillClipHeights( + x0, j * CellLength + y0, cyPlane, edgePlanes, grid[j]); + for (int cx = 0; cx < n; cx++) + { + int westRow = (cx & 1) * cornerRow; + int eastRow = ((cx - 1) & 1) * cornerRow; + for (int j = 0; j <= n; j++) + WalkVisibilityMath.FillClipHeights( + (cx + 1) * CellLength + x0, j * CellLength + y0, + cyPlane, edgePlanes, grid[eastRow + j]); + for (int cy = 0; cy < n; cy++) + { + if (block.CellInView[n * cx + cy] != WalkBoundingType.Outside) + continue; // union across views: never downgrade + block.CellInView[n * cx + cy] = WalkVisibilityMath.BlockCheck( + grid[westRow + cy], grid[westRow + cy + 1], + grid[eastRow + cy], grid[eastRow + cy + 1], + edgePlanes.Length, block.MaxZ, block.MinZ); + } + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs new file mode 100644 index 00000000..471dfd66 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs @@ -0,0 +1,224 @@ +using System.Numerics; +using AcDream.App.Rendering.Walk; + +namespace AcDream.App.Tests.Rendering.Walk; + +public sealed class RetailFrameWalkTests +{ + private sealed class Recorder : IWalkEventSink + { + public readonly List Events = new(); + public void Emit(in WalkEvent walkEvent) => Events.Add(walkEvent); + + public string Signature() + => string.Join("|", Events.Select(e => e.Kind switch + { + WalkEventKind.Landscape => "LS", + WalkEventKind.Building => $"BLD:{e.CellId:x8}", + WalkEventKind.DrawInside => $"DI:{e.CellId:x8}", + WalkEventKind.DrawCells => + $"DC:ov={e.OutsideViewCount}:{string.Join(',', e.Cells.Select(c => c.ToString("x8")))}", + _ => "?", + })); + } + + private sealed class Caster : IWalkRayCaster + { + public Vector3 RayThrough(float screenX, float screenY) + => new(screenX, screenY, 100f); + } + + private sealed class TestContext : IWalkFrameContext, IRetailFrameWalkContext + { + 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; + } + + 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; + // Permissive near plane: every column wholly inside. + public WalkPlane CyPlane => new(new Vector3(0, 0, 1), 0f); + public void SetActiveView(WalkPortalView views, int index) { } + + public int ClipBuildingPolygon( + WalkBuilding building, WalkPolygon polygon, int side, Span output) + => 0; // no BSP-driven look-ins in these composition fixtures + } + + private static WalkPolygon Quad(float z, bool facingViewer = true) => new() + { + Vertices = + [ + new Vector3(-0.5f, -0.5f, z), new Vector3(0.5f, -0.5f, z), + new Vector3(0.5f, 0.5f, z), new Vector3(-0.5f, 0.5f, z), + ], + Plane = new WalkPlane(new Vector3(0, 0, facingViewer ? 1f : -1f), facingViewer ? -z : z), + }; + + private static WalkLandscape Landscape3x3() + { + var landscape = new WalkLandscape + { + MidWidth = 3, + Blocks = new WalkLandBlock?[9], + ViewerBlockX = 1, + ViewerBlockY = 1, + ViewerCellX = 0, + ViewerCellY = 0, + }; + for (int i = 0; i < 9; i++) + { + landscape.Blocks[i] = new WalkLandBlock { MaxZ = 10f, MinZ = 0f }; + landscape.Blocks[i]!.EnsureCellArrays(); + } + return landscape; + } + + [Fact] + public void Outdoor_frame_emits_landscape_then_buildings_far_to_near() + { + var ctx = new TestContext(); + WalkLandscape landscape = Landscape3x3(); + // A building in ring-1 block (0,0), and two in the viewer block: + // one at the block's far corner, one at the viewer's closest cell. + var ringBuilding = new WalkBuilding { PositionCellId = 0xAAAA0001 }; + var farBuilding = new WalkBuilding { PositionCellId = 0xBBBB0002 }; + var nearBuilding = new WalkBuilding { PositionCellId = 0xCCCC0003 }; + landscape.Blocks[0]!.CellBuildings[0] = ringBuilding; // block (0,0) + WalkLandBlock viewerBlock = landscape.Blocks[1 * 3 + 1]!; + viewerBlock.CellBuildings[7 * 8 + 7] = farBuilding; // far corner cell + viewerBlock.CellBuildings[0] = nearBuilding; // the closest cell (viewer at 0,0) + var walk = new RetailFrameWalk(); + var recorder = new Recorder(); + + // ViewCount == 0 exercises the CY-only visibility arm deterministically. + walk.DrawLandscape(landscape, new WalkPortalView(), ctx, recorder); + + Assert.Equal( + "LS|BLD:aaaa0001|BLD:bbbb0002|BLD:cccc0003", + recorder.Signature()); + } + + [Fact] + public void Degraded_building_still_emits_its_entry_event() + { + var ctx = new TestContext(); + var walk = new RetailFrameWalk(); + var recorder = new Recorder(); + var building = new WalkBuilding { PositionCellId = 0xF518002E, HasGeometry = false }; + + walk.DrawBuilding(building, new WalkPortalView(), ctx, recorder); + + Assert.Equal("BLD:f518002e", recorder.Signature()); + } + + [Fact] + public void Interior_frame_without_exit_views_skips_the_landscape() + { + var ctx = new TestContext(); + // One facing portal only: the flood stays in the cell, no exit view. + var cell = new WalkCell + { + CellId = 0xA9B40178, + Portals = [new WalkCellPortal + { + OtherCellId = 0xA9B40179, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0, + }], + PortalPolygons = [Quad(-2f)], + }; + ctx.Cells[cell.CellId] = cell; + var walk = new RetailFrameWalk(); + var recorder = new Recorder(); + + walk.WalkFrame(cell.CellId, cell, Landscape3x3(), ctx, recorder); + + Assert.Equal("DI:a9b40178|DC:ov=0:a9b40178", recorder.Signature()); + } + + [Fact] + public void Interior_frame_with_an_exit_view_draws_the_landscape_through_it() + { + var ctx = new TestContext(); + var cell = new WalkCell + { + CellId = 0xA9B40150, + Portals = [new WalkCellPortal + { + OtherCellId = 0xFFFFFFFF, PolygonIndex = 0, PortalSide = 0, OtherPortalId = -1, + }], + PortalPolygons = [Quad(-2f)], + }; + ctx.Cells[cell.CellId] = cell; + var walk = new RetailFrameWalk(); + var recorder = new Recorder(); + var landscape = new WalkLandscape + { + MidWidth = 1, + Blocks = new WalkLandBlock?[1], + ViewerBlockX = 0, + ViewerBlockY = 0, + }; + + walk.WalkFrame(cell.CellId, cell, landscape, ctx, recorder); + + Assert.Equal("DI:a9b40150|DC:ov=1:a9b40150|LS", recorder.Signature()); + } + + [Fact] + public void Outdoor_camera_cell_roots_the_landscape_walk() + { + var ctx = new TestContext(); + var walk = new RetailFrameWalk(); + var recorder = new Recorder(); + + // Low word < 0x100 = an outdoor landcell id. + walk.WalkFrame(0xA9B40015, null, Landscape3x3(), ctx, recorder); + + Assert.StartsWith("LS", recorder.Signature()); + } + + [Fact] + public void View_state_unwinds_completely_after_an_interior_frame() + { + var ctx = new TestContext(); + var stabCell = new WalkCell { CellId = 0xA9B40151 }; + var cell = new WalkCell + { + CellId = 0xA9B40150, + StabList = [0xA9B40151u], + Portals = [new WalkCellPortal + { + OtherCellId = 0xA9B40151, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0, + }], + PortalPolygons = [Quad(-2f)], + }; + stabCell.Portals = [new WalkCellPortal + { + OtherCellId = 0xA9B40150, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0, + }]; + stabCell.PortalPolygons = [Quad(-2f)]; + ctx.Cells[cell.CellId] = cell; + ctx.Cells[stabCell.CellId] = stabCell; + var walk = new RetailFrameWalk(); + + walk.WalkFrame(cell.CellId, cell, Landscape3x3(), ctx, new Recorder()); + + Assert.Equal(0, cell.NumView); + Assert.Equal(0, stabCell.NumView); + } +}