feat(O-T2): extract pure stateless helpers to AcDream.Core.Rendering.Wb

Verbatim copy of 5 WorldBuilder files into src/AcDream.Core/Rendering/Wb/:
- TextureHelpers.cs (pixel-format decoders, Chorizite Lib)
- SceneryHelpers.cs (scenery transforms, Chorizite Lib)
- TerrainUtils.cs, TerrainEntry.cs, CellSplitDirection.cs (WB.Shared Landscape)

Namespace migrated from WorldBuilder.* / Chorizite.OpenGLSDLBackend.Lib
to AcDream.Core.Rendering.Wb per O-D11. [MemoryPackable] stripped from
TerrainEntry per O-D10 (we don't serialize the struct).

Updated 3 source files + 1 test file to import from the new namespace.

Verbatim discipline (O-D1): only namespace + MemoryPack attribute changed.
All algorithm bodies byte-identical to upstream.

Note: TextureHelpers omits IsAlphaFormat() and GetCompressedLayerSize()
because those reference Chorizite.Core.Render.Enums.TextureFormat, a type
that has no path into AcDream.Core without adding an unwanted NuGet dep.
Neither method is called from Core or the test suite; the omission is safe.

Verified on main checkout: dotnet build green (0 errors), dotnet test
green — Failed: 8, Passed: 1147, Skipped: 0, Total: 1155 (baseline maintained).
TextureDecodeConformanceTests (9/9) pass byte-for-byte after namespace swap.
AcDream.Core project alone builds green in this worktree (App-layer failures
are pre-existing, blocked by empty WB submodule, addressed in Tasks 3+4).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-21 15:13:26 +02:00
parent 8c073e0c4c
commit 16bc10c99d
9 changed files with 785 additions and 5 deletions

View file

@ -0,0 +1,256 @@
using System;
using System.Numerics;
using System.Runtime.CompilerServices;
namespace AcDream.Core.Rendering.Wb
{
/// <summary>
/// Utility methods for terrain calculations.
/// </summary>
public static class TerrainUtils
{
/// <summary>
/// The width of a road in units.
/// </summary>
public const float RoadWidth = 5f;
/// <summary>
/// The minimum Z component of a surface normal for it to be considered walkable.
/// </summary>
public const float FloorZ = 0.66417414618662751f;
/// <summary>
/// Determines if a surface with the given normal is walkable.
/// </summary>
/// <param name="normal">The surface normal.</param>
/// <returns>True if the surface is walkable, false otherwise.</returns>
public static bool IsValidWalkable(Vector3 normal)
{
return normal.Z >= FloorZ;
}
/// <summary>
/// Calculates the split direction for a terrain cell based on its coordinates.
/// This is deterministic and used to ensure consistency between the renderer and physics/logic.
/// </summary>
/// <param name="landblockX">The global landblock X coordinate.</param>
/// <param name="cellX">The local cell X coordinate (0-7).</param>
/// <param name="landblockY">The global landblock Y coordinate.</param>
/// <param name="cellY">The local cell Y coordinate (0-7).</param>
/// <returns>The split direction for the cell.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static CellSplitDirection CalculateSplitDirection(uint landblockX, uint cellX, uint landblockY, uint cellY)
{
uint seedA = (landblockX * 8 + cellX) * 214614067u;
uint seedB = (landblockY * 8 + cellY) * 1109124029u;
uint magicA = seedA + 1813693831u;
uint magicB = seedB;
float splitDir = magicA - magicB - 1369149221u;
return splitDir * 2.3283064e-10f >= 0.5f ? CellSplitDirection.SEtoNW : CellSplitDirection.SWtoNE;
}
/// <summary>
/// Gets the interpolated terrain height at a local position within a landblock.
/// Uses barycentric interpolation on the cell's triangle pair.
/// </summary>
public static float GetHeight(DatReaderWriter.DBObjs.Region region, TerrainEntry[] lbTerrainEntries,
uint landblockX, uint landblockY, Vector3 localPos)
{
uint cellX = (uint)(localPos.X / 24f);
uint cellY = (uint)(localPos.Y / 24f);
if (cellX >= 8 || cellY >= 8) return 0f;
var splitDirection = CalculateSplitDirection(landblockX, cellX, landblockY, cellY);
var bottomLeft = GetTerrainEntryForCell(lbTerrainEntries, cellX, cellY);
var bottomRight = GetTerrainEntryForCell(lbTerrainEntries, cellX + 1, cellY);
var topRight = GetTerrainEntryForCell(lbTerrainEntries, cellX + 1, cellY + 1);
var topLeft = GetTerrainEntryForCell(lbTerrainEntries, cellX, cellY + 1);
float h0 = region.LandDefs.LandHeightTable[bottomLeft.Height ?? 0];
float h1 = region.LandDefs.LandHeightTable[bottomRight.Height ?? 0];
float h2 = region.LandDefs.LandHeightTable[topRight.Height ?? 0];
float h3 = region.LandDefs.LandHeightTable[topLeft.Height ?? 0];
float lx = localPos.X - cellX * 24f;
float ly = localPos.Y - cellY * 24f;
float s = lx / 24f;
float t = ly / 24f;
if (splitDirection == CellSplitDirection.SWtoNE)
{
if (s + t <= 1f)
{
return h0 * (1f - s - t) + h1 * s + h3 * t;
}
else
{
float u = s + t - 1f;
float v = 1f - s;
float w = 1f - u - v;
return h1 * w + h2 * u + h3 * v;
}
}
else
{
if (s >= t)
{
return h0 * (1f - s) + h1 * (s - t) + h2 * t;
}
else
{
return h0 * (1f - t) + h2 * s + h3 * (t - s);
}
}
}
/// <summary>
/// Gets the terrain surface normal at a local position within a landblock.
/// </summary>
public static Vector3 GetNormal(DatReaderWriter.DBObjs.Region region, TerrainEntry[] lbTerrainEntries,
uint landblockX, uint landblockY, Vector3 localPos)
{
uint cellX = (uint)(localPos.X / 24f);
uint cellY = (uint)(localPos.Y / 24f);
if (cellX >= 8 || cellY >= 8) return new Vector3(0, 0, 1);
var splitDirection = CalculateSplitDirection(landblockX, cellX, landblockY, cellY);
var bottomLeft = GetTerrainEntryForCell(lbTerrainEntries, cellX, cellY);
var bottomRight = GetTerrainEntryForCell(lbTerrainEntries, cellX + 1, cellY);
var topRight = GetTerrainEntryForCell(lbTerrainEntries, cellX + 1, cellY + 1);
var topLeft = GetTerrainEntryForCell(lbTerrainEntries, cellX, cellY + 1);
float h0 = region.LandDefs.LandHeightTable[bottomLeft.Height ?? 0];
float h1 = region.LandDefs.LandHeightTable[bottomRight.Height ?? 0];
float h2 = region.LandDefs.LandHeightTable[topRight.Height ?? 0];
float h3 = region.LandDefs.LandHeightTable[topLeft.Height ?? 0];
float lx = localPos.X - cellX * 24f;
float ly = localPos.Y - cellY * 24f;
Vector3 p0 = new Vector3(0, 0, h0);
Vector3 p1 = new Vector3(24, 0, h1);
Vector3 p2 = new Vector3(24, 24, h2);
Vector3 p3 = new Vector3(0, 24, h3);
if (splitDirection == CellSplitDirection.SWtoNE)
{
Vector3 normal1 = Vector3.Normalize(Vector3.Cross(p1 - p0, p3 - p0));
Vector3 normal2 = Vector3.Normalize(Vector3.Cross(p2 - p1, p3 - p1));
bool inTri1 = (lx + ly <= 24f);
return inTri1 ? normal1 : normal2;
}
else
{
Vector3 normal1 = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
Vector3 normal2 = Vector3.Normalize(Vector3.Cross(p2 - p0, p3 - p0));
bool inTri1 = (lx >= ly);
return inTri1 ? normal1 : normal2;
}
}
/// <summary>
/// Checks if a local position within a landblock is on a road.
/// Uses per-vertex road flags and proximity testing.
/// </summary>
public static bool OnRoad(Vector3 obj, TerrainEntry[] entries)
{
int x = (int)(obj.X / 24f);
int y = (int)(obj.Y / 24f);
float rMin = RoadWidth;
float rMax = 24f - RoadWidth;
uint r0 = GetRoad(entries, x, y);
uint r1 = GetRoad(entries, x, y + 1);
uint r2 = GetRoad(entries, x + 1, y);
uint r3 = GetRoad(entries, x + 1, y + 1);
if (r0 == 0 && r1 == 0 && r2 == 0 && r3 == 0)
return false;
float dx = obj.X - x * 24f;
float dy = obj.Y - y * 24f;
if (r0 > 0)
{
if (r1 > 0)
{
if (r2 > 0)
{
if (r3 > 0) return true;
else return (dx < rMin || dy < rMin);
}
else
{
if (r3 > 0) return (dx < rMin || dy > rMax);
else return (dx < rMin);
}
}
else
{
if (r2 > 0)
{
if (r3 > 0) return (dx > rMax || dy < rMin);
else return (dy < rMin);
}
else
{
if (r3 > 0) return (Math.Abs(dx - dy) < rMin);
else return (dx + dy < rMin);
}
}
}
else
{
if (r1 > 0)
{
if (r2 > 0)
{
if (r3 > 0) return (dx > rMax || dy > rMax);
else return (Math.Abs(dx + dy - 24f) < rMin);
}
else
{
if (r3 > 0) return (dy > rMax);
else return (24f + dx - dy < rMin);
}
}
else
{
if (r2 > 0)
{
if (r3 > 0) return (dx > rMax);
else return (24f - dx + dy < rMin);
}
else
{
if (r3 > 0) return (24f * 2f - dx - dy < rMin);
else return false;
}
}
}
}
/// <summary>
/// Gets the road value for a specific vertex in the 9x9 terrain entry grid.
/// </summary>
public static uint GetRoad(TerrainEntry[] entries, int x, int y)
{
if (x < 0 || y < 0 || x >= 9 || y >= 9) return 0;
var idx = x * 9 + y;
if (idx >= entries.Length) return 0;
var road = entries[idx].Road ?? 0;
return (uint)(road & 0x3);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static TerrainEntry GetTerrainEntryForCell(TerrainEntry[] data, uint cellX, uint cellY)
{
var idx = (int)(cellX * 9 + cellY);
return data != null && idx < data.Length ? data[idx] : new TerrainEntry();
}
}
}