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:
commit
1d1e668a2f
21 changed files with 1322 additions and 0 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
9
src/AcDream.App/Rendering/Shaders/mesh.frag
Normal file
9
src/AcDream.App/Rendering/Shaders/mesh.frag
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#version 430 core
|
||||||
|
in vec2 vTex;
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
uniform sampler2D uDiffuse;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
fragColor = texture(uDiffuse, vTex);
|
||||||
|
}
|
||||||
15
src/AcDream.App/Rendering/Shaders/mesh.vert
Normal file
15
src/AcDream.App/Rendering/Shaders/mesh.vert
Normal 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);
|
||||||
|
}
|
||||||
134
src/AcDream.App/Rendering/StaticMeshRenderer.cs
Normal file
134
src/AcDream.App/Rendering/StaticMeshRenderer.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/AcDream.App/Rendering/TextureCache.cs
Normal file
92
src/AcDream.App/Rendering/TextureCache.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
88
src/AcDream.Core/Meshing/GfxObjMesh.cs
Normal file
88
src/AcDream.Core/Meshing/GfxObjMesh.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/AcDream.Core/Meshing/GfxObjSubMesh.cs
Normal file
12
src/AcDream.Core/Meshing/GfxObjSubMesh.cs
Normal 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);
|
||||||
45
src/AcDream.Core/Meshing/SetupMesh.cs
Normal file
45
src/AcDream.Core/Meshing/SetupMesh.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/AcDream.Core/Textures/DecodedTexture.cs
Normal file
10
src/AcDream.Core/Textures/DecodedTexture.cs
Normal 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);
|
||||||
|
}
|
||||||
71
src/AcDream.Core/Textures/SurfaceDecoder.cs
Normal file
71
src/AcDream.Core/Textures/SurfaceDecoder.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/AcDream.Core/World/LandblockLoader.cs
Normal file
77
src/AcDream.Core/World/LandblockLoader.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/AcDream.Core/World/LoadedLandblock.cs
Normal file
8
src/AcDream.Core/World/LoadedLandblock.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
|
||||||
|
namespace AcDream.Core.World;
|
||||||
|
|
||||||
|
public sealed record LoadedLandblock(
|
||||||
|
uint LandblockId,
|
||||||
|
LandBlock Heightmap,
|
||||||
|
IReadOnlyList<WorldEntity> Entities);
|
||||||
5
src/AcDream.Core/World/MeshRef.cs
Normal file
5
src/AcDream.Core/World/MeshRef.cs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.Core.World;
|
||||||
|
|
||||||
|
public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform);
|
||||||
12
src/AcDream.Core/World/WorldEntity.cs
Normal file
12
src/AcDream.Core/World/WorldEntity.cs
Normal 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; }
|
||||||
|
}
|
||||||
55
src/AcDream.Core/World/WorldView.cs
Normal file
55
src/AcDream.Core/World/WorldView.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
250
tests/AcDream.Core.Tests/Meshing/GfxObjMeshTests.cs
Normal file
250
tests/AcDream.Core.Tests/Meshing/GfxObjMeshTests.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
110
tests/AcDream.Core.Tests/Meshing/SetupMeshTests.cs
Normal file
110
tests/AcDream.Core.Tests/Meshing/SetupMeshTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs
Normal file
89
tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs
Normal file
119
tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
tests/AcDream.Core.Tests/World/WorldViewTests.cs
Normal file
46
tests/AcDream.Core.Tests/World/WorldViewTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue