diff --git a/src/AcDream.Core/Rendering/Wb/CellSplitDirection.cs b/src/AcDream.Core/Rendering/Wb/CellSplitDirection.cs
new file mode 100644
index 0000000..8bd88a3
--- /dev/null
+++ b/src/AcDream.Core/Rendering/Wb/CellSplitDirection.cs
@@ -0,0 +1,18 @@
+namespace AcDream.Core.Rendering.Wb
+{
+ ///
+ /// The direction a terrain cell is split into triangles.
+ ///
+ public enum CellSplitDirection
+ {
+ ///
+ /// South-West to North-East
+ ///
+ SWtoNE,
+
+ ///
+ /// South-East to North-West
+ ///
+ SEtoNW
+ }
+}
diff --git a/src/AcDream.Core/Rendering/Wb/SceneryHelpers.cs b/src/AcDream.Core/Rendering/Wb/SceneryHelpers.cs
new file mode 100644
index 0000000..0b86d14
--- /dev/null
+++ b/src/AcDream.Core/Rendering/Wb/SceneryHelpers.cs
@@ -0,0 +1,107 @@
+using DatReaderWriter.Types;
+using System;
+using System.Numerics;
+
+namespace AcDream.Core.Rendering.Wb {
+ ///
+ /// Stateless utility methods for deterministic scenery placement.
+ ///
+ public static class SceneryHelpers {
+ ///
+ /// Displaces a scenery object into a pseudo-randomized location
+ ///
+ public static Vector3 Displace(ObjectDesc obj, uint ix, uint iy, uint iq) {
+ float x;
+ float y;
+ float z = obj.BaseLoc.Origin.Z;
+ var loc = obj.BaseLoc;
+
+ if (obj.DisplaceX <= 0)
+ x = loc.Origin.X;
+ else
+ x = (float)(unchecked((uint)(1813693831u * iy - (iq + 45773u) * (1360117743u * iy * ix + 1888038839u) - 1109124029u * ix))
+ * 2.3283064e-10 * obj.DisplaceX + loc.Origin.X);
+
+ if (obj.DisplaceY <= 0)
+ y = loc.Origin.Y;
+ else
+ y = (float)(unchecked((uint)(1813693831u * iy - (iq + 72719u) * (1360117743u * iy * ix + 1888038839u) - 1109124029u * ix))
+ * 2.3283064e-10 * obj.DisplaceY + loc.Origin.Y);
+
+ var quadrantVal = unchecked((uint)(1813693831u * iy - ix * (1870387557u * iy + 1109124029u) - 402451965u)) * 2.3283064e-10f;
+
+ if (quadrantVal >= 0.75) return new Vector3(y, -x, z);
+ if (quadrantVal >= 0.5) return new Vector3(-x, -y, z);
+ if (quadrantVal >= 0.25) return new Vector3(-y, x, z);
+ return new Vector3(x, y, z);
+ }
+
+ ///
+ /// Returns the scale for a scenery object
+ ///
+ public static float ScaleObj(ObjectDesc obj, uint x, uint y, uint k) {
+ var minScale = obj.MinScale;
+ var maxScale = obj.MaxScale;
+
+ if (minScale == maxScale)
+ return maxScale;
+
+ return (float)(Math.Pow(maxScale / minScale,
+ unchecked((uint)(1813693831u * y - (k + 32593u) * (1360117743u * y * x + 1888038839u) - 1109124029u * x)) * 2.3283064e-10) * minScale);
+ }
+
+ ///
+ /// Returns the rotation for a scenery object as a Quaternion.
+ ///
+ public static Quaternion RotateObj(ObjectDesc obj, uint x, uint y, uint k, Vector3 loc) {
+ var baseOrientation = new Quaternion(obj.BaseLoc.Orientation.X, obj.BaseLoc.Orientation.Y, obj.BaseLoc.Orientation.Z, obj.BaseLoc.Orientation.W);
+
+ if (obj.MaxRotation <= 0.0f)
+ return baseOrientation;
+
+ var degrees = (float)(unchecked((uint)(1813693831u * y - (k + 63127u) * (1360117743u * y * x + 1888038839u) - 1109124029u * x))
+ * 2.3283064e-10 * obj.MaxRotation);
+
+ return SetHeading(baseOrientation, degrees);
+ }
+
+ ///
+ /// Aligns an object to a plane normal, returning Quaternion.
+ ///
+ public static Quaternion ObjAlign(ObjectDesc obj, Vector3 normal, float z, Vector3 loc) {
+ var baseOrientation = new Quaternion(obj.BaseLoc.Orientation.X, obj.BaseLoc.Orientation.Y, obj.BaseLoc.Orientation.Z, obj.BaseLoc.Orientation.W);
+
+ var negNormal = -normal;
+ var headingDeg = (450.0f - (MathF.Atan2(negNormal.Y, negNormal.X) * 180f / MathF.PI)) % 360f;
+
+ return SetHeading(baseOrientation, headingDeg);
+ }
+
+ public static Quaternion SetHeading(Quaternion orientation, float degrees) {
+ var rads = degrees * MathF.PI / 180f;
+
+ var matrix = Matrix4x4.CreateFromQuaternion(orientation);
+ var heading = new Vector3(MathF.Sin(rads), MathF.Cos(rads), matrix.M23 + matrix.M13);
+
+ var normal = Vector3.Normalize(heading);
+
+ // Avoid attempting to rotate if normal is too small
+ if (normal.LengthSquared() < 0.0001f)
+ return orientation;
+
+ var headingDeg = MathF.Atan2(normal.Y, normal.X) * 180f / MathF.PI;
+ var zDeg = 450.0f - headingDeg;
+ var zRot = -(zDeg % 360.0f) * MathF.PI / 180f;
+
+ var xRot = MathF.Asin(normal.Z);
+ return Quaternion.CreateFromYawPitchRoll(xRot, 0, zRot);
+ }
+
+ ///
+ /// Returns true if floor slope is within bounds for this object
+ ///
+ public static bool CheckSlope(ObjectDesc obj, float zNormal) {
+ return zNormal >= obj.MinSlope && zNormal <= obj.MaxSlope;
+ }
+ }
+}
diff --git a/src/AcDream.Core/Rendering/Wb/TerrainEntry.cs b/src/AcDream.Core/Rendering/Wb/TerrainEntry.cs
new file mode 100644
index 0000000..8b217c5
--- /dev/null
+++ b/src/AcDream.Core/Rendering/Wb/TerrainEntry.cs
@@ -0,0 +1,239 @@
+using System;
+
+namespace AcDream.Core.Rendering.Wb {
+ ///
+ /// Flags that indicate which fields are present in TerrainEntry
+ ///
+ [Flags]
+ public enum TerrainEntryFlags : byte {
+ None = 0,
+ Height = 1 << 0,
+ Texture = 1 << 1,
+ Scenery = 1 << 2,
+ Road = 1 << 3,
+ Encounters = 1 << 4,
+ }
+
+ ///
+ /// Represents a terrain entry with optional height, texture, scenery, road, encounters, and flags.
+ /// Flags are automatically synchronized with nullable fields.
+ /// Packed format: [Height:8][Texture:5][Scenery:5][Encounters:4][Road:3][Unused:2][Flags:5]
+ ///
+ public struct TerrainEntry {
+ private const int HEIGHT_SHIFT = 24;
+ private const uint HEIGHT_MASK = 0xFF000000;
+
+ private const int TEXTURE_SHIFT = 19;
+ private const uint TEXTURE_MASK = 0x00F80000;
+
+ private const int SCENERY_SHIFT = 14;
+ private const uint SCENERY_MASK = 0x0007C000;
+
+ private const int ENCOUNTERS_SHIFT = 10;
+ private const uint ENCOUNTERS_MASK = 0x00003C00;
+
+ private const int ROAD_SHIFT = 7;
+ private const uint ROAD_MASK = 0x00000380;
+
+ private const int FLAGS_SHIFT = 0;
+ private const uint FLAGS_MASK = 0x0000001F;
+
+ private uint _data;
+
+ /// Gets the flags for this terrain entry, indicating which fields are set.
+ public TerrainEntryFlags Flags {
+ get => (TerrainEntryFlags)((_data & FLAGS_MASK) >> FLAGS_SHIFT);
+ private set => _data = (_data & ~FLAGS_MASK) | ((uint)value << FLAGS_SHIFT);
+ }
+
+ /// Gets or sets the height of the terrain.
+ public byte? Height {
+ get => (_data & (uint)TerrainEntryFlags.Height) != 0
+ ? (byte)((_data & HEIGHT_MASK) >> HEIGHT_SHIFT)
+ : null;
+ set {
+ if (value.HasValue) {
+ _data = (_data & ~HEIGHT_MASK) | ((uint)value.Value << HEIGHT_SHIFT);
+ Flags |= TerrainEntryFlags.Height;
+ }
+ else {
+ _data &= ~HEIGHT_MASK;
+ Flags &= ~TerrainEntryFlags.Height;
+ }
+ }
+ }
+
+ /// Gets or sets the texture type of the terrain.
+ public byte? Type {
+ get => (_data & (uint)TerrainEntryFlags.Texture) != 0
+ ? (byte)((_data & TEXTURE_MASK) >> TEXTURE_SHIFT)
+ : null;
+ set {
+ if (value.HasValue) {
+ if (value.Value > 31)
+ throw new ArgumentOutOfRangeException(nameof(Type), "Texture must be 0-31");
+ _data = (_data & ~TEXTURE_MASK) | ((uint)value.Value << TEXTURE_SHIFT);
+ Flags |= TerrainEntryFlags.Texture;
+ }
+ else {
+ _data &= ~TEXTURE_MASK;
+ Flags &= ~TerrainEntryFlags.Texture;
+ }
+ }
+ }
+
+ /// Gets or sets the scenery index of the terrain.
+ public byte? Scenery {
+ get => (_data & (uint)TerrainEntryFlags.Scenery) != 0
+ ? (byte)((_data & SCENERY_MASK) >> SCENERY_SHIFT)
+ : null;
+ set {
+ if (value.HasValue) {
+ if (value.Value > 31)
+ throw new ArgumentOutOfRangeException(nameof(Scenery), "Scenery must be 0-31");
+ _data = (_data & ~SCENERY_MASK) | ((uint)value.Value << SCENERY_SHIFT);
+ Flags |= TerrainEntryFlags.Scenery;
+ }
+ else {
+ _data &= ~SCENERY_MASK;
+ Flags &= ~TerrainEntryFlags.Scenery;
+ }
+ }
+ }
+
+ /// Gets or sets the road index of the terrain.
+ public byte? Road {
+ get => (_data & (uint)TerrainEntryFlags.Road) != 0
+ ? (byte)((_data & ROAD_MASK) >> ROAD_SHIFT)
+ : null;
+ set {
+ if (value.HasValue) {
+ if (value.Value > 7)
+ throw new ArgumentOutOfRangeException(nameof(Road), "Road must be 0-7");
+ _data = (_data & ~ROAD_MASK) | ((uint)value.Value << ROAD_SHIFT);
+ Flags |= TerrainEntryFlags.Road;
+ }
+ else {
+ _data &= ~ROAD_MASK;
+ Flags &= ~TerrainEntryFlags.Road;
+ }
+ }
+ }
+
+ /// Gets or sets the encounters index of the terrain.
+ public byte? Encounters {
+ get => (_data & (uint)TerrainEntryFlags.Encounters) != 0
+ ? (byte)((_data & ENCOUNTERS_MASK) >> ENCOUNTERS_SHIFT)
+ : null;
+ set {
+ if (value.HasValue) {
+ if (value.Value > 15)
+ throw new ArgumentOutOfRangeException(nameof(Encounters), "Encounters must be 0-15");
+ _data = (_data & ~ENCOUNTERS_MASK) | ((uint)value.Value << ENCOUNTERS_SHIFT);
+ Flags |= TerrainEntryFlags.Encounters;
+ }
+ else {
+ _data &= ~ENCOUNTERS_MASK;
+ Flags &= ~TerrainEntryFlags.Encounters;
+ }
+ }
+ }
+
+ /// Initializes a new instance of the struct.
+ public TerrainEntry() {
+ _data = 0;
+ }
+
+ ///
+ /// Initializes a new instance of the struct with specified components.
+ ///
+ /// The terrain height.
+ /// The texture type.
+ /// The scenery index.
+ /// The road index.
+ /// The encounters index.
+ public TerrainEntry(byte? height, byte? texture, byte? scenery, byte? road, byte? encounters) {
+ _data = 0;
+
+ if (texture.HasValue && texture.Value > 31)
+ throw new ArgumentOutOfRangeException(nameof(texture), "Texture must be 0-31");
+ if (scenery.HasValue && scenery.Value > 31)
+ throw new ArgumentOutOfRangeException(nameof(scenery), "Scenery must be 0-31");
+ if (road.HasValue && road.Value > 7)
+ throw new ArgumentOutOfRangeException(nameof(road), "Road must be 0-7");
+ if (encounters.HasValue && encounters.Value > 15)
+ throw new ArgumentOutOfRangeException(nameof(encounters), "Encounters must be 0-15");
+
+ Height = height;
+ Type = texture;
+ Scenery = scenery;
+ Road = road;
+ Encounters = encounters;
+ }
+
+ /// Creates a with only height data.
+ /// The height.
+ /// A new .
+ public static TerrainEntry FromHeight(byte height) => new(height, null, null, null, null);
+
+ /// Creates a with only texture data.
+ /// The texture type.
+ /// A new .
+ public static TerrainEntry FromTexture(byte texture) => new(null, texture, null, null, null);
+
+ /// Creates a with only scenery data.
+ /// The scenery index.
+ /// A new .
+ public static TerrainEntry FromScenery(byte scenery) => new(null, null, scenery, null, null);
+
+ /// Creates a with only road data.
+ /// The road index.
+ /// A new .
+ public static TerrainEntry FromRoad(byte road) => new(null, null, null, road, null);
+
+ /// Creates a with only encounters data.
+ /// The encounters index.
+ /// A new .
+ public static TerrainEntry FromEncounters(byte encounters) => new(null, null, null, null, encounters);
+
+ /// Creates a with texture and scenery data.
+ /// The texture type.
+ /// The scenery index.
+ /// A new .
+ public static TerrainEntry FromTextureScenery(byte texture, byte scenery) => new(null, texture, scenery, null, null);
+
+ ///
+ /// Returns the packed 32-bit representation
+ ///
+ public uint Pack() => _data;
+
+ ///
+ /// Creates a TerrainEntry from a packed 32-bit value
+ ///
+ public static TerrainEntry Unpack(uint packed) {
+ return new TerrainEntry { _data = packed };
+ }
+
+ ///
+ /// Merges another entry into this one. Null values are ignored.
+ ///
+ public void Merge(TerrainEntry value) {
+ if (value.Height.HasValue) Height = value.Height;
+ if (value.Type.HasValue) Type = value.Type;
+ if (value.Scenery.HasValue) Scenery = value.Scenery;
+ if (value.Road.HasValue) Road = value.Road;
+ if (value.Encounters.HasValue) Encounters = value.Encounters;
+ }
+
+ ///
+ public override string ToString() {
+ string height = Height?.ToString() ?? "null";
+ string texture = Type?.ToString() ?? "null";
+ string scenery = Scenery?.ToString() ?? "null";
+ string road = Road?.ToString() ?? "null";
+ string encounters = Encounters?.ToString() ?? "null";
+
+ return $"Height:{height}, Texture:{texture}, Scenery:{scenery}, Road:{road}, Encounters:{encounters}, Flags:{Flags}";
+ }
+ }
+}
diff --git a/src/AcDream.Core/Rendering/Wb/TerrainUtils.cs b/src/AcDream.Core/Rendering/Wb/TerrainUtils.cs
new file mode 100644
index 0000000..66c00c8
--- /dev/null
+++ b/src/AcDream.Core/Rendering/Wb/TerrainUtils.cs
@@ -0,0 +1,256 @@
+using System;
+using System.Numerics;
+using System.Runtime.CompilerServices;
+
+namespace AcDream.Core.Rendering.Wb
+{
+ ///
+ /// Utility methods for terrain calculations.
+ ///
+ public static class TerrainUtils
+ {
+ ///
+ /// The width of a road in units.
+ ///
+ public const float RoadWidth = 5f;
+
+ ///
+ /// The minimum Z component of a surface normal for it to be considered walkable.
+ ///
+ public const float FloorZ = 0.66417414618662751f;
+
+ ///
+ /// Determines if a surface with the given normal is walkable.
+ ///
+ /// The surface normal.
+ /// True if the surface is walkable, false otherwise.
+ public static bool IsValidWalkable(Vector3 normal)
+ {
+ return normal.Z >= FloorZ;
+ }
+
+ ///
+ /// 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.
+ ///
+ /// The global landblock X coordinate.
+ /// The local cell X coordinate (0-7).
+ /// The global landblock Y coordinate.
+ /// The local cell Y coordinate (0-7).
+ /// The split direction for the cell.
+ [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;
+ }
+
+ ///
+ /// Gets the interpolated terrain height at a local position within a landblock.
+ /// Uses barycentric interpolation on the cell's triangle pair.
+ ///
+ 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);
+ }
+ }
+ }
+
+ ///
+ /// Gets the terrain surface normal at a local position within a landblock.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Checks if a local position within a landblock is on a road.
+ /// Uses per-vertex road flags and proximity testing.
+ ///
+ 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;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Gets the road value for a specific vertex in the 9x9 terrain entry grid.
+ ///
+ 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();
+ }
+ }
+}
diff --git a/src/AcDream.Core/Rendering/Wb/TextureHelpers.cs b/src/AcDream.Core/Rendering/Wb/TextureHelpers.cs
new file mode 100644
index 0000000..38a347f
--- /dev/null
+++ b/src/AcDream.Core/Rendering/Wb/TextureHelpers.cs
@@ -0,0 +1,161 @@
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.Core.Rendering.Wb {
+ public static class TextureHelpers {
+ public static byte[] CreateSolidColorTexture(DatReaderWriter.Types.ColorARGB color, int width, int height) {
+ var bytes = new byte[width * height * 4];
+ for (int i = 0; i < width * height; i++) {
+ bytes[i * 4 + 0] = color.Red;
+ bytes[i * 4 + 1] = color.Green;
+ bytes[i * 4 + 2] = color.Blue;
+ bytes[i * 4 + 3] = color.Alpha;
+ }
+ return bytes;
+ }
+
+ public static void FillIndex16(byte[] src, Palette palette, Span dst, int width, int height, bool isClipMap = false) {
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ var srcIdx = (y * width + x) * 2;
+ var palIdx = (ushort)(src[srcIdx] | (src[srcIdx + 1] << 8));
+ var color = palette.Colors[palIdx];
+ var dstIdx = (y * width + x) * 4;
+
+ if (isClipMap && palIdx < 8) {
+ dst[dstIdx + 0] = 0;
+ dst[dstIdx + 1] = 0;
+ dst[dstIdx + 2] = 0;
+ dst[dstIdx + 3] = 0;
+ }
+ else {
+ dst[dstIdx + 0] = color.Red;
+ dst[dstIdx + 1] = color.Green;
+ dst[dstIdx + 2] = color.Blue;
+ dst[dstIdx + 3] = color.Alpha;
+ }
+ }
+ }
+ }
+
+ public static void FillP8(byte[] src, Palette palette, Span dst, int width, int height, bool isClipMap = false) {
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ var srcIdx = (y * width + x);
+ var palIdx = src[srcIdx];
+ var color = palette.Colors[palIdx];
+ var dstIdx = (y * width + x) * 4;
+
+ if (isClipMap && palIdx < 8) {
+ dst[dstIdx + 0] = 0;
+ dst[dstIdx + 1] = 0;
+ dst[dstIdx + 2] = 0;
+ dst[dstIdx + 3] = 0;
+ }
+ else {
+ dst[dstIdx + 0] = color.Red;
+ dst[dstIdx + 1] = color.Green;
+ dst[dstIdx + 2] = color.Blue;
+ dst[dstIdx + 3] = color.Alpha;
+ }
+ }
+ }
+ }
+
+ public static void FillR5G6B5(byte[] src, Span dst, int width, int height) {
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ var srcIdx = (y * width + x) * 2;
+ var val = (ushort)(src[srcIdx] | (src[srcIdx + 1] << 8));
+ var dstIdx = (y * width + x) * 4;
+
+ dst[dstIdx + 0] = (byte)(((val & 0xF800) >> 11) << 3);
+ dst[dstIdx + 1] = (byte)(((val & 0x7E0) >> 5) << 2);
+ dst[dstIdx + 2] = (byte)((val & 0x1F) << 3);
+ dst[dstIdx + 3] = 255;
+ }
+ }
+ }
+
+ public static void FillA4R4G4B4(byte[] src, Span dst, int width, int height) {
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ var srcIdx = (y * width + x) * 2;
+ var val = (ushort)(src[srcIdx] | (src[srcIdx + 1] << 8));
+ var dstIdx = (y * width + x) * 4;
+
+ dst[dstIdx + 0] = (byte)(((val >> 8) & 0x0F) * 17);
+ dst[dstIdx + 1] = (byte)(((val >> 4) & 0x0F) * 17);
+ dst[dstIdx + 2] = (byte)((val & 0x0F) * 17);
+ var alpha = (byte)(((val >> 12) & 0x0F) * 17);
+ dst[dstIdx + 3] = alpha;
+ }
+ }
+ }
+
+ public static void FillA8R8G8B8(byte[] src, Span dst, int width, int height) {
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ var srcIdx = (y * width + x) * 4;
+ var dstIdx = (y * width + x) * 4;
+
+ dst[dstIdx + 0] = src[srcIdx + 2]; // R
+ dst[dstIdx + 1] = src[srcIdx + 1]; // G
+ dst[dstIdx + 2] = src[srcIdx + 0]; // B
+
+ var alpha = src[srcIdx + 3];
+ dst[dstIdx + 3] = alpha;
+ }
+ }
+ }
+
+ public static void FillR8G8B8(byte[] src, Span dst, int width, int height) {
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ var srcIdx = (y * width + x) * 3;
+ var dstIdx = (y * width + x) * 4;
+
+ dst[dstIdx + 0] = src[srcIdx + 2]; // R
+ dst[dstIdx + 1] = src[srcIdx + 1]; // G
+ dst[dstIdx + 2] = src[srcIdx + 0]; // B
+ dst[dstIdx + 3] = 255; // A
+ }
+ }
+ }
+
+ public static void FillA8(byte[] src, Span dst, int width, int height) {
+ for (int i = 0; i < width * height; i++) {
+ byte val = src[i];
+ dst[i * 4] = 255;
+ dst[i * 4 + 1] = 255;
+ dst[i * 4 + 2] = 255;
+ dst[i * 4 + 3] = val;
+ }
+ }
+
+ public static void FillA8Additive(byte[] src, Span dst, int width, int height) {
+ for (int i = 0; i < width * height; i++) {
+ byte val = src[i];
+ dst[i * 4] = val;
+ dst[i * 4 + 1] = val;
+ dst[i * 4 + 2] = val;
+ dst[i * 4 + 3] = val;
+ }
+ }
+
+ ///
+ /// Checks if a pixel format is compressed
+ ///
+ public static bool IsCompressedFormat(DatReaderWriter.Enums.PixelFormat format) {
+ return format == DatReaderWriter.Enums.PixelFormat.PFID_DXT1 ||
+ format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 ||
+ format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
+ }
+
+ public static byte[] Color565ToRgba(ushort color565) {
+ int r = (color565 >> 11) & 31;
+ int g = (color565 >> 5) & 63;
+ int b = color565 & 31;
+ return new byte[] { (byte)(r * 255 / 31), (byte)(g * 255 / 63), (byte)(b * 255 / 31), 255 };
+ }
+ }
+}
diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs
index 8b3158f..49cfe19 100644
--- a/src/AcDream.Core/Textures/SurfaceDecoder.cs
+++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs
@@ -1,6 +1,6 @@
+using AcDream.Core.Rendering.Wb;
using BCnEncoder.Decoder;
using BCnEncoder.Shared;
-using Chorizite.OpenGLSDLBackend.Lib;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs
index b306e41..953c7de 100644
--- a/src/AcDream.Core/World/SceneryGenerator.cs
+++ b/src/AcDream.Core/World/SceneryGenerator.cs
@@ -1,9 +1,8 @@
using System.Numerics;
-using Chorizite.OpenGLSDLBackend.Lib;
+using AcDream.Core.Rendering.Wb;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
-using WorldBuilder.Shared.Modules.Landscape.Lib;
namespace AcDream.Core.World;
diff --git a/src/AcDream.Core/World/WbSceneryAdapter.cs b/src/AcDream.Core/World/WbSceneryAdapter.cs
index 1a90149..8313839 100644
--- a/src/AcDream.Core/World/WbSceneryAdapter.cs
+++ b/src/AcDream.Core/World/WbSceneryAdapter.cs
@@ -1,5 +1,5 @@
+using AcDream.Core.Rendering.Wb;
using DatReaderWriter.DBObjs;
-using WorldBuilder.Shared.Models;
namespace AcDream.Core.World;
diff --git a/tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs b/tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs
index b7fb62a..b2a4be7 100644
--- a/tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs
+++ b/tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs
@@ -1,4 +1,4 @@
-using Chorizite.OpenGLSDLBackend.Lib;
+using AcDream.Core.Rendering.Wb;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;