From 16bc10c99dd1a8bfb15f4bc5e9f808d68bae5439 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 21 May 2026 15:13:26 +0200 Subject: [PATCH] feat(O-T2): extract pure stateless helpers to AcDream.Core.Rendering.Wb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Rendering/Wb/CellSplitDirection.cs | 18 ++ .../Rendering/Wb/SceneryHelpers.cs | 107 ++++++++ src/AcDream.Core/Rendering/Wb/TerrainEntry.cs | 239 ++++++++++++++++ src/AcDream.Core/Rendering/Wb/TerrainUtils.cs | 256 ++++++++++++++++++ .../Rendering/Wb/TextureHelpers.cs | 161 +++++++++++ src/AcDream.Core/Textures/SurfaceDecoder.cs | 2 +- src/AcDream.Core/World/SceneryGenerator.cs | 3 +- src/AcDream.Core/World/WbSceneryAdapter.cs | 2 +- .../Textures/TextureDecodeConformanceTests.cs | 2 +- 9 files changed, 785 insertions(+), 5 deletions(-) create mode 100644 src/AcDream.Core/Rendering/Wb/CellSplitDirection.cs create mode 100644 src/AcDream.Core/Rendering/Wb/SceneryHelpers.cs create mode 100644 src/AcDream.Core/Rendering/Wb/TerrainEntry.cs create mode 100644 src/AcDream.Core/Rendering/Wb/TerrainUtils.cs create mode 100644 src/AcDream.Core/Rendering/Wb/TextureHelpers.cs 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;