acdream/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs
Erik 35b37dfb5f chore(phys): A6.P3 #98 triage — revert neg-poly + bldg-check experiments
Triage step from the plan at C:\Users\erikn\.claude\plans\
i-did-some-work-sharded-acorn.md. Four sessions on issue #98 left the
worktree dirty with ~1352 LOC of mixed work. This commit splits the
work into "keep" (defensible + diagnostic) and "drop" (failed
experiments), then commits the keep set with the drops removed.

Plan asked for three commits (diag / fix / revert); consolidated to one
because the diagnostic emits in TransitionTypes.cs are tightly
interleaved with the multi-sphere CellTransit calls and the CellId
switch. Hunk-level splitting in those files for marginal bisect
granularity didn't justify the misclick risk.

Reverted entirely (failed experiments per slice 7 handoff):
- src/AcDream.Core/Physics/PhysicsDataCache.cs — neg-poly storage
  fields (Stippling, PosSurface, NegSurface, HasNegativeSide,
  IsNegativeSide, NegativeSide).
- src/AcDream.Core/Physics/ShadowObjectRegistry.cs — isBuilding flag
  propagation through Register / ShadowEntry.
- tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs — 165 lines of
  PolygonWithNegativeSide_* tests.
- tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs —
  isBuilding propagation tests.
- src/AcDream.Core/World/WorldEntity.cs — IsLandblockBuilding field
  (no consumer once ShadowObjectRegistry.isBuilding is gone).
- src/AcDream.Core/World/LandblockLoader.cs — IsLandblockBuilding=true
  setter on building entities (kept BuildBuildingTerrainCells).
- src/AcDream.App/Rendering/GameWindow.cs — isBuilding: arg passed to
  ShadowObjects.Register.
- src/AcDream.Core/Physics/BSPQuery.cs — TryAdjustWalkableSide /
  IsWalkableAt helpers, their callers, the Path 5 / Path 6 neg-poly
  branch split, the BldgCheck-tied clearCell conditional, and the
  neg-poly ResolveCellPolygons writes.
- src/AcDream.Core/Physics/PhysicsDiagnostics.cs — neg-poly fields
  in the poly-dump format.
- src/AcDream.Core/Physics/TransitionTypes.cs — SpherePath.BldgCheck +
  SpherePath.HitsInteriorCell fields and every consumer, the
  savedBldgCheck try/finally around FindCollisions, and the neg-poly
  format additions to the dump-on-error helper.
- src/AcDream.Core/Physics/CellTransit.cs — FindCellSet overloads
  with hitsInteriorCell out-param and the BuildCellSetAndPickContaining
  out-param threading.

Kept (defensible correctness fixes + diagnostic infrastructure):
- src/AcDream.App/Rendering/GameWindow.cs — render-vs-physics cell
  origin split: the 0.02m render lift no longer leaks into physics
  BSP caching. lb.BuildingTerrainCells threaded into LandblockMesh.Build.
- src/AcDream.Core/World/LoadedLandblock.cs — BuildingTerrainCells
  record field.
- src/AcDream.Core/World/LandblockLoader.cs — BuildBuildingTerrainCells
  (cy*8+cx from LandBlockInfo.Buildings).
- src/AcDream.Core/Terrain/LandblockMesh.cs — hiddenTerrainCells
  param that collapses owned-cell triangles to a zero-area degenerate.
- src/AcDream.App/Streaming/{GpuWorldState,LandblockStreamer}.cs —
  mechanical BuildingTerrainCells threading through LoadedLandblock
  reconstructions.
- src/AcDream.Core/Physics/CellTransit.cs — multi-sphere
  FindTransitCellsSphere variant + multi-sphere AddAllOutsideCells +
  FindCellSet(IReadOnlyList<Sphere>, …) overload + the
  BSPQuery.SphereIntersectsCellBsp call for loaded neighbours. Matches
  retail CObjCell::find_cell_list / CEnvCell::find_transit_cells.
- src/AcDream.Core/Physics/TransitionTypes.cs — multi-sphere FindCellSet
  call site, retail-faithful CellId switch after CheckOtherCells, the
  outdoor-landcell terrain-walkable fallback in CheckOtherCells, and
  the full diagnostic suite ([step-walk], [walkable-nearest],
  [issue98-walkable-detail], [cell-set-summary], LastBspHitPoly
  emits).
- src/AcDream.Core/Physics/PhysicsDiagnostics.cs — ProbeStepWalkEnabled
  gate (ACDREAM_PROBE_STEP_WALK=1) + LogStepWalk helper + FormatVector
  / FormatPlane utilities. All emit-gated.
- src/AcDream.Core/Physics/BSPQuery.cs — diagnostic emits to
  LastBspHitPoly at four sites in SphereIntersectsPolyInternal /
  the placement adjustment path.
- Test files for the kept work: CellTransitFindCellSetTests,
  CellTransitFindTransitCellsSphereTests, PhysicsDiagnosticsTests,
  TransitionCheckOtherCellsTests, LandblockMeshTests,
  LandblockLoaderTests.

Verification:
- dotnet build: green, 0 errors, 3 pre-existing warnings.
- dotnet test: 1156 passed + 8 failed (baseline was 1148 + 8 pre-
  existing; the +8 passing are the new tests for the kept defensible
  work). Same 8 pre-existing failures, no new regressions.

Backup of pre-triage worktree state in stash@{0}.

A6.P3 #98 is still open; this is the apparatus-prep step, not a fix.
Next: cell-dump probe (Step 2 of the plan).
2026-05-23 15:11:49 +02:00

223 lines
8.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using AcDream.Core.Terrain;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Terrain;
public class LandblockMeshTests
{
/// <summary>
/// Synthetic height table with a * 2.0f scale (mirrors Phase 1's ramp so
/// existing test intuition carries through the Phase 3c rewrite).
/// </summary>
private static readonly float[] IdentityHeightTable =
Enumerable.Range(0, 256).Select(i => i * 2f).ToArray();
private static TerrainBlendingContext MakeContext() => new(
TerrainTypeToLayer: new Dictionary<uint, byte> { [0u] = 0 },
RoadLayer: SurfaceInfo.None,
CornerAlphaLayers: Array.Empty<byte>(),
SideAlphaLayers: Array.Empty<byte>(),
RoadAlphaLayers: Array.Empty<byte>(),
CornerAlphaTCodes: Array.Empty<uint>(),
SideAlphaTCodes: Array.Empty<uint>(),
RoadAlphaRCodes: Array.Empty<uint>());
private static LandBlock BuildFlatLandBlock(byte heightIndex = 0)
{
var block = new LandBlock
{
HasObjects = false,
Terrain = new TerrainInfo[81],
Height = new byte[81],
};
for (int i = 0; i < 81; i++)
{
block.Terrain[i] = (ushort)0;
block.Height[i] = heightIndex;
}
return block;
}
[Fact]
public void Build_FlatBlock_Produces384VerticesAnd128Triangles()
{
var block = BuildFlatLandBlock();
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache);
// 64 cells × 6 vertices per cell = 384
Assert.Equal(384, mesh.Vertices.Length);
// Each cell emits 2 triangles = 6 indices, 64 cells → 384 indices (= 128 triangles)
Assert.Equal(128 * 3, mesh.Indices.Length);
}
[Fact]
public void Build_HiddenTerrainCell_PreservesCountsAndDegeneratesOnlyThatCell()
{
var block = BuildFlatLandBlock();
var cache = new Dictionary<uint, SurfaceInfo>();
int hiddenCell = (3 * LandblockMesh.CellsPerSide) + 5;
var mesh = LandblockMesh.Build(
block,
0,
0,
IdentityHeightTable,
MakeContext(),
cache,
new HashSet<int> { hiddenCell });
Assert.Equal(LandblockMesh.VerticesPerLandblock, mesh.Vertices.Length);
Assert.Equal(LandblockMesh.VerticesPerLandblock, mesh.Indices.Length);
int hiddenBase = hiddenCell * LandblockMesh.VerticesPerCell;
for (int i = 0; i < LandblockMesh.VerticesPerCell; i++)
Assert.Equal((uint)hiddenBase, mesh.Indices[hiddenBase + i]);
int visibleCell = hiddenCell + 1;
int visibleBase = visibleCell * LandblockMesh.VerticesPerCell;
for (int i = 0; i < LandblockMesh.VerticesPerCell; i++)
Assert.Equal((uint)(visibleBase + i), mesh.Indices[visibleBase + i]);
}
[Fact]
public void Build_Vertices_CoverExactly192x192WorldUnits()
{
var block = BuildFlatLandBlock();
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache);
var minX = mesh.Vertices.Min(v => v.Position.X);
var maxX = mesh.Vertices.Max(v => v.Position.X);
var minY = mesh.Vertices.Min(v => v.Position.Y);
var maxY = mesh.Vertices.Max(v => v.Position.Y);
Assert.Equal(0.0f, minX);
Assert.Equal(192.0f, maxX);
Assert.Equal(0.0f, minY);
Assert.Equal(192.0f, maxY);
}
[Fact]
public void Build_FlatBlock_AllVerticesSameZ()
{
var block = BuildFlatLandBlock(heightIndex: 10);
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache);
var zs = mesh.Vertices.Select(v => v.Position.Z).Distinct().ToArray();
Assert.Single(zs);
Assert.Equal(20.0f, zs[0]); // heightIndex 10 × IdentityHeightTable[10] = 20
}
[Fact]
public void Build_FlatBlock_NormalsPointStraightUp()
{
var block = BuildFlatLandBlock();
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache);
foreach (var v in mesh.Vertices)
{
Assert.Equal(new Vector3(0, 0, 1), v.Normal);
}
}
[Fact]
public void Build_AllVerticesOfACellShareIdenticalData()
{
var block = BuildFlatLandBlock();
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache);
// Vertices are emitted in strides of 6 per cell. Within each stride,
// Data0..3 must be identical — the vertex shader relies on that when
// it propagates the cell's blend recipe to all 3 fragment-shader outputs.
for (int cellIdx = 0; cellIdx < 64; cellIdx++)
{
int baseIdx = cellIdx * 6;
var d0 = mesh.Vertices[baseIdx].Data0;
var d1 = mesh.Vertices[baseIdx].Data1;
var d2 = mesh.Vertices[baseIdx].Data2;
var d3 = mesh.Vertices[baseIdx].Data3;
for (int i = 1; i < 6; i++)
{
Assert.Equal(d0, mesh.Vertices[baseIdx + i].Data0);
Assert.Equal(d1, mesh.Vertices[baseIdx + i].Data1);
Assert.Equal(d2, mesh.Vertices[baseIdx + i].Data2);
Assert.Equal(d3, mesh.Vertices[baseIdx + i].Data3);
}
}
}
[Fact]
public void Build_SurfaceCacheIsReusedAcrossIdenticalCells()
{
var block = BuildFlatLandBlock(); // every cell has identical all-zero corners
var cache = new Dictionary<uint, SurfaceInfo>();
LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache);
// A uniform flat landblock produces exactly ONE palette code (all
// corners are type 0, no roads) → BuildSurface called once, cache
// contains a single entry even though 64 cells were processed.
Assert.Single(cache);
}
[Fact]
public void Build_CellsWithDistinctTerrainTypes_ProducesDistinctPaletteCodes()
{
// Put a dirt cell (type 4) at the center of an otherwise grass landblock.
// Grass cells all share one palCode; the "dirt + grass border" cells
// around the center introduce additional palette codes.
var block = BuildFlatLandBlock();
// Type is at bits 2-6, so type=4 → ushort = (4 << 2) = 0x10.
block.Terrain[4 * 9 + 4] = (ushort)(4 << 2);
var ctx = new TerrainBlendingContext(
TerrainTypeToLayer: new Dictionary<uint, byte> { [0u] = 0, [4u] = 1 },
RoadLayer: SurfaceInfo.None,
CornerAlphaLayers: new byte[] { 0, 1, 2, 3 },
SideAlphaLayers: Array.Empty<byte>(),
RoadAlphaLayers: Array.Empty<byte>(),
CornerAlphaTCodes: new uint[] { 1, 2, 4, 8 },
SideAlphaTCodes: Array.Empty<uint>(),
RoadAlphaRCodes: Array.Empty<uint>());
var cache = new Dictionary<uint, SurfaceInfo>();
LandblockMesh.Build(block, 0, 0, IdentityHeightTable, ctx, cache);
// Should have more than one palette code now — uniform-grass cells
// plus at least one boundary cell with a non-zero corner type.
Assert.True(cache.Count >= 2, $"Expected mix of palette codes, got {cache.Count}");
}
[Fact]
public void Build_HeightmapPackedAsXMajor_NotYMajor()
{
// Regression from the Phase 1 → 2a transpose bug. The underlying
// heightmap is indexed x*9+y; testing this lives on even after the
// per-cell refactor because the corner lookup in the cell loop still
// reads block.Height[cx*9+cy] for the BL corner.
var block = BuildFlatLandBlock();
block.Height[2 * 9 + 0] = 5; // x=2, y=0 → world (48, 0), Z should be 10
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache);
// Search the vertex buffer for a vertex at world position (48, 0).
var atX48Y0 = mesh.Vertices.FirstOrDefault(v =>
Math.Abs(v.Position.X - 48f) < 0.01f && Math.Abs(v.Position.Y) < 0.01f);
var atX0Y48 = mesh.Vertices.FirstOrDefault(v =>
Math.Abs(v.Position.X) < 0.01f && Math.Abs(v.Position.Y - 48f) < 0.01f);
Assert.Equal(10.0f, atX48Y0.Position.Z);
Assert.Equal(0.0f, atX0Y48.Position.Z);
}
}