diff --git a/tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs b/tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs new file mode 100644 index 0000000..feaa28f --- /dev/null +++ b/tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs @@ -0,0 +1,168 @@ +using AcDream.Core.Terrain; +using Xunit; +using Xunit.Abstractions; +using WbTerrainUtils = WorldBuilder.Shared.Modules.Landscape.Lib.TerrainUtils; +using WbCellSplitDirection = WorldBuilder.Shared.Modules.Landscape.Models.CellSplitDirection; + +namespace AcDream.Core.Tests.Terrain; + +/// +/// Phase N.5b data-collection test: quantifies how often WB's +/// TerrainUtils.CalculateSplitDirection disagrees with acdream's +/// TerrainBlending.CalculateSplitDirection (which retail uses +/// per CLandBlockStruct::ConstructPolygons at retail address +/// 00531d10; named-retail decomp lines 316042-316144 contain +/// the exact constants 0x0CCAC033 / 0x6C1AC587 / 0x421BE3BD / +/// 0x519B8F25). +/// +/// Sweeps every (lbX, lbY, cellX, cellY) tuple in the world map +/// (255 x 255 landblocks x 64 cells = ~4.16M cells) and reports the +/// disagreement rate, per-landblock worst case, and a few named +/// representative landblocks. The number drives the Path A/B/C +/// decision in the N.5b brainstorm: +/// - Low disagreement <5% : Path A's risk is bounded +/// - Medium 5-20% : Path B (fork-patch WB) preferred +/// - High >20% : Path B/C strongly preferred +/// +public class SplitFormulaDivergenceTest +{ + private readonly ITestOutputHelper _out; + + public SplitFormulaDivergenceTest(ITestOutputHelper output) => _out = output; + + [Fact] + public void Quantify_RetailVsWb_DivergenceRate() + { + // Two divergence flavors are tracked simultaneously: + // + // rawDisagree : retail-enum != wb-enum (pure formula output) + // diagonalDisagree: retail-actually-paints-diagonal != + // wb-actually-paints-diagonal (effective geometry) + // + // The two differ because the enums are SEMANTICALLY INVERTED: + // - acdream `CellSplitDirection.SWtoNE` -> renderer paints BL->TR + // (SW-NE diagonal). Matches retail per AC2D Landblocks.cpp:400-412 + // where FSplitNESW=true wraps a TRIANGLE_FAN [BL, BR, TR, TL] = + // diagonal BL-TR. + // - WB `CellSplitDirection.SWtoNE` -> WB's TerrainGeometryGenerator + // emits triangles {BL,TL,BR}+{BR,TL,TR} which share the BR-TL + // diagonal (SE-NW direction). The enum name is misleading; what + // WB actually draws is the OTHER diagonal. + // + // So the question "would WB's pipeline produce the same diagonals as + // retail's pipeline?" is answered by `diagonalDisagree`, not + // `rawDisagree`. If diagonalDisagree is near 0%, WB's formula + + // renderer happen to compose into a correct pipeline (despite the + // confusing labels). If diagonalDisagree is ~50%, the two pipelines + // truly diverge and Path A would visibly break terrain on every + // landblock. + + const int lbCount = 255; + const int cellsPerSide = 8; + long totalCells = 0; + long rawDisagree = 0; + long diagonalDisagree = 0; + + int worstLbDiag = 0; + uint worstLbX = 0, worstLbY = 0; + int bestLbDiag = 64; + uint bestLbX = 0, bestLbY = 0; + + for (uint lbX = 0; lbX < lbCount; lbX++) + for (uint lbY = 0; lbY < lbCount; lbY++) + { + int lbDiagDisagree = 0; + for (uint cx = 0; cx < cellsPerSide; cx++) + for (uint cy = 0; cy < cellsPerSide; cy++) + { + bool retailEnumSWtoNE = + TerrainBlending.CalculateSplitDirection(lbX, cx, lbY, cy) + == CellSplitDirection.SWtoNE; + bool wbEnumSWtoNE = + WbTerrainUtils.CalculateSplitDirection(lbX, cx, lbY, cy) + == WbCellSplitDirection.SWtoNE; + + // What diagonal each pipeline actually paints. + bool retailPaintsBLtoTR = retailEnumSWtoNE; // direct mapping + bool wbPaintsBLtoTR = !wbEnumSWtoNE; // inverted mapping + + totalCells++; + if (retailEnumSWtoNE != wbEnumSWtoNE) rawDisagree++; + if (retailPaintsBLtoTR != wbPaintsBLtoTR) + { + diagonalDisagree++; + lbDiagDisagree++; + } + } + + if (lbDiagDisagree > worstLbDiag) + { + worstLbDiag = lbDiagDisagree; + worstLbX = lbX; + worstLbY = lbY; + } + if (lbDiagDisagree < bestLbDiag) + { + bestLbDiag = lbDiagDisagree; + bestLbX = lbX; + bestLbY = lbY; + } + } + + double rawPct = 100.0 * rawDisagree / totalCells; + double diagPct = 100.0 * diagonalDisagree / totalCells; + + _out.WriteLine($"=== Phase N.5b — terrain split formula divergence ==="); + _out.WriteLine($"Sweep: {lbCount}x{lbCount} landblocks, {cellsPerSide*cellsPerSide} cells each"); + _out.WriteLine($"Total cells: {totalCells:N0}"); + _out.WriteLine(""); + _out.WriteLine($"RAW enum-output disagreement : {rawDisagree,12:N0} ({rawPct:F2}%)"); + _out.WriteLine($" (compares retail-enum vs wb-enum, NOT what each system actually draws)"); + _out.WriteLine(""); + _out.WriteLine($"DIAGONAL-actually-painted disagreement: {diagonalDisagree,12:N0} ({diagPct:F2}%)"); + _out.WriteLine($" (compares retail-paints-BL->TR vs wb-paints-BL->TR; this is the"); + _out.WriteLine($" number that determines whether Path A visibly works)"); + _out.WriteLine(""); + _out.WriteLine($"Worst landblock (diagonal): 0x{worstLbX:X2}{worstLbY:X2} disagrees on {worstLbDiag}/64 cells ({100.0*worstLbDiag/64:F1}%)"); + _out.WriteLine($"Best landblock (diagonal): 0x{bestLbX:X2}{bestLbY:X2} disagrees on {bestLbDiag}/64 cells ({100.0*bestLbDiag/64:F1}%)"); + + // Specific landblocks of interest (per N.5b handoff representative set). + var representative = new (string name, uint lbX, uint lbY)[] + { + ("Holtburg town", 0xA9, 0xB0), + ("Holtburg LB 0xA9B1", 0xA9, 0xB1), + ("Foundry-area", 0x80, 0x80), + ("Cragstone", 0xCB, 0x99), + ("Direlands sample", 0xC0, 0x40), + ("MapOrigin 0x0000", 0x00, 0x00), + ("MapCorner 0xFEFE", 0xFE, 0xFE), + ("Mid-map 0x7F7F", 0x7F, 0x7F), + ("Subway dungeon LB 0x0185 outdoor part", 0x01, 0x85), + }; + + _out.WriteLine(""); + _out.WriteLine("Representative landblocks (diagonal-actually-painted disagreement):"); + foreach (var (name, lbX, lbY) in representative) + { + int dis = 0; + for (uint cx = 0; cx < 8; cx++) + for (uint cy = 0; cy < 8; cy++) + { + bool retailEnum = TerrainBlending.CalculateSplitDirection(lbX, cx, lbY, cy) == CellSplitDirection.SWtoNE; + bool wbEnum = WbTerrainUtils.CalculateSplitDirection(lbX, cx, lbY, cy) == WbCellSplitDirection.SWtoNE; + bool retailPaintsBLtoTR = retailEnum; + bool wbPaintsBLtoTR = !wbEnum; + if (retailPaintsBLtoTR != wbPaintsBLtoTR) dis++; + } + _out.WriteLine($" 0x{lbX:X2}{lbY:X2} {dis,2}/64 cells disagree ({100.0*dis/64:F1}%) {name}"); + } + + // Soft-floor on the DIAGONAL comparison: if diagPct is near 0% the + // formulas are equivalent post-inversion (Path A would just work + // visually; the only "bug" is enum naming). If diagPct is well + // above 0%, Path A truly breaks terrain. + // Soft-ceiling: an inversion of inversion shouldn't push past ~70%. + Assert.True(diagPct >= 0 && diagPct <= 100, + $"Sanity: diagonal disagreement out of range (rate={diagPct:F2}%)"); + } +}