diff --git a/src/AcDream.App/Rendering/Walk/LandWalkOrder.cs b/src/AcDream.App/Rendering/Walk/LandWalkOrder.cs new file mode 100644 index 00000000..4aef0c69 --- /dev/null +++ b/src/AcDream.App/Rendering/Walk/LandWalkOrder.cs @@ -0,0 +1,163 @@ +namespace AcDream.App.Rendering.Walk; + +/// +/// Campaign FW1 — retail's landscape draw-order machinery, ported from the +/// named 2013 decomp (model: docs/research/2026-08-30-fw-walk-pseudocode.md §3). +/// +/// Retail enumerates viewer-centered Chebyshev rings with one shared 8-slot +/// coefficient pattern at BOTH grid levels: LScape::get_block_order +/// @0x00504c50 builds the landblock draw list NEAR-TO-FAR (viewer block at +/// index 0; LScape::draw @0x00506330 then walks it BACKWARDS = +/// far-to-near), and CLandBlock::calc_sq_draw_order @0x0052f4a0 fills +/// the per-block cell array BACKWARDS from the closest cell (forward walk = +/// far-to-near). The coefficient tables were byte-decoded from the +/// PDB-paired binary (block tables VA 0x0081cc6c..0x0081cd2b, cell tables +/// VA 0x0081df88..0x0081e047 — the two sets are IDENTICAL). +/// +public static class LandWalkOrder +{ + // The shared 8-slot ring pattern. For ring r, step s (0..r-1), slot k + // emits offset (dx, dy) = (XStep[k]*s + XRing[k]*r + XConst[k], + // YStep[k]*s + YRing[k]*r + YConst[k]). + // Expanded: (-s,+r) (-r,-s) (+s,-r) (+r,+s) (+s+1,+r) (-r,+s+1) + // (-s-1,-r) (+r,-s-1) — tiles each ring's 8r slots exactly once. + // Byte-decoded 2026-08-30; do not "simplify" the visit order — it decides + // draw order between equidistant blocks/cells and the oracle traces pin it. + private static readonly int[] XConst = [0, 0, 0, 0, 1, 0, -1, 0]; + private static readonly int[] XRing = [0, -1, 0, 1, 0, -1, 0, 1]; + private static readonly int[] XStep = [-1, 0, 1, 0, 1, 0, -1, 0]; + private static readonly int[] YConst = [0, 0, 0, 0, 0, 1, 0, -1]; + private static readonly int[] YStep = [0, -1, 0, 1, 0, 1, 0, -1]; + private static readonly int[] YRing = [1, 0, -1, 0, 1, 0, -1, 0]; + + /// + /// LScape::get_block_order @0x00504c50: fills + /// with grid slots (x * width + y) near-to-far — + /// the viewer's slot first, then rings outward, skipping out-of-bounds + /// slots. Returns the number of slots written (= width² when the viewer + /// is inside the grid). The caller draws by walking the result BACKWARDS. + /// + public static int GetBlockOrder(int viewerX, int viewerY, int width, Span order) + { + int count = 0; + order[count++] = viewerX * width + viewerY; + int maxRing = MaxRingTo(viewerX, viewerY, width, width); + for (int ring = 1; ring <= maxRing; ring++) + { + for (int step = 0; step < ring; step++) + { + for (int slot = 0; slot < 8; slot++) + { + int x = XStep[slot] * step + XRing[slot] * ring + XConst[slot] + viewerX; + int y = YStep[slot] * step + YRing[slot] * ring + YConst[slot] + viewerY; + if (x >= 0 && x < width && y >= 0 && y < width) + order[count++] = x * width + y; + } + } + } + return count; + } + + /// + /// CLandBlock::calc_sq_draw_order @0x0052f4a0 (ring half): fills + /// with cell slots (x * side + y) so that a + /// FORWARD walk visits cells far-to-near — the closest cell is written to + /// the LAST index and rings fill backwards from there. The array is + /// exactly filled (retail's --k reaches 0). + /// + public static void FillCellOrderFarToNear(int closestX, int closestY, int side, Span order) + { + int k = side * side; + order[--k] = closestX * side + closestY; + int maxRing = MaxRingTo(closestX, closestY, side, side); + for (int ring = 1; ring <= maxRing; ring++) + { + for (int step = 0; step < ring; step++) + { + for (int slot = 0; slot < 8; slot++) + { + int x = XStep[slot] * step + XRing[slot] * ring + XConst[slot] + closestX; + int y = YStep[slot] * step + YRing[slot] * ring + YConst[slot] + closestY; + if (x >= 0 && x < side && y >= 0 && y < side) + order[--k] = x * side + y; + } + } + } + } + + /// + /// LandDefs::get_dir @0x005a9aa0: compass direction of a block at + /// grid offset (dx, dy) from the viewer's block. Lcoord y grows northward. + /// + public static LandDirection GetDirection(int dx, int dy) + { + if (dx < 0) + { + if (dy < 0) return LandDirection.SouthWest; + return dy > 0 ? LandDirection.NorthWest : LandDirection.West; + } + if (dx == 0) + { + if (dy < 0) return LandDirection.South; + return dy > 0 ? LandDirection.North : LandDirection.InViewerBlock; + } + if (dy < 0) return LandDirection.SouthEast; + return dy > 0 ? LandDirection.NorthEast : LandDirection.East; + } + + /// + /// CLandBlock::calc_sq_draw_order @0x0052f4a0 (direction switch, + /// jump table @0x0052f8e0): the block's cell nearest the viewer, given the + /// block's compass direction from the viewer. For the viewer's own block + /// the viewer's cell coordinate (cell & 7 per axis) is scaled by + /// 8/side; for other blocks the facing edge/corner is closest. + /// + public static (int X, int Y) ClosestCell( + LandDirection dir, int viewerSqX, int viewerSqY, int side) + { + int scale = 8 / side; + return dir switch + { + LandDirection.InViewerBlock => (viewerSqX / scale, viewerSqY / scale), + LandDirection.North => (viewerSqX / scale, 0), + LandDirection.South => (viewerSqX / scale, side - 1), + LandDirection.East => (0, viewerSqY / scale), + LandDirection.West => (side - 1, viewerSqY / scale), + LandDirection.NorthWest => (side - 1, 0), + LandDirection.SouthWest => (side - 1, side - 1), + LandDirection.NorthEast => (0, 0), + LandDirection.SouthEast => (0, side - 1), + _ => throw new ArgumentOutOfRangeException(nameof(dir)), + }; + } + + // get_block_order's branchy max computation @0x00504ca0-0x00504cba: + // the largest Chebyshev distance from (x, y) to any grid corner — + // max(x, y, width-1-x, height-1-y, ...) — which guarantees the ring sweep + // reaches every in-bounds slot. + private static int MaxRingTo(int x, int y, int width, int height) + { + int max = x; + if (y > max) max = y; + if (width - 1 - x > max) max = width - 1 - x; + if (height - 1 - y > max) max = height - 1 - y; + return max; + } +} + +/// +/// LandDefs::Direction (retail enum, values used raw by the +/// calc_sq_draw_order jump table @0x0052f8e0). +/// +public enum LandDirection +{ + InViewerBlock = 0, + North = 1, + South = 2, + East = 3, + West = 4, + NorthWest = 5, + SouthWest = 6, + NorthEast = 7, + SouthEast = 8, +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/LandWalkOrderTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/LandWalkOrderTests.cs new file mode 100644 index 00000000..76009bbf --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/LandWalkOrderTests.cs @@ -0,0 +1,141 @@ +using AcDream.App.Rendering.Walk; + +namespace AcDream.App.Tests.Rendering.Walk; + +public sealed class LandWalkOrderTests +{ + [Theory] + [InlineData(5, 5, 11)] // retail defaults: mid_width 11, viewer centered + [InlineData(0, 0, 11)] // viewer at a grid corner + [InlineData(10, 3, 11)] // viewer at an edge + [InlineData(1, 1, 3)] + public void Block_order_covers_every_slot_exactly_once_viewer_first( + int vx, int vy, int width) + { + Span order = new int[width * width]; + + int count = LandWalkOrder.GetBlockOrder(vx, vy, width, order); + + Assert.Equal(width * width, count); + Assert.Equal(vx * width + vy, order[0]); + var seen = new HashSet(); + foreach (int slot in order) + Assert.True(seen.Add(slot), $"slot {slot} emitted twice"); + } + + [Fact] + public void Block_order_is_ring_monotone_near_to_far() + { + const int width = 11; + const int vx = 5, vy = 5; + Span order = new int[width * width]; + LandWalkOrder.GetBlockOrder(vx, vy, width, order); + + int previousRing = 0; + foreach (int slot in order) + { + int ring = Math.Max(Math.Abs(slot / width - vx), Math.Abs(slot % width - vy)); + Assert.True(ring >= previousRing, "a later entry moved to a NEARER ring"); + previousRing = ring; + } + } + + [Fact] + public void Block_order_ring_one_matches_the_decoded_slot_pattern() + { + // Hand-expanded from the byte-decoded tables for ring 1, step 0: + // (0,+1) (-1,0) (0,-1) (+1,0) (+1,+1) (-1,+1) (-1,-1) (+1,-1) + // — cardinals first (N W S E), then diagonals (NE NW SW SE). + const int width = 3; + Span order = new int[9]; + LandWalkOrder.GetBlockOrder(1, 1, width, order); + + int[] expected = + [ + 1 * 3 + 1, // viewer + 1 * 3 + 2, // (0,+1) + 0 * 3 + 1, // (-1,0) + 1 * 3 + 0, // (0,-1) + 2 * 3 + 1, // (+1,0) + 2 * 3 + 2, // (+1,+1) + 0 * 3 + 2, // (-1,+1) + 0 * 3 + 0, // (-1,-1) + 2 * 3 + 0, // (+1,-1) + ]; + Assert.Equal(expected, order.ToArray()); + } + + [Theory] + [InlineData(0, 0, 8)] + [InlineData(7, 7, 8)] + [InlineData(3, 5, 8)] + [InlineData(0, 0, 1)] + public void Cell_order_fills_exactly_and_ends_at_the_closest_cell( + int cx, int cy, int side) + { + Span order = new int[side * side]; + order.Fill(-1); + + LandWalkOrder.FillCellOrderFarToNear(cx, cy, side, order); + + Assert.Equal(cx * side + cy, order[^1]); + var seen = new HashSet(); + foreach (int slot in order) + { + Assert.InRange(slot, 0, side * side - 1); + Assert.True(seen.Add(slot), $"slot {slot} written twice"); + } + } + + [Fact] + public void Cell_order_forward_walk_is_far_to_near() + { + const int side = 8; + const int cx = 2, cy = 6; + Span order = new int[side * side]; + LandWalkOrder.FillCellOrderFarToNear(cx, cy, side, order); + + int previousRing = int.MaxValue; + foreach (int slot in order) + { + int ring = Math.Max(Math.Abs(slot / side - cx), Math.Abs(slot % side - cy)); + Assert.True(ring <= previousRing, "a later entry moved to a FARTHER ring"); + previousRing = ring; + } + } + + [Theory] + [InlineData(0, 0, LandDirection.InViewerBlock)] + [InlineData(0, 3, LandDirection.North)] + [InlineData(0, -1, LandDirection.South)] + [InlineData(2, 0, LandDirection.East)] + [InlineData(-4, 0, LandDirection.West)] + [InlineData(-1, 1, LandDirection.NorthWest)] + [InlineData(-2, -2, LandDirection.SouthWest)] + [InlineData(3, 1, LandDirection.NorthEast)] + [InlineData(1, -5, LandDirection.SouthEast)] + public void Direction_mapping_matches_get_dir(int dx, int dy, LandDirection expected) + => Assert.Equal(expected, LandWalkOrder.GetDirection(dx, dy)); + + [Theory] + [InlineData(LandDirection.InViewerBlock, 5, 3, 8, 5, 3)] + [InlineData(LandDirection.North, 5, 3, 8, 5, 0)] // block north: south edge faces viewer + [InlineData(LandDirection.South, 5, 3, 8, 5, 7)] + [InlineData(LandDirection.East, 5, 3, 8, 0, 3)] // block east: west edge faces viewer + [InlineData(LandDirection.West, 5, 3, 8, 7, 3)] + [InlineData(LandDirection.NorthWest, 5, 3, 8, 7, 0)] + [InlineData(LandDirection.SouthWest, 5, 3, 8, 7, 7)] + [InlineData(LandDirection.NorthEast, 5, 3, 8, 0, 0)] + [InlineData(LandDirection.SouthEast, 5, 3, 8, 0, 7)] + public void Closest_cell_matches_the_direction_switch( + LandDirection dir, int sqx, int sqy, int side, int expectedX, int expectedY) + => Assert.Equal((expectedX, expectedY), LandWalkOrder.ClosestCell(dir, sqx, sqy, side)); + + [Fact] + public void Closest_cell_scales_the_viewer_coordinate_for_low_resolution_blocks() + { + // side_cell_count < 8: retail divides the viewer's cell coord by 8/side. + Assert.Equal((2, 1), LandWalkOrder.ClosestCell(LandDirection.InViewerBlock, 5, 3, 4)); + Assert.Equal((0, 0), LandWalkOrder.ClosestCell(LandDirection.InViewerBlock, 7, 7, 1)); + } +}