feat(render) Campaign FW1: port the retail landscape draw-order machinery
LandWalkOrder ports LScape::get_block_order @0x00504c50 (near-to-far ring list, walked backwards by LScape::draw), CLandBlock::calc_sq_draw_order @0x0052f4a0 (cell array filled backwards from the closest cell), LandDefs::get_dir @0x005a9aa0, and the 9-case closest-cell direction switch. The shared 8-slot ring coefficient tables were byte-decoded from the PDB-paired binary (block VA 0x0081cc6c.., cell VA 0x0081df88.. - identical sets); the intra-ring visit order is preserved exactly because it decides draw order between equidistant blocks and the oracle traces pin it. Thirty focused tests cover coverage/ordering invariants, the hand-expanded ring-1 pattern, and the direction/closest-cell contracts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4a72600b45
commit
d1fc4a0986
2 changed files with 304 additions and 0 deletions
163
src/AcDream.App/Rendering/Walk/LandWalkOrder.cs
Normal file
163
src/AcDream.App/Rendering/Walk/LandWalkOrder.cs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
namespace AcDream.App.Rendering.Walk;
|
||||
|
||||
/// <summary>
|
||||
/// 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: <c>LScape::get_block_order</c>
|
||||
/// @0x00504c50 builds the landblock draw list NEAR-TO-FAR (viewer block at
|
||||
/// index 0; <c>LScape::draw</c> @0x00506330 then walks it BACKWARDS =
|
||||
/// far-to-near), and <c>CLandBlock::calc_sq_draw_order</c> @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).
|
||||
/// </summary>
|
||||
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];
|
||||
|
||||
/// <summary>
|
||||
/// <c>LScape::get_block_order</c> @0x00504c50: fills
|
||||
/// <paramref name="order"/> 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.
|
||||
/// </summary>
|
||||
public static int GetBlockOrder(int viewerX, int viewerY, int width, Span<int> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>CLandBlock::calc_sq_draw_order</c> @0x0052f4a0 (ring half): fills
|
||||
/// <paramref name="order"/> 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 <c>--k</c> reaches 0).
|
||||
/// </summary>
|
||||
public static void FillCellOrderFarToNear(int closestX, int closestY, int side, Span<int> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>LandDefs::get_dir</c> @0x005a9aa0: compass direction of a block at
|
||||
/// grid offset (dx, dy) from the viewer's block. Lcoord y grows northward.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>CLandBlock::calc_sq_draw_order</c> @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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>LandDefs::Direction</c> (retail enum, values used raw by the
|
||||
/// calc_sq_draw_order jump table @0x0052f8e0).
|
||||
/// </summary>
|
||||
public enum LandDirection
|
||||
{
|
||||
InViewerBlock = 0,
|
||||
North = 1,
|
||||
South = 2,
|
||||
East = 3,
|
||||
West = 4,
|
||||
NorthWest = 5,
|
||||
SouthWest = 6,
|
||||
NorthEast = 7,
|
||||
SouthEast = 8,
|
||||
}
|
||||
141
tests/AcDream.App.Tests/Rendering/Walk/LandWalkOrderTests.cs
Normal file
141
tests/AcDream.App.Tests/Rendering/Walk/LandWalkOrderTests.cs
Normal file
|
|
@ -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<int> 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<int>();
|
||||
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<int> 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<int> 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<int> 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<int>();
|
||||
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<int> 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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue