feat(app): load landblock from dats and upload mesh to GPU

GameWindow now owns a DatCollection + TerrainRenderer. On load it
opens the dat directory passed as argv[0] (or ACDREAM_DAT_DIR), finds
Holtburg (landblock 0xA9B4FFFF) by default with a fallback to the
first landblock in the cell b-tree, builds the CPU mesh from
LandblockMesh.Build, and uploads VBO+EBO+VAO with a 3f/3f/2f attribute
layout. No draw call yet — shader and matrix uniforms land in Task 9.

Enabled AllowUnsafeBlocks on the App csproj so the fixed-buffer upload
in TerrainRenderer compiles. Uses dats.Get<LandBlock>(id) instead of
TryGet(..., out T) to sidestep the [MaybeNullWhen(false)] analysis that
TreatWarningsAsErrors was flagging.

Smoke verified against the real retail dats: prints
"loaded landblock 0xA9B4FFFF" and the window stays alive with no GL
errors or exceptions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-10 16:42:13 +02:00
parent 6d18e0bd38
commit 8356fe65a0
4 changed files with 113 additions and 1 deletions

View file

@ -7,6 +7,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>AcDream.App</RootNamespace> <RootNamespace>AcDream.App</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" /> <PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />

View file

@ -1,5 +1,15 @@
using AcDream.App.Rendering; using AcDream.App.Rendering;
var window = new GameWindow(); var datDir = args.FirstOrDefault()
?? Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (string.IsNullOrWhiteSpace(datDir))
{
Console.Error.WriteLine("usage: AcDream.App <dat-directory>");
Console.Error.WriteLine(" or: set ACDREAM_DAT_DIR and run with no args");
return 2;
}
using var window = new GameWindow(datDir);
window.Run(); window.Run();
return 0; return 0;

View file

@ -1,3 +1,7 @@
using AcDream.Core.Terrain;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Silk.NET.Input; using Silk.NET.Input;
using Silk.NET.Maths; using Silk.NET.Maths;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
@ -7,9 +11,14 @@ namespace AcDream.App.Rendering;
public sealed class GameWindow : IDisposable public sealed class GameWindow : IDisposable
{ {
private readonly string _datDir;
private IWindow? _window; private IWindow? _window;
private GL? _gl; private GL? _gl;
private IInputContext? _input; private IInputContext? _input;
private TerrainRenderer? _terrain;
private DatCollection? _dats;
public GameWindow(string datDir) => _datDir = datDir;
public void Run() public void Run()
{ {
@ -45,15 +54,46 @@ public sealed class GameWindow : IDisposable
}; };
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
_gl.Enable(EnableCap.DepthTest);
_dats = new DatCollection(_datDir, DatAccessType.Read);
// Find ANY landblock ending in 0xFFFF. Holtburg 0xA9B4FFFF is a
// good default; fall back to the first one we find.
uint landblockId = 0xA9B4FFFFu;
var block = _dats.Get<LandBlock>(landblockId);
if (block is null)
{
foreach (var file in _dats.Cell.Tree)
{
if ((file.Id & 0xFFFFu) == 0xFFFFu)
{
landblockId = file.Id;
block = _dats.Get<LandBlock>(landblockId);
break;
}
}
}
if (block is null)
throw new InvalidOperationException("no landblock found in cell dat");
Console.WriteLine($"loaded landblock 0x{landblockId:X8}");
var meshData = LandblockMesh.Build(block);
_terrain = new TerrainRenderer(_gl, meshData);
} }
private void OnRender(double deltaSeconds) private void OnRender(double deltaSeconds)
{ {
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
_terrain?.Draw();
} }
private void OnClosing() private void OnClosing()
{ {
_terrain?.Dispose();
_dats?.Dispose();
_input?.Dispose(); _input?.Dispose();
_gl?.Dispose(); _gl?.Dispose();
} }

View file

@ -0,0 +1,61 @@
using AcDream.Core.Terrain;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
public sealed unsafe class TerrainRenderer : IDisposable
{
private readonly GL _gl;
private readonly uint _vao;
private readonly uint _vbo;
private readonly uint _ebo;
private readonly int _indexCount;
public TerrainRenderer(GL gl, LandblockMeshData meshData)
{
_gl = gl;
_indexCount = meshData.Indices.Length;
_vao = _gl.GenVertexArray();
_gl.BindVertexArray(_vao);
_vbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
fixed (void* p = meshData.Vertices)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(meshData.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
_ebo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _ebo);
fixed (void* p = meshData.Indices)
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
(nuint)(meshData.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
// vertex layout: position(3f), normal(3f), texcoord(2f) = 8 floats stride
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);
}
public void Draw()
{
// Shader binding + draw call come in Task 9. For this task, binding the
// VAO is enough to prove the buffers uploaded without GL errors.
_gl.BindVertexArray(_vao);
// intentionally no draw call yet
_gl.BindVertexArray(0);
}
public void Dispose()
{
_gl.DeleteBuffer(_vbo);
_gl.DeleteBuffer(_ebo);
_gl.DeleteVertexArray(_vao);
}
}