Merge phase-2/static-meshes-and-textures: Phase 2a static meshes

Phase 2a MVP complete. 8 commits implementing Tasks 1-8 of the
Phase 2 plan:
- BCnEncoder.Net 2.2.1 added to AcDream.Core
- GfxObjMesh.Build: multi-surface CPU mesh extractor from GfxObj
  vertex+polygon data, with position/UV dedupe and fan triangulation
- SurfaceDecoder: BCnEncoder.Net-backed DXT decoder + A8R8G8B8 raw,
  with 1x1 magenta fallback
- LandblockLoader: parses Stabs and BuildingInfo from LandBlockInfo
  into WorldEntity records, type-filtering to GfxObj + Setup only
- SetupMesh.Flatten: single-level part hierarchy walker
- WorldView: 3x3 neighbor landblock id computation with edge clamping
- TextureCache: App-side GL texture handle cache, walks the
  Surface → SurfaceTexture → RenderSurface chain from the dats
- StaticMeshRenderer + mesh.vert/mesh.frag + GameWindow wire-up

21 new xUnit tests (17 → 38), all green, build clean.

Smoke verified against real dats: Holtburg landblock hydrated
126 static entities (Stabs + Buildings), process runs without
exception, terrain + static meshes pipeline complete end-to-end.

Phase 2a stops here. Tasks 9-18 (conditional texture debugging,
terrain atlas, 3x3 neighbor rendering, dual cameras, plugin API
growth) deferred to Phase 2b in a future session.
This commit is contained in:
Erik 2026-04-10 18:33:39 +02:00
commit 1d1e668a2f
21 changed files with 1322 additions and 0 deletions

View file

@ -21,6 +21,10 @@ public sealed class GameWindow : IDisposable
private DatCollection? _dats; private DatCollection? _dats;
private float _lastMouseX; private float _lastMouseX;
private float _lastMouseY; private float _lastMouseY;
private StaticMeshRenderer? _staticMesh;
private Shader? _meshShader;
private TextureCache? _textureCache;
private IReadOnlyList<AcDream.Core.World.WorldEntity> _entities = Array.Empty<AcDream.Core.World.WorldEntity>();
public GameWindow(string datDir) => _datDir = datDir; public GameWindow(string datDir) => _datDir = datDir;
@ -83,6 +87,10 @@ public sealed class GameWindow : IDisposable
Path.Combine(shadersDir, "terrain.vert"), Path.Combine(shadersDir, "terrain.vert"),
Path.Combine(shadersDir, "terrain.frag")); Path.Combine(shadersDir, "terrain.frag"));
_meshShader = new Shader(_gl,
Path.Combine(shadersDir, "mesh.vert"),
Path.Combine(shadersDir, "mesh.frag"));
_camera = new OrbitCamera _camera = new OrbitCamera
{ {
Aspect = _window!.Size.X / (float)_window.Size.Y, Aspect = _window!.Size.X / (float)_window.Size.Y,
@ -117,16 +125,82 @@ public sealed class GameWindow : IDisposable
var meshData = LandblockMesh.Build(block); var meshData = LandblockMesh.Build(block);
_terrain = new TerrainRenderer(_gl, meshData, _shader); _terrain = new TerrainRenderer(_gl, meshData, _shader);
_textureCache = new TextureCache(_gl, _dats);
_staticMesh = new StaticMeshRenderer(_gl, _meshShader, _textureCache);
// Load LandBlockInfo for Holtburg, hydrate entities.
var info = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
var entities = info is not null
? AcDream.Core.World.LandblockLoader.BuildEntitiesFromInfo(info)
: Array.Empty<AcDream.Core.World.WorldEntity>();
// Populate MeshRefs for each entity by resolving its source id to GfxObj or Setup
// and extracting sub-meshes. Store back onto the entity. Since WorldEntity is
// `required init`, we rebuild the entity here.
var hydratedEntities = new List<AcDream.Core.World.WorldEntity>(entities.Count);
foreach (var e in entities)
{
var meshRefs = new List<AcDream.Core.World.MeshRef>();
if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
{
// GfxObj: one mesh ref with identity transform.
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId);
if (gfx is not null)
{
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx);
_staticMesh.EnsureUploaded(e.SourceGfxObjOrSetupId, subMeshes);
meshRefs.Add(new AcDream.Core.World.MeshRef(e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
}
}
else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
// Setup: flatten into parts, upload each part's GfxObj.
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
if (setup is not null)
{
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
foreach (var mr in flat)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue;
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx);
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
meshRefs.Add(mr);
}
}
}
if (meshRefs.Count > 0)
{
hydratedEntities.Add(new AcDream.Core.World.WorldEntity
{
Id = e.Id,
SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
Position = e.Position,
Rotation = e.Rotation,
MeshRefs = meshRefs,
});
}
}
_entities = hydratedEntities;
Console.WriteLine($"hydrated {_entities.Count} entities on landblock 0x{landblockId:X8}");
} }
private void OnRender(double deltaSeconds) private void OnRender(double deltaSeconds)
{ {
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
_terrain?.Draw(_camera!); _terrain?.Draw(_camera!);
_staticMesh?.Draw(_camera!, _entities);
} }
private void OnClosing() private void OnClosing()
{ {
_staticMesh?.Dispose();
_textureCache?.Dispose();
_meshShader?.Dispose();
_terrain?.Dispose(); _terrain?.Dispose();
_shader?.Dispose(); _shader?.Dispose();
_dats?.Dispose(); _dats?.Dispose();

View file

@ -0,0 +1,9 @@
#version 430 core
in vec2 vTex;
out vec4 fragColor;
uniform sampler2D uDiffuse;
void main() {
fragColor = texture(uDiffuse, vTex);
}

View file

@ -0,0 +1,15 @@
#version 430 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTex;
uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;
out vec2 vTex;
void main() {
vTex = aTex;
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
}

View file

@ -0,0 +1,134 @@
// src/AcDream.App/Rendering/StaticMeshRenderer.cs
using System.Numerics;
using AcDream.Core.Meshing;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
public sealed unsafe class StaticMeshRenderer : IDisposable
{
private readonly GL _gl;
private readonly Shader _shader;
private readonly TextureCache _textures;
// One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes.
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
public StaticMeshRenderer(GL gl, Shader shader, TextureCache textures)
{
_gl = gl;
_shader = shader;
_textures = textures;
}
public void EnsureUploaded(uint gfxObjId, IReadOnlyList<GfxObjSubMesh> subMeshes)
{
if (_gpuByGfxObj.ContainsKey(gfxObjId))
return;
var list = new List<SubMeshGpu>(subMeshes.Count);
foreach (var sm in subMeshes)
list.Add(UploadSubMesh(sm));
_gpuByGfxObj[gfxObjId] = list;
}
private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm)
{
uint vao = _gl.GenVertexArray();
_gl.BindVertexArray(vao);
uint vbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
fixed (void* p = sm.Vertices)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
uint ebo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
fixed (void* p = sm.Indices)
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
(nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
uint stride = (uint)sizeof(Vertex);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
_gl.BindVertexArray(0);
return new SubMeshGpu
{
Vao = vao,
Vbo = vbo,
Ebo = ebo,
IndexCount = sm.Indices.Length,
SurfaceId = sm.SurfaceId,
};
}
public void Draw(OrbitCamera camera, IEnumerable<WorldEntity> entities)
{
_shader.Use();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
foreach (var entity in entities)
{
if (entity.MeshRefs.Count == 0)
continue;
foreach (var meshRef in entity.MeshRefs)
{
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
continue;
// model = entity root transform * per-part transform
var entityRoot =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
var model = meshRef.PartTransform * entityRoot;
_shader.SetMatrix4("uModel", model);
foreach (var sub in subMeshes)
{
uint tex = _textures.GetOrUpload(sub.SurfaceId);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
_gl.BindVertexArray(sub.Vao);
_gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
}
}
}
_gl.BindVertexArray(0);
}
public void Dispose()
{
foreach (var subs in _gpuByGfxObj.Values)
{
foreach (var sub in subs)
{
_gl.DeleteBuffer(sub.Vbo);
_gl.DeleteBuffer(sub.Ebo);
_gl.DeleteVertexArray(sub.Vao);
}
}
_gpuByGfxObj.Clear();
}
private sealed class SubMeshGpu
{
public uint Vao;
public uint Vbo;
public uint Ebo;
public int IndexCount;
public uint SurfaceId;
}
}

View file

@ -0,0 +1,92 @@
// src/AcDream.App/Rendering/TextureCache.cs
using AcDream.Core.Textures;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
public sealed unsafe class TextureCache : IDisposable
{
private readonly GL _gl;
private readonly DatCollection _dats;
private readonly Dictionary<uint, uint> _handlesBySurfaceId = new();
private uint _magentaHandle;
public TextureCache(GL gl, DatCollection dats)
{
_gl = gl;
_dats = dats;
}
/// <summary>
/// Get or upload the GL texture handle for a Surface id. Returns a
/// 1x1 magenta fallback if the Surface or its RenderSurface chain is
/// missing or uses an unsupported format.
/// </summary>
public uint GetOrUpload(uint surfaceId)
{
if (_handlesBySurfaceId.TryGetValue(surfaceId, out var h))
return h;
var decoded = DecodeFromDats(surfaceId);
h = UploadRgba8(decoded);
_handlesBySurfaceId[surfaceId] = h;
return h;
}
private DecodedTexture DecodeFromDats(uint surfaceId)
{
var surface = _dats.Get<Surface>(surfaceId);
if (surface is null)
return DecodedTexture.Magenta;
var surfaceTexture = _dats.Get<SurfaceTexture>((uint)surface.OrigTextureId);
if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
return DecodedTexture.Magenta;
var rs = _dats.Get<RenderSurface>((uint)surfaceTexture.Textures[0]);
if (rs is null)
return DecodedTexture.Magenta;
return SurfaceDecoder.DecodeRenderSurface(rs);
}
private uint UploadRgba8(DecodedTexture decoded)
{
uint tex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, tex);
fixed (byte* p = decoded.Rgba8)
_gl.TexImage2D(
TextureTarget.Texture2D,
0,
InternalFormat.Rgba8,
(uint)decoded.Width,
(uint)decoded.Height,
0,
PixelFormat.Rgba,
PixelType.UnsignedByte,
p);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
_gl.BindTexture(TextureTarget.Texture2D, 0);
return tex;
}
public void Dispose()
{
foreach (var h in _handlesBySurfaceId.Values)
_gl.DeleteTexture(h);
_handlesBySurfaceId.Clear();
if (_magentaHandle != 0)
{
_gl.DeleteTexture(_magentaHandle);
_magentaHandle = 0;
}
}
}

View file

@ -7,6 +7,7 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BCnEncoder.Net" Version="2.2.1" />
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.4" /> <PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.4" />
<PackageReference Include="Serilog" Version="4.0.2" /> <PackageReference Include="Serilog" Version="4.0.2" />
</ItemGroup> </ItemGroup>

View file

@ -0,0 +1,88 @@
using System.Numerics;
using AcDream.Core.Terrain;
using DatReaderWriter.DBObjs;
namespace AcDream.Core.Meshing;
public static class GfxObjMesh
{
/// <summary>
/// Walk a GfxObj's polygons and produce one <see cref="GfxObjSubMesh"/>
/// per referenced Surface. Polygons are triangulated as fans.
/// </summary>
public static IReadOnlyList<GfxObjSubMesh> Build(GfxObj gfxObj)
{
// Group output vertices and indices per surface index.
var perSurface = new Dictionary<int, (List<Vertex> Vertices, List<uint> Indices, Dictionary<(int pos, int uv), uint> Dedupe)>();
foreach (var kvp in gfxObj.Polygons)
{
var poly = kvp.Value;
if (poly.VertexIds.Count < 3)
continue; // degenerate
int surfaceIdx = poly.PosSurface;
if (surfaceIdx < 0 || surfaceIdx >= gfxObj.Surfaces.Count)
continue; // out of range surface
if (!perSurface.TryGetValue(surfaceIdx, out var bucket))
{
bucket = (new List<Vertex>(), new List<uint>(), new Dictionary<(int, int), uint>());
perSurface[surfaceIdx] = bucket;
}
// Collect output vertex indices for this polygon.
var polyOut = new List<uint>(poly.VertexIds.Count);
bool skipPoly = false;
for (int i = 0; i < poly.VertexIds.Count; i++)
{
int posIdx = poly.VertexIds[i];
int uvIdx = i < poly.PosUVIndices.Count ? poly.PosUVIndices[i] : 0;
if (!gfxObj.VertexArray.Vertices.TryGetValue((ushort)posIdx, out var sw))
{
skipPoly = true;
break;
}
var texcoord = uvIdx >= 0 && uvIdx < sw.UVs.Count
? new Vector2(sw.UVs[uvIdx].U, sw.UVs[uvIdx].V)
: Vector2.Zero;
var key = (posIdx, uvIdx);
if (!bucket.Dedupe.TryGetValue(key, out var outIdx))
{
outIdx = (uint)bucket.Vertices.Count;
bucket.Vertices.Add(new Vertex(sw.Origin, sw.Normal, texcoord));
bucket.Dedupe[key] = outIdx;
}
polyOut.Add(outIdx);
}
if (skipPoly || polyOut.Count < 3)
continue;
// Fan triangulation: (v0, v1, v2), (v0, v2, v3), ...
for (int i = 1; i < polyOut.Count - 1; i++)
{
bucket.Indices.Add(polyOut[0]);
bucket.Indices.Add(polyOut[i]);
bucket.Indices.Add(polyOut[i + 1]);
}
}
// Emit one sub-mesh per surface.
var result = new List<GfxObjSubMesh>(perSurface.Count);
foreach (var kvp in perSurface)
{
var surfaceId = (uint)gfxObj.Surfaces[kvp.Key];
result.Add(new GfxObjSubMesh(
SurfaceId: surfaceId,
Vertices: kvp.Value.Vertices.ToArray(),
Indices: kvp.Value.Indices.ToArray()));
}
return result;
}
}

View file

@ -0,0 +1,12 @@
using AcDream.Core.Terrain;
namespace AcDream.Core.Meshing;
/// <summary>
/// One sub-mesh of a GfxObj: a vertex+index buffer that uses a single Surface.
/// A GfxObj with multiple surfaces produces multiple sub-meshes.
/// </summary>
public sealed record GfxObjSubMesh(
uint SurfaceId,
Vertex[] Vertices,
uint[] Indices);

View file

@ -0,0 +1,45 @@
using System.Numerics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Meshing;
public static class SetupMesh
{
/// <summary>
/// Flatten a Setup into a list of (GfxObjId, PartTransform) refs.
/// Uses the default placement frame and DefaultScale per part.
/// Does NOT walk ParentIndex — each part's transform is local to the setup root.
/// This is simplification for Phase 2; complex hierarchical rigs are Phase 3.
/// </summary>
public static IReadOnlyList<MeshRef> Flatten(Setup setup)
{
AnimationFrame? defaultAnim = null;
if (setup.PlacementFrames.TryGetValue(Placement.Default, out var af))
defaultAnim = af;
var result = new List<MeshRef>(setup.Parts.Count);
for (int i = 0; i < setup.Parts.Count; i++)
{
uint gfxObjId = (uint)setup.Parts[i];
Frame frame;
if (defaultAnim is not null && i < defaultAnim.Frames.Count)
frame = defaultAnim.Frames[i];
else
frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
Vector3 scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : Vector3.One;
var transform =
Matrix4x4.CreateScale(scale) *
Matrix4x4.CreateFromQuaternion(frame.Orientation) *
Matrix4x4.CreateTranslation(frame.Origin);
result.Add(new MeshRef(gfxObjId, transform));
}
return result;
}
}

View file

@ -0,0 +1,10 @@
namespace AcDream.Core.Textures;
public sealed record DecodedTexture(byte[] Rgba8, int Width, int Height)
{
/// <summary>1x1 magenta fallback for missing/unsupported textures.</summary>
public static readonly DecodedTexture Magenta = new(
Rgba8: [0xFF, 0x00, 0xFF, 0xFF],
Width: 1,
Height: 1);
}

View file

@ -0,0 +1,71 @@
using BCnEncoder.Decoder;
using BCnEncoder.Shared;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
namespace AcDream.Core.Textures;
public static class SurfaceDecoder
{
private static readonly BcDecoder BcDecoder = new();
/// <summary>
/// Decode a RenderSurface's pixel bytes into RGBA8. Returns <see cref="DecodedTexture.Magenta"/>
/// for unsupported formats, null data, or corrupt sizing.
/// </summary>
public static DecodedTexture DecodeRenderSurface(RenderSurface rs)
{
if (rs.SourceData is null || rs.Width <= 0 || rs.Height <= 0)
return DecodedTexture.Magenta;
try
{
return rs.Format switch
{
PixelFormat.PFID_A8R8G8B8 => DecodeA8R8G8B8(rs),
PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1),
PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2),
PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3),
_ => DecodedTexture.Magenta,
};
}
catch
{
return DecodedTexture.Magenta;
}
}
private static DecodedTexture DecodeA8R8G8B8(RenderSurface rs)
{
int expected = rs.Width * rs.Height * 4;
if (rs.SourceData.Length < expected)
return DecodedTexture.Magenta;
var rgba = new byte[expected];
// Source layout per pixel: B, G, R, A → swap to R, G, B, A
for (int i = 0; i < rs.Width * rs.Height; i++)
{
int s = i * 4;
rgba[s + 0] = rs.SourceData[s + 2]; // R <- R
rgba[s + 1] = rs.SourceData[s + 1]; // G <- G
rgba[s + 2] = rs.SourceData[s + 0]; // B <- B
rgba[s + 3] = rs.SourceData[s + 3]; // A <- A
}
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format)
{
var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format);
var rgba = new byte[rs.Width * rs.Height * 4];
for (int i = 0; i < pixels.Length; i++)
{
int s = i * 4;
rgba[s + 0] = pixels[i].r;
rgba[s + 1] = pixels[i].g;
rgba[s + 2] = pixels[i].b;
rgba[s + 3] = pixels[i].a;
}
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
}

View file

@ -0,0 +1,77 @@
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.Core.World;
public static class LandblockLoader
{
private const uint GfxObjMask = 0x01000000u;
private const uint SetupMask = 0x02000000u;
private const uint TypeMask = 0xFF000000u;
/// <summary>
/// Load a single landblock (heightmap + static objects) from the dats.
/// </summary>
/// <returns>Null if the landblock is missing from the cell dat.</returns>
public static LoadedLandblock? Load(DatCollection dats, uint landblockId)
{
var block = dats.Get<LandBlock>(landblockId);
if (block is null)
return null;
var info = dats.Get<LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
var entities = info is null
? Array.Empty<WorldEntity>()
: BuildEntitiesFromInfo(info);
return new LoadedLandblock(landblockId, block, entities);
}
/// <summary>
/// Pure mapping from a parsed LandBlockInfo to a list of WorldEntity.
/// Each Stab and BuildingInfo becomes one entity. Unsupported id types
/// (neither GfxObj 0x01xxxxxx nor Setup 0x02xxxxxx) are silently skipped.
/// MeshRefs is left empty at this stage — Task 5 populates it.
/// </summary>
public static IReadOnlyList<WorldEntity> BuildEntitiesFromInfo(LandBlockInfo info)
{
var result = new List<WorldEntity>(info.Objects.Count + info.Buildings.Count);
uint nextId = 1;
foreach (var stab in info.Objects)
{
if (!IsSupported(stab.Id))
continue;
result.Add(new WorldEntity
{
Id = nextId++,
SourceGfxObjOrSetupId = stab.Id,
Position = stab.Frame.Origin,
Rotation = stab.Frame.Orientation,
MeshRefs = Array.Empty<MeshRef>(),
});
}
foreach (var building in info.Buildings)
{
if (!IsSupported(building.ModelId))
continue;
result.Add(new WorldEntity
{
Id = nextId++,
SourceGfxObjOrSetupId = building.ModelId,
Position = building.Frame.Origin,
Rotation = building.Frame.Orientation,
MeshRefs = Array.Empty<MeshRef>(),
});
}
return result;
}
private static bool IsSupported(uint id)
{
var type = id & TypeMask;
return type == GfxObjMask || type == SetupMask;
}
}

View file

@ -0,0 +1,8 @@
using DatReaderWriter.DBObjs;
namespace AcDream.Core.World;
public sealed record LoadedLandblock(
uint LandblockId,
LandBlock Heightmap,
IReadOnlyList<WorldEntity> Entities);

View file

@ -0,0 +1,5 @@
using System.Numerics;
namespace AcDream.Core.World;
public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform);

View file

@ -0,0 +1,12 @@
using System.Numerics;
namespace AcDream.Core.World;
public sealed class WorldEntity
{
public required uint Id { get; init; }
public required uint SourceGfxObjOrSetupId { get; init; }
public required Vector3 Position { get; init; }
public required Quaternion Rotation { get; init; }
public required IReadOnlyList<MeshRef> MeshRefs { get; init; }
}

View file

@ -0,0 +1,55 @@
// src/AcDream.Core/World/WorldView.cs
using DatReaderWriter;
namespace AcDream.Core.World;
public sealed class WorldView
{
public uint CenterLandblockId { get; }
public IReadOnlyList<LoadedLandblock> Landblocks { get; }
public IEnumerable<WorldEntity> AllEntities => Landblocks.SelectMany(lb => lb.Entities);
private WorldView(uint centerLandblockId, IReadOnlyList<LoadedLandblock> landblocks)
{
CenterLandblockId = centerLandblockId;
Landblocks = landblocks;
}
/// <summary>
/// Load the 3x3 grid of landblocks around <paramref name="centerLandblockId"/>.
/// Missing neighbors (edges of the world or absent from the cell dat) are silently skipped.
/// </summary>
public static WorldView Load(DatCollection dats, uint centerLandblockId)
{
var loaded = new List<LoadedLandblock>();
foreach (var id in NeighborLandblockIds(centerLandblockId))
{
var lb = LandblockLoader.Load(dats, id);
if (lb is not null)
loaded.Add(lb);
}
return new WorldView(centerLandblockId, loaded);
}
/// <summary>
/// Enumerate the 3x3 neighbor landblock ids around a center. Clamps at the world edges
/// (skipping neighbors that would underflow or overflow the 8-bit coordinate range).
/// </summary>
public static IEnumerable<uint> NeighborLandblockIds(uint centerLandblockId)
{
int cx = (int)((centerLandblockId >> 24) & 0xFFu);
int cy = (int)((centerLandblockId >> 16) & 0xFFu);
for (int dy = -1; dy <= 1; dy++)
{
for (int dx = -1; dx <= 1; dx++)
{
int nx = cx + dx;
int ny = cy + dy;
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF)
continue;
yield return (uint)((nx << 24) | (ny << 16) | 0xFFFFu);
}
}
}
}

View file

@ -0,0 +1,250 @@
using System.Numerics;
using AcDream.Core.Meshing;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Lib;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Meshing;
public class GfxObjMeshTests
{
/// <summary>
/// Build a minimal GfxObj fixture with a single triangle using surface index 0.
/// Three unique positions, one UV slot each.
/// </summary>
private static GfxObj BuildSingleTriangle()
{
var gfx = new GfxObj
{
Surfaces = { 0x08000000u }, // synthetic surface id
VertexArray = new VertexArray
{
VertexType = VertexType.CSWVertexType,
Vertices =
{
[0] = new SWVertex
{
Origin = new Vector3(0, 0, 0),
Normal = new Vector3(0, 0, 1),
UVs = { new Vec2Duv { U = 0, V = 0 } },
},
[1] = new SWVertex
{
Origin = new Vector3(1, 0, 0),
Normal = new Vector3(0, 0, 1),
UVs = { new Vec2Duv { U = 1, V = 0 } },
},
[2] = new SWVertex
{
Origin = new Vector3(0, 1, 0),
Normal = new Vector3(0, 0, 1),
UVs = { new Vec2Duv { U = 0, V = 1 } },
},
},
},
Polygons =
{
[0] = new Polygon
{
PosSurface = 0,
NegSurface = -1,
VertexIds = { 0, 1, 2 },
PosUVIndices = { 0, 0, 0 },
},
},
};
return gfx;
}
[Fact]
public void Build_SingleTriangle_ProducesOneSubMeshOneTriangle()
{
var gfx = BuildSingleTriangle();
var subs = GfxObjMesh.Build(gfx);
var sub = Assert.Single(subs);
Assert.Equal(0x08000000u, sub.SurfaceId);
Assert.Equal(3, sub.Vertices.Length);
Assert.Equal(3, sub.Indices.Length); // one triangle, 3 indices
}
[Fact]
public void Build_SingleTriangle_CopiesPositionsNormalsAndUVs()
{
var gfx = BuildSingleTriangle();
var sub = GfxObjMesh.Build(gfx).Single();
// Indices point at unique vertices; collect them in order.
var vAtIdx0 = sub.Vertices[sub.Indices[0]];
var vAtIdx1 = sub.Vertices[sub.Indices[1]];
var vAtIdx2 = sub.Vertices[sub.Indices[2]];
Assert.Equal(new Vector3(0, 0, 0), vAtIdx0.Position);
Assert.Equal(new Vector3(1, 0, 0), vAtIdx1.Position);
Assert.Equal(new Vector3(0, 1, 0), vAtIdx2.Position);
Assert.Equal(new Vector3(0, 0, 1), vAtIdx0.Normal);
Assert.Equal(new Vector2(0, 0), vAtIdx0.TexCoord);
Assert.Equal(new Vector2(1, 0), vAtIdx1.TexCoord);
Assert.Equal(new Vector2(0, 1), vAtIdx2.TexCoord);
}
[Fact]
public void Build_Quad_IsTriangulatedAsFan()
{
// Single quad polygon with 4 vertices -> 2 triangles, 6 indices.
var gfx = new GfxObj
{
Surfaces = { 0x08000000u },
VertexArray = new VertexArray
{
Vertices =
{
[0] = new SWVertex { Origin = new(0, 0, 0), UVs = { new Vec2Duv() } },
[1] = new SWVertex { Origin = new(1, 0, 0), UVs = { new Vec2Duv() } },
[2] = new SWVertex { Origin = new(1, 1, 0), UVs = { new Vec2Duv() } },
[3] = new SWVertex { Origin = new(0, 1, 0), UVs = { new Vec2Duv() } },
},
},
Polygons =
{
[0] = new Polygon
{
PosSurface = 0,
VertexIds = { 0, 1, 2, 3 },
PosUVIndices = { 0, 0, 0, 0 },
},
},
};
var sub = GfxObjMesh.Build(gfx).Single();
Assert.Equal(4, sub.Vertices.Length);
Assert.Equal(6, sub.Indices.Length); // 2 triangles
}
[Fact]
public void Build_SamePositionDifferentUVs_DuplicatesOutputVertices()
{
// One vertex has two different UV slots. Each (posIdx, uvIdx) combo
// becomes a distinct output vertex.
var gfx = new GfxObj
{
Surfaces = { 0x08000000u },
VertexArray = new VertexArray
{
Vertices =
{
[0] = new SWVertex
{
Origin = new(0, 0, 0),
UVs =
{
new Vec2Duv { U = 0, V = 0 },
new Vec2Duv { U = 1, V = 1 },
},
},
[1] = new SWVertex { Origin = new(1, 0, 0), UVs = { new Vec2Duv() } },
[2] = new SWVertex { Origin = new(0, 1, 0), UVs = { new Vec2Duv() } },
},
},
Polygons =
{
[0] = new Polygon
{
PosSurface = 0,
VertexIds = { 0, 1, 2 },
PosUVIndices = { 0, 0, 0 },
},
[1] = new Polygon
{
PosSurface = 0,
VertexIds = { 0, 1, 2 },
PosUVIndices = { 1, 0, 0 }, // same positions, different UV on vert 0
},
},
};
var sub = GfxObjMesh.Build(gfx).Single();
// vert 0 has two different UV slots → 2 output vertices for pos 0
// vert 1 + 2 unique → 2 more output vertices
// total: 4 output vertices
Assert.Equal(4, sub.Vertices.Length);
Assert.Equal(6, sub.Indices.Length); // 2 triangles
}
[Fact]
public void Build_MultipleSurfaces_ProducesMultipleSubMeshes()
{
// 2 polygons, 2 surfaces → 2 sub-meshes.
var gfx = new GfxObj
{
Surfaces = { 0x08000001u, 0x08000002u },
VertexArray = new VertexArray
{
Vertices =
{
[0] = new SWVertex { Origin = new(0, 0, 0), UVs = { new Vec2Duv() } },
[1] = new SWVertex { Origin = new(1, 0, 0), UVs = { new Vec2Duv() } },
[2] = new SWVertex { Origin = new(0, 1, 0), UVs = { new Vec2Duv() } },
[3] = new SWVertex { Origin = new(1, 1, 0), UVs = { new Vec2Duv() } },
},
},
Polygons =
{
[0] = new Polygon
{
PosSurface = 0,
VertexIds = { 0, 1, 2 },
PosUVIndices = { 0, 0, 0 },
},
[1] = new Polygon
{
PosSurface = 1,
VertexIds = { 1, 3, 2 },
PosUVIndices = { 0, 0, 0 },
},
},
};
var subs = GfxObjMesh.Build(gfx);
Assert.Equal(2, subs.Count);
Assert.Contains(subs, s => s.SurfaceId == 0x08000001u);
Assert.Contains(subs, s => s.SurfaceId == 0x08000002u);
}
[Fact]
public void Build_DegeneratePolygonWithTwoVertices_Skipped()
{
var gfx = new GfxObj
{
Surfaces = { 0x08000000u },
VertexArray = new VertexArray
{
Vertices =
{
[0] = new SWVertex { Origin = new(0, 0, 0), UVs = { new Vec2Duv() } },
[1] = new SWVertex { Origin = new(1, 0, 0), UVs = { new Vec2Duv() } },
},
},
Polygons =
{
[0] = new Polygon
{
PosSurface = 0,
VertexIds = { 0, 1 },
PosUVIndices = { 0, 0 },
},
},
};
var subs = GfxObjMesh.Build(gfx);
Assert.Empty(subs); // no valid polygons → no sub-meshes
}
}

View file

@ -0,0 +1,110 @@
using System.Numerics;
using AcDream.Core.Meshing;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Meshing;
public class SetupMeshTests
{
[Fact]
public void Flatten_SinglePartSetup_YieldsOneMeshRef()
{
var setup = new Setup
{
Parts = { 0x01000100u },
DefaultScale = { Vector3.One },
PlacementFrames =
{
[Placement.Default] = new AnimationFrame(1)
{
Frames =
{
new Frame
{
Origin = new Vector3(0, 0, 0),
Orientation = Quaternion.Identity,
},
},
},
},
};
var refs = SetupMesh.Flatten(setup);
var single = Assert.Single(refs);
Assert.Equal(0x01000100u, single.GfxObjId);
// Identity-ish transform
Assert.Equal(Matrix4x4.Identity, single.PartTransform);
}
[Fact]
public void Flatten_TwoPartSetup_YieldsTwoMeshRefs()
{
var setup = new Setup
{
Parts = { 0x01000100u, 0x01000200u },
DefaultScale = { Vector3.One, Vector3.One },
PlacementFrames =
{
[Placement.Default] = new AnimationFrame(2)
{
Frames =
{
new Frame { Origin = new(0, 0, 0), Orientation = Quaternion.Identity },
new Frame { Origin = new(10, 0, 0), Orientation = Quaternion.Identity },
},
},
},
};
var refs = SetupMesh.Flatten(setup);
Assert.Equal(2, refs.Count);
Assert.Equal(0x01000100u, refs[0].GfxObjId);
Assert.Equal(0x01000200u, refs[1].GfxObjId);
// Second part is translated by 10 on X.
Assert.Equal(10f, refs[1].PartTransform.Translation.X);
}
[Fact]
public void Flatten_PartScale_IsAppliedToTransform()
{
var setup = new Setup
{
Parts = { 0x01000100u },
DefaultScale = { new Vector3(2, 3, 4) },
PlacementFrames =
{
[Placement.Default] = new AnimationFrame(1)
{
Frames = { new Frame { Orientation = Quaternion.Identity } },
},
},
};
var refs = SetupMesh.Flatten(setup);
// The transform's M11 = 2 (scale X), M22 = 3, M33 = 4
Assert.Equal(2f, refs[0].PartTransform.M11);
Assert.Equal(3f, refs[0].PartTransform.M22);
Assert.Equal(4f, refs[0].PartTransform.M33);
}
[Fact]
public void Flatten_MissingPlacementFrame_UsesIdentity()
{
var setup = new Setup
{
Parts = { 0x01000100u },
DefaultScale = { Vector3.One },
// PlacementFrames deliberately empty
};
var refs = SetupMesh.Flatten(setup);
Assert.Single(refs);
Assert.Equal(Matrix4x4.Identity, refs[0].PartTransform);
}
}

View file

@ -0,0 +1,89 @@
using AcDream.Core.Textures;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
namespace AcDream.Core.Tests.Textures;
public class SurfaceDecoderTests
{
[Fact]
public void Decode_A8R8G8B8_ConvertsToRgba8()
{
// Source format is B, G, R, A in memory (little-endian ARGB).
// One 2x2 image: red, green, blue, white pixels.
var src = new byte[]
{
0x00, 0x00, 0xFF, 0xFF, // red (B=0, G=0, R=255, A=255)
0x00, 0xFF, 0x00, 0xFF, // green
0xFF, 0x00, 0x00, 0xFF, // blue
0xFF, 0xFF, 0xFF, 0xFF, // white
};
var rs = new RenderSurface
{
Width = 2,
Height = 2,
Format = PixelFormat.PFID_A8R8G8B8,
SourceData = src,
};
var decoded = SurfaceDecoder.DecodeRenderSurface(rs);
Assert.Equal(2, decoded.Width);
Assert.Equal(2, decoded.Height);
Assert.Equal(16, decoded.Rgba8.Length); // 2*2*4
// red pixel, in RGBA: 255, 0, 0, 255
Assert.Equal(0xFF, decoded.Rgba8[0]);
Assert.Equal(0x00, decoded.Rgba8[1]);
Assert.Equal(0x00, decoded.Rgba8[2]);
Assert.Equal(0xFF, decoded.Rgba8[3]);
}
[Fact]
public void Decode_UnsupportedFormat_ReturnsMagenta()
{
var rs = new RenderSurface
{
Width = 4,
Height = 4,
Format = PixelFormat.PFID_INDEX16, // not implemented path
SourceData = new byte[32],
};
var decoded = SurfaceDecoder.DecodeRenderSurface(rs);
Assert.Same(DecodedTexture.Magenta, decoded);
}
[Fact]
public void Decode_NullSourceData_ReturnsMagenta()
{
var rs = new RenderSurface
{
Width = 4,
Height = 4,
Format = PixelFormat.PFID_A8R8G8B8,
SourceData = null!,
};
var decoded = SurfaceDecoder.DecodeRenderSurface(rs);
Assert.Same(DecodedTexture.Magenta, decoded);
}
[Fact]
public void Decode_TruncatedA8R8G8B8_ReturnsMagenta()
{
// Buffer too small for width*height*4.
var rs = new RenderSurface
{
Width = 2,
Height = 2,
Format = PixelFormat.PFID_A8R8G8B8,
SourceData = new byte[8], // should be 16
};
var decoded = SurfaceDecoder.DecodeRenderSurface(rs);
Assert.Same(DecodedTexture.Magenta, decoded);
}
}

View file

@ -0,0 +1,119 @@
using System.Numerics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.World;
public class LandblockLoaderTests
{
private static LandBlock BuildFlatLandBlock()
{
var block = new LandBlock
{
HasObjects = true,
Terrain = new TerrainInfo[81],
Height = new byte[81],
};
for (int i = 0; i < 81; i++)
{
block.Terrain[i] = (ushort)0;
block.Height[i] = 0;
}
return block;
}
[Fact]
public void BuildEntitiesFromInfo_StabsAndBuildings_AreMappedToEntities()
{
var info = new LandBlockInfo
{
Objects =
{
new Stab
{
Id = 0x01000042u, // GfxObj id
Frame = new Frame
{
Origin = new Vector3(10, 20, 5),
Orientation = Quaternion.Identity,
},
},
new Stab
{
Id = 0x02000099u, // Setup id
Frame = new Frame
{
Origin = new Vector3(30, 40, 10),
Orientation = Quaternion.Identity,
},
},
},
Buildings =
{
new BuildingInfo
{
ModelId = 0x020000AAu, // Setup for a building
Frame = new Frame
{
Origin = new Vector3(50, 60, 0),
Orientation = Quaternion.Identity,
},
},
},
};
var entities = LandblockLoader.BuildEntitiesFromInfo(info);
Assert.Equal(3, entities.Count);
Assert.Contains(entities, e => e.SourceGfxObjOrSetupId == 0x01000042u && e.Position == new Vector3(10, 20, 5));
Assert.Contains(entities, e => e.SourceGfxObjOrSetupId == 0x02000099u && e.Position == new Vector3(30, 40, 10));
Assert.Contains(entities, e => e.SourceGfxObjOrSetupId == 0x020000AAu && e.Position == new Vector3(50, 60, 0));
}
[Fact]
public void BuildEntitiesFromInfo_AssignsMonotonicIds()
{
var info = new LandBlockInfo
{
Objects =
{
new Stab { Id = 0x01000001u, Frame = new Frame() },
new Stab { Id = 0x01000002u, Frame = new Frame() },
new Stab { Id = 0x01000003u, Frame = new Frame() },
},
};
var entities = LandblockLoader.BuildEntitiesFromInfo(info);
var ids = entities.Select(e => e.Id).OrderBy(i => i).ToArray();
Assert.Equal(3, ids.Distinct().Count()); // all unique
}
[Fact]
public void BuildEntitiesFromInfo_UnsupportedIdType_IsSkipped()
{
// 0x03xxxxxx is neither GfxObj (0x01) nor Setup (0x02).
var info = new LandBlockInfo
{
Objects =
{
new Stab { Id = 0x01000001u, Frame = new Frame() },
new Stab { Id = 0x03000002u, Frame = new Frame() }, // skipped
new Stab { Id = 0x02000003u, Frame = new Frame() },
},
};
var entities = LandblockLoader.BuildEntitiesFromInfo(info);
Assert.Equal(2, entities.Count);
Assert.DoesNotContain(entities, e => e.SourceGfxObjOrSetupId == 0x03000002u);
}
[Fact]
public void BuildEntitiesFromInfo_Empty_ReturnsEmpty()
{
var entities = LandblockLoader.BuildEntitiesFromInfo(new LandBlockInfo());
Assert.Empty(entities);
}
}

View file

@ -0,0 +1,46 @@
// tests/AcDream.Core.Tests/World/WorldViewTests.cs
using AcDream.Core.World;
namespace AcDream.Core.Tests.World;
public class WorldViewTests
{
[Fact]
public void NeighborIds_Center_Returns9Ids()
{
var ids = WorldView.NeighborLandblockIds(0xA9B4FFFFu).ToList();
Assert.Equal(9, ids.Count);
Assert.Contains(0xA9B4FFFFu, ids); // center
Assert.Contains(0xA8B3FFFFu, ids); // NW
Assert.Contains(0xAAB5FFFFu, ids); // SE
}
[Fact]
public void NeighborIds_LowerEdge_ClampsUnderflow()
{
// Landblock 0x0000FFFF — no west or south neighbors.
var ids = WorldView.NeighborLandblockIds(0x0000FFFFu).ToList();
// 4 neighbors should exist: center + E + N + NE
Assert.Equal(4, ids.Count);
Assert.Contains(0x0000FFFFu, ids);
Assert.Contains(0x0100FFFFu, ids);
Assert.Contains(0x0001FFFFu, ids);
Assert.Contains(0x0101FFFFu, ids);
}
[Fact]
public void NeighborIds_UpperEdge_ClampsOverflow()
{
// Landblock 0xFFFFFFFF — no east or north neighbors.
var ids = WorldView.NeighborLandblockIds(0xFFFFFFFFu).ToList();
// 4 neighbors: center + W + S + SW
Assert.Equal(4, ids.Count);
Assert.Contains(0xFFFFFFFFu, ids);
Assert.Contains(0xFEFFFFFFu, ids);
Assert.Contains(0xFFFEFFFFu, ids);
Assert.Contains(0xFEFEFFFFu, ids);
}
}