1648 lines
54 KiB
Markdown
1648 lines
54 KiB
Markdown
# Phase 2b Implementation Plan — Atlas Textures, Neighbors, Dual Cameras, Plugin API Growth
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.
|
|
|
|
**Goal:** Turn Phase 2a's single textured Holtburg landblock into a 3x3 neighbor grid with real terrain textures from the dats, explorable with a first-person FlyCamera (F toggle, raw cursor), and expose the world entity list to plugins via IGameState + IEvents with replay-on-subscribe.
|
|
|
|
**Architecture:** Vertex struct grows a `TerrainLayer` field. TerrainAtlas builds a GL_TEXTURE_2D_ARRAY from `Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc`. Terrain shader samples `sampler2DArray` with a `flat uint` layer attribute. WorldView.Load drives neighbor rendering via a `uModel` uniform per landblock. ICamera interface unifies OrbitCamera + new FlyCamera; CameraController.F toggles and manages cursor capture. Plugin abstractions grow by IGameState, IEvents, WorldEntitySnapshot. WorldEvents implements replay-on-subscribe so late subscribers see every already-spawned entity before `+=` returns.
|
|
|
|
**Tech Stack:** .NET 10, Silk.NET 2.23.0, Chorizite.DatReaderWriter 2.1.4, BCnEncoder.Net 2.2.1, xUnit.
|
|
|
|
**Context:** Read `docs/plans/2026-04-10-phase-2b-design.md` for the full design. Phase 2a merged on `main` as `1d1e668` + fix-up `cc55c3f`. The `Vertex` struct in `src/AcDream.Core/Terrain/Vertex.cs` currently has 8 floats (Position, Normal, TexCoord); Phase 2b adds a 9th value (uint TerrainLayer) → 36-byte stride. Both `StaticMeshRenderer` and `TerrainRenderer` must update their `VertexAttribPointer` calls to match. The retail dat directory lives at `references/Asheron's Call/` (gitignored).
|
|
|
|
**Work branch:** `phase-2b/atlas-neighbors-cameras-events` (Task 0 creates it).
|
|
|
|
**Testing philosophy:** TDD the pure CPU pieces (LandblockMesh updated tests, FlyCamera math, WorldEvents replay). Manual smoke the GL-coupled pieces (terrain atlas rendering, neighbor visibility, FlyCamera input). 42 tests from Phase 2a must stay green.
|
|
|
|
**Commit cadence:** One commit per task minimum. Never batch tasks.
|
|
|
|
**Stopping point for this session:** Task 9 completes Phase 2b. Merge to main after end-to-end smoke confirms clean startup.
|
|
|
|
---
|
|
|
|
## Task 0: Branch off main
|
|
|
|
**Files:** none
|
|
|
|
- [ ] **Step 1:** Verify on main, clean working tree:
|
|
```bash
|
|
git checkout main
|
|
git status
|
|
```
|
|
Expected: `On branch main`, `nothing to commit, working tree clean`.
|
|
|
|
- [ ] **Step 2:** Create feature branch:
|
|
```bash
|
|
git checkout -b phase-2b/atlas-neighbors-cameras-events
|
|
```
|
|
|
|
- [ ] **Step 3:** Sanity build + test:
|
|
```bash
|
|
dotnet build
|
|
dotnet test
|
|
```
|
|
Expected: 0 warnings, 0 errors, 42 tests passing.
|
|
|
|
No commit for this task — just branch setup.
|
|
|
|
---
|
|
|
|
## Task 1: Expand `Vertex` struct + `LandblockMesh.Build` signature
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.Core/Terrain/Vertex.cs`
|
|
- Modify: `src/AcDream.Core/Terrain/LandblockMesh.cs`
|
|
- Modify: `tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs`
|
|
- Modify: `src/AcDream.Core/Meshing/GfxObjMesh.cs` (write `TerrainLayer = 0` for static mesh vertices)
|
|
- Modify: `src/AcDream.App/Rendering/StaticMeshRenderer.cs` (new `VertexAttribPointer` for attribute 3)
|
|
- Modify: `src/AcDream.App/Rendering/TerrainRenderer.cs` (new `VertexAttribPointer` for attribute 3)
|
|
|
|
### Step 1: Write the failing test
|
|
|
|
Add this test to `tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs`:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public void Build_PerVertexTerrainLayer_UsesMappedLayerIndex()
|
|
{
|
|
var block = BuildFlatLandBlock();
|
|
// TerrainInfo is a struct with implicit conversion from ushort. The low 5 bits
|
|
// of the ushort encode TerrainTextureType via TerrainInfo.Type.
|
|
// Set vertex at x-major index (x=2, y=3) to terrain type 7.
|
|
block.Terrain[2 * 9 + 3] = (ushort)7; // low 5 bits = 7
|
|
|
|
var map = new Dictionary<uint, uint>
|
|
{
|
|
[0] = 0u, // default type → atlas layer 0
|
|
[7] = 4u, // type 7 → atlas layer 4
|
|
};
|
|
|
|
var mesh = LandblockMesh.Build(block, IdentityHeightTable, map);
|
|
|
|
// Vertex buffer internal order is y*9+x, so vertex at world (x=2, y=3) is at
|
|
// index 3*9+2 = 29.
|
|
Assert.Equal(4u, mesh.Vertices[3 * 9 + 2].TerrainLayer);
|
|
// An untouched vertex still has type 0, maps to layer 0.
|
|
Assert.Equal(0u, mesh.Vertices[0].TerrainLayer);
|
|
}
|
|
```
|
|
|
|
### Step 2: Run test to verify it fails
|
|
|
|
```bash
|
|
dotnet test --filter "FullyQualifiedName~LandblockMeshTests.Build_PerVertexTerrainLayer"
|
|
```
|
|
Expected: compile error — `Vertex` does not contain a definition for `TerrainLayer`, or `Build` signature mismatch.
|
|
|
|
### Step 3: Update `Vertex` struct
|
|
|
|
Replace `src/AcDream.Core/Terrain/Vertex.cs` with:
|
|
|
|
```csharp
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.Core.Terrain;
|
|
|
|
public readonly record struct Vertex(
|
|
Vector3 Position,
|
|
Vector3 Normal,
|
|
Vector2 TexCoord,
|
|
uint TerrainLayer);
|
|
```
|
|
|
|
### Step 4: Update `LandblockMesh.Build` signature
|
|
|
|
Replace `src/AcDream.Core/Terrain/LandblockMesh.cs` body:
|
|
|
|
```csharp
|
|
using System.Numerics;
|
|
using DatReaderWriter.DBObjs;
|
|
|
|
namespace AcDream.Core.Terrain;
|
|
|
|
public sealed record LandblockMeshData(Vertex[] Vertices, uint[] Indices);
|
|
|
|
public static class LandblockMesh
|
|
{
|
|
private const int VerticesPerSide = 9;
|
|
private const int CellsPerSide = VerticesPerSide - 1;
|
|
private const float CellSize = 24.0f;
|
|
// Phase 2b: tile terrain textures ~4x per landblock instead of stretching
|
|
// a single texture across the whole 192-unit patch.
|
|
private const float TexCoordDivisor = CellsPerSide / 4.0f;
|
|
|
|
public static LandblockMeshData Build(
|
|
LandBlock block,
|
|
float[] heightTable,
|
|
IReadOnlyDictionary<uint, uint> terrainTypeToLayer)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(heightTable);
|
|
ArgumentNullException.ThrowIfNull(terrainTypeToLayer);
|
|
if (heightTable.Length < 256)
|
|
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
|
|
|
|
var vertices = new Vertex[VerticesPerSide * VerticesPerSide];
|
|
for (int y = 0; y < VerticesPerSide; y++)
|
|
{
|
|
for (int x = 0; x < VerticesPerSide; x++)
|
|
{
|
|
int vi = y * VerticesPerSide + x;
|
|
int hi = x * VerticesPerSide + y;
|
|
|
|
float height = heightTable[block.Height[hi]];
|
|
|
|
// TerrainInfo.Type returns the TerrainTextureType enum; cast to uint
|
|
// for the atlas-layer map lookup.
|
|
uint terrainType = (uint)block.Terrain[hi].Type;
|
|
if (!terrainTypeToLayer.TryGetValue(terrainType, out var layer))
|
|
layer = 0;
|
|
|
|
vertices[vi] = new Vertex(
|
|
Position: new Vector3(x * CellSize, y * CellSize, height),
|
|
Normal: Vector3.UnitZ,
|
|
TexCoord: new Vector2(x / TexCoordDivisor, y / TexCoordDivisor),
|
|
TerrainLayer: layer);
|
|
}
|
|
}
|
|
|
|
var indices = new uint[CellsPerSide * CellsPerSide * 6];
|
|
int idx = 0;
|
|
for (int y = 0; y < CellsPerSide; y++)
|
|
{
|
|
for (int x = 0; x < CellsPerSide; x++)
|
|
{
|
|
uint a = (uint)(y * VerticesPerSide + x);
|
|
uint b = (uint)(y * VerticesPerSide + x + 1);
|
|
uint c = (uint)((y + 1) * VerticesPerSide + x);
|
|
uint d = (uint)((y + 1) * VerticesPerSide + x + 1);
|
|
indices[idx++] = a; indices[idx++] = b; indices[idx++] = d;
|
|
indices[idx++] = a; indices[idx++] = d; indices[idx++] = c;
|
|
}
|
|
}
|
|
|
|
return new LandblockMeshData(vertices, indices);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 5: Update existing LandblockMesh tests to pass an empty map
|
|
|
|
In `tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs`, every existing `LandblockMesh.Build(block, IdentityHeightTable)` call must become:
|
|
|
|
```csharp
|
|
LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap)
|
|
```
|
|
|
|
Add a static field near `IdentityHeightTable`:
|
|
|
|
```csharp
|
|
private static readonly IReadOnlyDictionary<uint, uint> EmptyTerrainMap =
|
|
new Dictionary<uint, uint>();
|
|
```
|
|
|
|
The 4 existing tests plus the new `Build_PerVertexTerrainLayer_UsesMappedLayerIndex` test should all compile.
|
|
|
|
### Step 6: Update `GfxObjMesh` to write `TerrainLayer = 0`
|
|
|
|
In `src/AcDream.Core/Meshing/GfxObjMesh.cs`, find the `new Vertex(sw.Origin, sw.Normal, texcoord)` call and change it to:
|
|
|
|
```csharp
|
|
bucket.Vertices.Add(new Vertex(sw.Origin, sw.Normal, texcoord, TerrainLayer: 0));
|
|
```
|
|
|
|
GfxObj meshes don't use the terrain atlas; the mesh shader ignores `TerrainLayer`. Layer 0 is a safe no-op because the mesh shader binds its own `sampler2D uDiffuse` and doesn't touch the atlas.
|
|
|
|
### Step 7: Update `VertexAttribPointer` calls in renderers
|
|
|
|
In both `src/AcDream.App/Rendering/TerrainRenderer.cs` and `src/AcDream.App/Rendering/StaticMeshRenderer.cs` (in the `UploadSubMesh` method for the latter), after the existing three `EnableVertexAttribArray` / `VertexAttribPointer` calls for locations 0/1/2, add:
|
|
|
|
```csharp
|
|
_gl.EnableVertexAttribArray(3);
|
|
_gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float)));
|
|
```
|
|
|
|
Note: **`VertexAttribIPointer` not `VertexAttribPointer`** — the `I` variant is required for integer-typed vertex attributes in GLSL. Also note `VertexAttribIType.UnsignedInt`, not `VertexAttribPointerType.UnsignedInt`. Silk.NET exposes both.
|
|
|
|
The stride is `(uint)sizeof(Vertex)` which will now be 36 after the struct change, automatically computed — no hardcoded stride to update.
|
|
|
|
### Step 8: Run all tests
|
|
|
|
```bash
|
|
dotnet test
|
|
```
|
|
Expected: `Passed: 43` (42 previous + 1 new). All existing tests pass because `EmptyTerrainMap` maps nothing → all vertices get `TerrainLayer = 0`, which is compatible with their previous `Vertex` construction shape.
|
|
|
|
### Step 9: Build and verify runtime still works
|
|
|
|
```bash
|
|
dotnet build
|
|
```
|
|
Expected: 0 warnings, 0 errors.
|
|
|
|
Do NOT run the app yet — the current terrain shader doesn't know about `aTerrainLayer`, which OpenGL will happily ignore since the vertex attribute is unbound from the shader side. But Phase 2b Task 3 rewrites the shader; for now just confirm the build is clean.
|
|
|
|
### Step 10: Commit
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(core): add Vertex.TerrainLayer + LandblockMesh layer map"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: `TerrainAtlas` class
|
|
|
|
**Files:**
|
|
- Create: `src/AcDream.App/Rendering/TerrainAtlas.cs`
|
|
|
|
No unit tests — this is GL-coupled code with a dat dependency. Manual smoke is Task 4.
|
|
|
|
### Step 1: Write the class
|
|
|
|
Create `src/AcDream.App/Rendering/TerrainAtlas.cs`:
|
|
|
|
```csharp
|
|
using AcDream.Core.Textures;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using Silk.NET.OpenGL;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// Builds a GL_TEXTURE_2D_ARRAY from the set of terrain types seen in the loaded
|
|
/// landblocks, one layer per unique terrain type. LandblockMesh writes per-vertex
|
|
/// layer indices into Vertex.TerrainLayer; the terrain fragment shader samples
|
|
/// texture(uAtlas, vec3(uv, float(vLayer))).
|
|
/// </summary>
|
|
public sealed unsafe class TerrainAtlas : IDisposable
|
|
{
|
|
private readonly GL _gl;
|
|
public uint GlTexture { get; }
|
|
public IReadOnlyDictionary<uint, uint> TerrainTypeToLayer { get; }
|
|
public int LayerCount { get; }
|
|
|
|
private TerrainAtlas(GL gl, uint glTexture, IReadOnlyDictionary<uint, uint> map, int layerCount)
|
|
{
|
|
_gl = gl;
|
|
GlTexture = glTexture;
|
|
TerrainTypeToLayer = map;
|
|
LayerCount = layerCount;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build the atlas by walking Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc
|
|
/// for the mapping from TerrainTextureType to SurfaceTexture id, decoding each
|
|
/// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY.
|
|
/// </summary>
|
|
public static TerrainAtlas Build(GL gl, DatCollection dats)
|
|
{
|
|
var region = dats.Get<Region>(0x13000000u)
|
|
?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
|
|
|
|
var terrainDesc = region.TerrainInfo?.LandSurfaces?.TexMerge?.TerrainDesc;
|
|
if (terrainDesc is null || terrainDesc.Count == 0)
|
|
{
|
|
// Fallback: upload a single 1x1 white layer as layer 0.
|
|
Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer");
|
|
return BuildFallback(gl);
|
|
}
|
|
|
|
// Walk TerrainDesc. Each TMTerrainDesc has a TerrainType (enum cast to uint)
|
|
// and a TerrainTex with a QualifiedDataId<SurfaceTexture> TextureId. Decode
|
|
// each referenced SurfaceTexture → RenderSurface → RGBA8 via SurfaceDecoder.
|
|
var decodedByType = new Dictionary<uint, DecodedTexture>();
|
|
int maxW = 1, maxH = 1;
|
|
foreach (var tmtd in terrainDesc)
|
|
{
|
|
uint typeKey = (uint)tmtd.TerrainType;
|
|
if (decodedByType.ContainsKey(typeKey))
|
|
continue;
|
|
|
|
var surfaceTextureId = (uint)tmtd.TerrainTex.TextureId;
|
|
var st = dats.Get<SurfaceTexture>(surfaceTextureId);
|
|
if (st is null || st.Textures.Count == 0)
|
|
{
|
|
Console.WriteLine($"WARN: TerrainType {tmtd.TerrainType} SurfaceTexture 0x{surfaceTextureId:X8} missing");
|
|
decodedByType[typeKey] = DecodedTexture.Magenta;
|
|
continue;
|
|
}
|
|
|
|
var rs = dats.Get<RenderSurface>((uint)st.Textures[0]);
|
|
if (rs is null)
|
|
{
|
|
decodedByType[typeKey] = DecodedTexture.Magenta;
|
|
continue;
|
|
}
|
|
|
|
Palette? palette = rs.DefaultPaletteId != 0
|
|
? dats.Get<Palette>(rs.DefaultPaletteId)
|
|
: null;
|
|
|
|
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette);
|
|
decodedByType[typeKey] = decoded;
|
|
if (decoded.Width > maxW) maxW = decoded.Width;
|
|
if (decoded.Height > maxH) maxH = decoded.Height;
|
|
}
|
|
|
|
// Allocate the GL_TEXTURE_2D_ARRAY with the max dimensions seen. Textures
|
|
// smaller than (maxW, maxH) are scaled up naively by nearest-neighbor
|
|
// replication into a resized RGBA8 buffer. Phase 2b doesn't need mip chains.
|
|
int layerCount = decodedByType.Count;
|
|
uint tex = gl.GenTexture();
|
|
gl.BindTexture(TextureTarget.Texture2DArray, tex);
|
|
gl.TexImage3D(
|
|
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
|
|
(uint)maxW, (uint)maxH, (uint)layerCount,
|
|
0, PixelFormat.Rgba, PixelType.UnsignedByte, null);
|
|
|
|
var map = new Dictionary<uint, uint>();
|
|
int layerIdx = 0;
|
|
foreach (var kvp in decodedByType)
|
|
{
|
|
byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH);
|
|
fixed (byte* p = buffer)
|
|
{
|
|
gl.TexSubImage3D(
|
|
TextureTarget.Texture2DArray, 0,
|
|
0, 0, layerIdx,
|
|
(uint)maxW, (uint)maxH, 1,
|
|
PixelFormat.Rgba, PixelType.UnsignedByte, p);
|
|
}
|
|
map[kvp.Key] = (uint)layerIdx;
|
|
layerIdx++;
|
|
}
|
|
|
|
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
|
|
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
|
|
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
|
|
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
|
|
|
|
gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
|
|
|
Console.WriteLine($"TerrainAtlas: {layerCount} layers at {maxW}x{maxH}");
|
|
return new TerrainAtlas(gl, tex, map, layerCount);
|
|
}
|
|
|
|
private static byte[] ResizeRgba8Nearest(DecodedTexture src, int dstW, int dstH)
|
|
{
|
|
if (src.Width == dstW && src.Height == dstH)
|
|
return src.Rgba8;
|
|
|
|
var dst = new byte[dstW * dstH * 4];
|
|
for (int y = 0; y < dstH; y++)
|
|
{
|
|
int srcY = y * src.Height / dstH;
|
|
for (int x = 0; x < dstW; x++)
|
|
{
|
|
int srcX = x * src.Width / dstW;
|
|
int si = (srcY * src.Width + srcX) * 4;
|
|
int di = (y * dstW + x) * 4;
|
|
dst[di + 0] = src.Rgba8[si + 0];
|
|
dst[di + 1] = src.Rgba8[si + 1];
|
|
dst[di + 2] = src.Rgba8[si + 2];
|
|
dst[di + 3] = src.Rgba8[si + 3];
|
|
}
|
|
}
|
|
return dst;
|
|
}
|
|
|
|
private static TerrainAtlas BuildFallback(GL gl)
|
|
{
|
|
uint tex = gl.GenTexture();
|
|
gl.BindTexture(TextureTarget.Texture2DArray, tex);
|
|
var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
|
|
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, PixelFormat.Rgba, PixelType.UnsignedByte, null);
|
|
fixed (byte* p = white)
|
|
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, PixelFormat.Rgba, PixelType.UnsignedByte, p);
|
|
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
|
|
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
|
|
gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
|
return new TerrainAtlas(gl, tex, new Dictionary<uint, uint> { [0] = 0u }, 1);
|
|
}
|
|
|
|
public void Dispose() => _gl.DeleteTexture(GlTexture);
|
|
}
|
|
```
|
|
|
|
### Step 2: Build
|
|
|
|
```bash
|
|
dotnet build
|
|
```
|
|
Expected: 0 warnings, 0 errors. This is compile-only verification; runtime exercise is Task 4.
|
|
|
|
### Step 3: Commit
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/TerrainAtlas.cs
|
|
git commit -m "feat(app): add TerrainAtlas for GL_TEXTURE_2D_ARRAY terrain textures"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Rewrite terrain shader + TerrainRenderer per-landblock uModel
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.App/Rendering/Shaders/terrain.vert`
|
|
- Modify: `src/AcDream.App/Rendering/Shaders/terrain.frag`
|
|
- Modify: `src/AcDream.App/Rendering/TerrainRenderer.cs`
|
|
|
|
### Step 1: Update `terrain.vert`
|
|
|
|
Replace `src/AcDream.App/Rendering/Shaders/terrain.vert`:
|
|
|
|
```glsl
|
|
#version 430 core
|
|
layout(location = 0) in vec3 aPos;
|
|
layout(location = 1) in vec3 aNormal;
|
|
layout(location = 2) in vec2 aTex;
|
|
layout(location = 3) in uint aTerrainLayer;
|
|
|
|
uniform mat4 uModel;
|
|
uniform mat4 uView;
|
|
uniform mat4 uProjection;
|
|
|
|
out vec2 vTex;
|
|
out flat uint vLayer;
|
|
|
|
void main() {
|
|
vTex = aTex;
|
|
vLayer = aTerrainLayer;
|
|
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
|
|
}
|
|
```
|
|
|
|
### Step 2: Update `terrain.frag`
|
|
|
|
Replace `src/AcDream.App/Rendering/Shaders/terrain.frag`:
|
|
|
|
```glsl
|
|
#version 430 core
|
|
in vec2 vTex;
|
|
in flat uint vLayer;
|
|
out vec4 fragColor;
|
|
|
|
uniform sampler2DArray uAtlas;
|
|
|
|
void main() {
|
|
fragColor = texture(uAtlas, vec3(vTex, float(vLayer)));
|
|
}
|
|
```
|
|
|
|
### Step 3: Update `TerrainRenderer` to take multiple landblocks + atlas
|
|
|
|
Replace `src/AcDream.App/Rendering/TerrainRenderer.cs`:
|
|
|
|
```csharp
|
|
using System.Numerics;
|
|
using AcDream.Core.Terrain;
|
|
using Silk.NET.OpenGL;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
public sealed unsafe class TerrainRenderer : IDisposable
|
|
{
|
|
private readonly GL _gl;
|
|
private readonly Shader _shader;
|
|
private readonly TerrainAtlas _atlas;
|
|
private readonly List<LandblockGpu> _landblocks = new();
|
|
|
|
public TerrainRenderer(GL gl, Shader shader, TerrainAtlas atlas)
|
|
{
|
|
_gl = gl;
|
|
_shader = shader;
|
|
_atlas = atlas;
|
|
}
|
|
|
|
public void AddLandblock(LandblockMeshData meshData, Vector3 worldOrigin)
|
|
{
|
|
var gpu = new LandblockGpu
|
|
{
|
|
Vao = _gl.GenVertexArray(),
|
|
WorldOrigin = worldOrigin,
|
|
IndexCount = meshData.Indices.Length,
|
|
};
|
|
|
|
_gl.BindVertexArray(gpu.Vao);
|
|
|
|
gpu.Vbo = _gl.GenBuffer();
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, gpu.Vbo);
|
|
fixed (void* p = meshData.Vertices)
|
|
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
|
(nuint)(meshData.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
|
|
|
|
gpu.Ebo = _gl.GenBuffer();
|
|
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, gpu.Ebo);
|
|
fixed (void* p = meshData.Indices)
|
|
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
|
|
(nuint)(meshData.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.EnableVertexAttribArray(3);
|
|
_gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float)));
|
|
|
|
_gl.BindVertexArray(0);
|
|
_landblocks.Add(gpu);
|
|
}
|
|
|
|
public void Draw(OrbitCamera camera) // ICamera in Task 5
|
|
{
|
|
_shader.Use();
|
|
_shader.SetMatrix4("uView", camera.View);
|
|
_shader.SetMatrix4("uProjection", camera.Projection);
|
|
|
|
_gl.ActiveTexture(TextureUnit.Texture0);
|
|
_gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlTexture);
|
|
|
|
foreach (var lb in _landblocks)
|
|
{
|
|
var model = Matrix4x4.CreateTranslation(lb.WorldOrigin);
|
|
_shader.SetMatrix4("uModel", model);
|
|
_gl.BindVertexArray(lb.Vao);
|
|
_gl.DrawElements(PrimitiveType.Triangles, (uint)lb.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
|
|
}
|
|
_gl.BindVertexArray(0);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var lb in _landblocks)
|
|
{
|
|
_gl.DeleteBuffer(lb.Vbo);
|
|
_gl.DeleteBuffer(lb.Ebo);
|
|
_gl.DeleteVertexArray(lb.Vao);
|
|
}
|
|
_landblocks.Clear();
|
|
}
|
|
|
|
private sealed class LandblockGpu
|
|
{
|
|
public uint Vao;
|
|
public uint Vbo;
|
|
public uint Ebo;
|
|
public int IndexCount;
|
|
public Vector3 WorldOrigin;
|
|
}
|
|
}
|
|
```
|
|
|
|
Note: `TerrainRenderer.Draw` still takes `OrbitCamera` — Task 5 changes it to `ICamera`.
|
|
|
|
### Step 4: Build
|
|
|
|
```bash
|
|
dotnet build
|
|
```
|
|
Expected: build FAILS because `GameWindow.cs` still calls `new TerrainRenderer(_gl, meshData, _shader)` and `_terrain.Draw(_camera)` which don't match the new signatures. This is expected — Task 4 fixes `GameWindow`.
|
|
|
|
**Stop here and do Task 4 next.** Do NOT commit a broken build.
|
|
|
|
---
|
|
|
|
## Task 4: Wire `WorldView.Load` + neighbors into `GameWindow`
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
|
|
|
### Step 1: Replace the single-landblock load with WorldView + 3x3 neighbors
|
|
|
|
Find the block in `GameWindow.OnLoad` that starts with `uint landblockId = 0xA9B4FFFFu;` and ends after `_terrain = new TerrainRenderer(_gl, meshData, _shader);`. Replace the whole block with:
|
|
|
|
```csharp
|
|
uint centerLandblockId = 0xA9B4FFFFu;
|
|
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
|
|
|
|
var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
|
|
var heightTable = region?.LandDefs.LandHeightTable;
|
|
if (heightTable is null || heightTable.Length < 256)
|
|
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
|
|
|
|
// Build the terrain atlas once from the Region dat.
|
|
var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats);
|
|
|
|
_terrain = new TerrainRenderer(_gl, _shader, terrainAtlas);
|
|
|
|
// Load the 3x3 neighbor grid.
|
|
var worldView = AcDream.Core.World.WorldView.Load(_dats, centerLandblockId);
|
|
Console.WriteLine($"loaded {worldView.Landblocks.Count} landblocks in 3x3 grid");
|
|
|
|
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
|
|
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
|
|
|
|
foreach (var lb in worldView.Landblocks)
|
|
{
|
|
var meshData = AcDream.Core.Terrain.LandblockMesh.Build(
|
|
lb.Heightmap, heightTable, terrainAtlas.TerrainTypeToLayer);
|
|
|
|
// Compute world origin for this landblock relative to the center.
|
|
int lbX = (int)((lb.LandblockId >> 24) & 0xFFu);
|
|
int lbY = (int)((lb.LandblockId >> 16) & 0xFFu);
|
|
var origin = new System.Numerics.Vector3(
|
|
(lbX - centerX) * 192f,
|
|
(lbY - centerY) * 192f,
|
|
0f);
|
|
|
|
_terrain.AddLandblock(meshData, origin);
|
|
}
|
|
```
|
|
|
|
### Step 2: Replace the entity hydration block to use WorldView.AllEntities
|
|
|
|
Find the existing block that does `var info = _dats.Get<LandBlockInfo>(...); var entities = info is not null ? LandblockLoader.BuildEntitiesFromInfo(info) : ...` and replace with:
|
|
|
|
```csharp
|
|
_textureCache = new TextureCache(_gl, _dats);
|
|
_staticMesh = new StaticMeshRenderer(_gl, _meshShader, _textureCache);
|
|
|
|
// Hydrate entities from ALL loaded landblocks, not just the center.
|
|
var allEntities = worldView.AllEntities.ToList();
|
|
Console.WriteLine($"hydrating {allEntities.Count} entities across {worldView.Landblocks.Count} landblocks");
|
|
|
|
var hydratedEntities = new List<AcDream.Core.World.WorldEntity>(allEntities.Count);
|
|
foreach (var e in allEntities)
|
|
{
|
|
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
|
|
|
if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
|
|
{
|
|
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)
|
|
{
|
|
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)
|
|
{
|
|
// Add the landblock origin to the entity's position so the static
|
|
// mesh renderer draws it at the correct world location.
|
|
var sourceLandblock = worldView.Landblocks.First(lb => lb.Entities.Contains(e));
|
|
int lbX = (int)((sourceLandblock.LandblockId >> 24) & 0xFFu);
|
|
int lbY = (int)((sourceLandblock.LandblockId >> 16) & 0xFFu);
|
|
var worldOffset = new System.Numerics.Vector3(
|
|
(lbX - centerX) * 192f,
|
|
(lbY - centerY) * 192f,
|
|
0f);
|
|
|
|
hydratedEntities.Add(new AcDream.Core.World.WorldEntity
|
|
{
|
|
Id = e.Id,
|
|
SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
|
|
Position = e.Position + worldOffset,
|
|
Rotation = e.Rotation,
|
|
MeshRefs = meshRefs,
|
|
});
|
|
}
|
|
}
|
|
|
|
_entities = hydratedEntities;
|
|
Console.WriteLine($"hydrated {_entities.Count} entities");
|
|
```
|
|
|
|
### Step 3: Build
|
|
|
|
```bash
|
|
dotnet build
|
|
```
|
|
Expected: 0 warnings, 0 errors.
|
|
|
|
### Step 4: Run against real dats (manual smoke)
|
|
|
|
```bash
|
|
dotnet run --project src/AcDream.App -- "references/Asheron's Call"
|
|
```
|
|
|
|
Expected console output:
|
|
```
|
|
loading world view centered on 0xA9B4FFFF
|
|
TerrainAtlas: <N> layers at <W>x<H>
|
|
loaded 9 landblocks in 3x3 grid
|
|
hydrating <several hundred> entities across 9 landblocks
|
|
hydrated <several hundred> entities
|
|
```
|
|
|
|
Expected visual: a 3x3 grid of textured terrain visible in the orbit camera, with buildings populated across all 9 landblocks (not just the center). Terrain should have real textures (not the Phase 1 green/brown/white ramp).
|
|
|
|
If the app crashes with a GL error or a DatReaderWriter nullref, STOP and debug. Likely culprits:
|
|
- `Region.TerrainInfo.LandSurfaces.TexMerge` path wrong — inspect `region.TerrainInfo` at runtime
|
|
- `VertexAttribIPointer` call using wrong type — check Silk.NET version
|
|
- `TerrainTextureType` enum cast to `uint` producing out-of-range values
|
|
|
|
Kill the process after a few seconds (process stays alive via Silk.NET window loop):
|
|
```bash
|
|
taskkill //IM AcDream.App.exe //F
|
|
```
|
|
|
|
### Step 5: Commit
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Rendering/TerrainRenderer.cs src/AcDream.App/Rendering/Shaders/terrain.vert src/AcDream.App/Rendering/Shaders/terrain.frag
|
|
git commit -m "feat(app): render 3x3 neighbor landblocks with texture atlas"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: `ICamera` interface + `OrbitCamera` refactor
|
|
|
|
**Files:**
|
|
- Create: `src/AcDream.App/Rendering/ICamera.cs`
|
|
- Modify: `src/AcDream.App/Rendering/OrbitCamera.cs`
|
|
- Modify: `src/AcDream.App/Rendering/TerrainRenderer.cs`
|
|
- Modify: `src/AcDream.App/Rendering/StaticMeshRenderer.cs`
|
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
|
|
|
No unit tests — interface extraction is behavior-preserving.
|
|
|
|
### Step 1: Create `ICamera`
|
|
|
|
```csharp
|
|
// src/AcDream.App/Rendering/ICamera.cs
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
public interface ICamera
|
|
{
|
|
Matrix4x4 View { get; }
|
|
Matrix4x4 Projection { get; }
|
|
float Aspect { get; set; }
|
|
}
|
|
```
|
|
|
|
### Step 2: Make `OrbitCamera` implement `ICamera`
|
|
|
|
In `src/AcDream.App/Rendering/OrbitCamera.cs`, change the class declaration:
|
|
|
|
```csharp
|
|
public sealed class OrbitCamera : ICamera
|
|
```
|
|
|
|
`Aspect`, `View`, and `Projection` properties already match the interface — no other changes needed.
|
|
|
|
### Step 3: Change renderer signatures to `ICamera`
|
|
|
|
In `src/AcDream.App/Rendering/TerrainRenderer.cs`, change `Draw(OrbitCamera camera)` to `Draw(ICamera camera)`.
|
|
|
|
In `src/AcDream.App/Rendering/StaticMeshRenderer.cs`, change `Draw(OrbitCamera camera, ...)` to `Draw(ICamera camera, ...)`.
|
|
|
|
### Step 4: Build and test
|
|
|
|
```bash
|
|
dotnet build
|
|
dotnet test
|
|
```
|
|
Expected: 0 warnings, 0 errors, 43 tests passing. `GameWindow` still uses `OrbitCamera` which now implements `ICamera`, so call sites compile unchanged.
|
|
|
|
### Step 5: Commit
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/ICamera.cs src/AcDream.App/Rendering/OrbitCamera.cs src/AcDream.App/Rendering/TerrainRenderer.cs src/AcDream.App/Rendering/StaticMeshRenderer.cs
|
|
git commit -m "feat(app): extract ICamera interface from OrbitCamera"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: `FlyCamera`
|
|
|
|
**Files:**
|
|
- Create: `src/AcDream.App/Rendering/FlyCamera.cs`
|
|
- Create: `tests/AcDream.Core.Tests/Rendering/FlyCameraTests.cs`
|
|
|
|
Wait — `tests/AcDream.Core.Tests/` can't easily reference `AcDream.App` (the test project references `AcDream.Core`, not `AcDream.App`). Move the FlyCamera to `AcDream.Core/Rendering/`? No — the Phase 1 pattern put OrbitCamera in `AcDream.App/Rendering/` because it's GL-adjacent conceptually, but FlyCamera is pure math and can live in Core.
|
|
|
|
**Revised decision for this plan:** put `FlyCamera`, `OrbitCamera`, and `ICamera` all in `src/AcDream.App/Rendering/`, and TDD the FlyCamera math by a small helper test class that's part of the App assembly. Since the App project currently has no tests, create `tests/AcDream.App.Tests/` with a new xunit project and reference AcDream.App.
|
|
|
|
Actually simpler: keep FlyCamera in `AcDream.App/Rendering/` and skip formal unit tests — validate via manual smoke in Task 7. Math is simple enough.
|
|
|
|
### Step 1: Create `FlyCamera`
|
|
|
|
```csharp
|
|
// src/AcDream.App/Rendering/FlyCamera.cs
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
public sealed class FlyCamera : ICamera
|
|
{
|
|
public Vector3 Position { get; set; } = new(96, 96, 150);
|
|
public float Yaw { get; set; } = MathF.PI / 2f; // facing +Y
|
|
public float Pitch { get; set; } = -0.3f; // looking slightly down
|
|
public float FovY { get; set; } = MathF.PI / 3f;
|
|
public float Aspect { get; set; } = 16f / 9f;
|
|
|
|
public float MoveSpeed { get; set; } = 100f; // world units per second
|
|
public float MouseSensitivity { get; set; } = 0.003f;
|
|
|
|
private const float PitchLimit = 1.5533f; // ~89 degrees
|
|
|
|
public Matrix4x4 View
|
|
{
|
|
get
|
|
{
|
|
var forward = Forward();
|
|
return Matrix4x4.CreateLookAt(Position, Position + forward, Vector3.UnitZ);
|
|
}
|
|
}
|
|
|
|
public Matrix4x4 Projection
|
|
=> Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);
|
|
|
|
/// <summary>
|
|
/// Integrate position for one frame based on WASD + vertical keys.
|
|
/// W/S move forward/back in the horizontal plane (ignoring pitch).
|
|
/// A/D strafe left/right. Up/down translate along world Z.
|
|
/// </summary>
|
|
public void Update(double dt, bool w, bool a, bool s, bool d, bool up, bool down)
|
|
{
|
|
float step = (float)(MoveSpeed * dt);
|
|
|
|
// Forward in the horizontal plane (ignore pitch so W doesn't dive into ground).
|
|
var flatForward = new Vector3(MathF.Cos(Yaw), MathF.Sin(Yaw), 0f);
|
|
var right = new Vector3(MathF.Sin(Yaw), -MathF.Cos(Yaw), 0f);
|
|
|
|
if (w) Position += flatForward * step;
|
|
if (s) Position -= flatForward * step;
|
|
if (a) Position -= right * step;
|
|
if (d) Position += right * step;
|
|
if (up) Position += Vector3.UnitZ * step;
|
|
if (down) Position -= Vector3.UnitZ * step;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply accumulated mouse deltas (pixels since last frame). Positive deltaX
|
|
/// rotates the view to the right (decreases yaw), positive deltaY rotates
|
|
/// down (decreases pitch).
|
|
/// </summary>
|
|
public void Look(float deltaX, float deltaY)
|
|
{
|
|
Yaw -= deltaX * MouseSensitivity;
|
|
Pitch = Math.Clamp(Pitch - deltaY * MouseSensitivity, -PitchLimit, PitchLimit);
|
|
}
|
|
|
|
private Vector3 Forward()
|
|
{
|
|
float cp = MathF.Cos(Pitch);
|
|
return new Vector3(
|
|
cp * MathF.Cos(Yaw),
|
|
cp * MathF.Sin(Yaw),
|
|
MathF.Sin(Pitch));
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 2: Build
|
|
|
|
```bash
|
|
dotnet build
|
|
```
|
|
Expected: 0 warnings, 0 errors.
|
|
|
|
### Step 3: Commit
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/FlyCamera.cs
|
|
git commit -m "feat(app): add FlyCamera with WASD + mouse look"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: `CameraController` + input wiring
|
|
|
|
**Files:**
|
|
- Create: `src/AcDream.App/Rendering/CameraController.cs`
|
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
|
|
|
### Step 1: Create `CameraController`
|
|
|
|
```csharp
|
|
// src/AcDream.App/Rendering/CameraController.cs
|
|
namespace AcDream.App.Rendering;
|
|
|
|
public sealed class CameraController
|
|
{
|
|
public OrbitCamera Orbit { get; }
|
|
public FlyCamera Fly { get; }
|
|
public ICamera Active { get; private set; }
|
|
public bool IsFlyMode => Active == Fly;
|
|
|
|
public event Action<bool>? ModeChanged;
|
|
|
|
public CameraController(OrbitCamera orbit, FlyCamera fly)
|
|
{
|
|
Orbit = orbit;
|
|
Fly = fly;
|
|
Active = orbit;
|
|
}
|
|
|
|
public void ToggleFly()
|
|
{
|
|
Active = IsFlyMode ? (ICamera)Orbit : Fly;
|
|
ModeChanged?.Invoke(IsFlyMode);
|
|
}
|
|
|
|
public void SetAspect(float aspect)
|
|
{
|
|
Orbit.Aspect = aspect;
|
|
Fly.Aspect = aspect;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 2: Wire into `GameWindow`
|
|
|
|
In `src/AcDream.App/Rendering/GameWindow.cs`:
|
|
|
|
**2a.** Replace the `_camera` field with a `_cameraController` field:
|
|
|
|
```csharp
|
|
private CameraController? _cameraController;
|
|
```
|
|
|
|
Remove the `_camera` field. Add:
|
|
|
|
```csharp
|
|
private IMouse? _capturedMouse; // set when entering fly mode
|
|
```
|
|
|
|
**2b.** In `OnLoad`, after the existing `_camera = new OrbitCamera { ... }` line (which you're deleting), add:
|
|
|
|
```csharp
|
|
var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y };
|
|
var fly = new FlyCamera { Aspect = _window.Size.X / (float)_window.Size.Y };
|
|
_cameraController = new CameraController(orbit, fly);
|
|
_cameraController.ModeChanged += OnCameraModeChanged;
|
|
```
|
|
|
|
**2c.** Replace all references to `_camera` in the rest of `OnLoad` with `_cameraController.Orbit` (for the initial orbit setup) or `_cameraController.Active` (for renderers that take ICamera). Specifically:
|
|
|
|
- `_terrain = new TerrainRenderer(_gl, _shader, terrainAtlas);` — unchanged
|
|
- `_staticMesh = new StaticMeshRenderer(_gl, _meshShader, _textureCache);` — unchanged
|
|
- Any spot that read `_camera.Yaw`, `_camera.Pitch`, `_camera.Distance` — those only existed in mouse drag handlers which need splitting (see 2d).
|
|
|
|
**2d.** Replace the existing mouse handlers:
|
|
|
|
```csharp
|
|
foreach (var mouse in _input.Mice)
|
|
{
|
|
mouse.MouseMove += (m, pos) =>
|
|
{
|
|
if (_cameraController is null) return;
|
|
|
|
if (_cameraController.IsFlyMode)
|
|
{
|
|
// Raw cursor mode: Silk.NET gives deltas via position. Compute delta from last.
|
|
float dx = pos.X - _lastMouseX;
|
|
float dy = pos.Y - _lastMouseY;
|
|
_cameraController.Fly.Look(dx, dy);
|
|
}
|
|
else
|
|
{
|
|
if (m.IsButtonPressed(MouseButton.Left))
|
|
{
|
|
_cameraController.Orbit.Yaw -= (pos.X - _lastMouseX) * 0.005f;
|
|
_cameraController.Orbit.Pitch = Math.Clamp(
|
|
_cameraController.Orbit.Pitch + (pos.Y - _lastMouseY) * 0.005f,
|
|
0.1f, 1.5f);
|
|
}
|
|
}
|
|
_lastMouseX = pos.X;
|
|
_lastMouseY = pos.Y;
|
|
};
|
|
mouse.Scroll += (_, scroll) =>
|
|
{
|
|
if (_cameraController is null || _cameraController.IsFlyMode) return;
|
|
_cameraController.Orbit.Distance = Math.Clamp(
|
|
_cameraController.Orbit.Distance - scroll.Y * 20f, 50f, 2000f);
|
|
};
|
|
}
|
|
```
|
|
|
|
**2e.** Update the keyboard handler to add F toggle and contextual Escape:
|
|
|
|
```csharp
|
|
foreach (var kb in _input.Keyboards)
|
|
kb.KeyDown += (_, key, _) =>
|
|
{
|
|
if (key == Key.F)
|
|
_cameraController?.ToggleFly();
|
|
else if (key == Key.Escape)
|
|
{
|
|
if (_cameraController?.IsFlyMode == true)
|
|
_cameraController.ToggleFly(); // exit fly, release cursor
|
|
else
|
|
_window!.Close();
|
|
}
|
|
};
|
|
```
|
|
|
|
**2f.** Add `OnUpdate` handler. Register it in `Run()`:
|
|
|
|
```csharp
|
|
_window.Update += OnUpdate;
|
|
```
|
|
|
|
(next to the existing `_window.Load += OnLoad;` etc.)
|
|
|
|
Then add the method:
|
|
|
|
```csharp
|
|
private void OnUpdate(double dt)
|
|
{
|
|
if (_cameraController is null || _input is null) return;
|
|
if (!_cameraController.IsFlyMode) return;
|
|
|
|
var kb = _input.Keyboards[0];
|
|
_cameraController.Fly.Update(
|
|
dt,
|
|
w: kb.IsKeyPressed(Key.W),
|
|
a: kb.IsKeyPressed(Key.A),
|
|
s: kb.IsKeyPressed(Key.S),
|
|
d: kb.IsKeyPressed(Key.D),
|
|
up: kb.IsKeyPressed(Key.Space),
|
|
down: kb.IsKeyPressed(Key.ControlLeft));
|
|
}
|
|
```
|
|
|
|
**2g.** Add the mode-change handler that manages cursor capture:
|
|
|
|
```csharp
|
|
private void OnCameraModeChanged(bool isFlyMode)
|
|
{
|
|
if (_input is null) return;
|
|
var mouse = _input.Mice.FirstOrDefault();
|
|
if (mouse is null) return;
|
|
|
|
mouse.Cursor.CursorMode = isFlyMode ? CursorMode.Raw : CursorMode.Normal;
|
|
_capturedMouse = isFlyMode ? mouse : null;
|
|
}
|
|
```
|
|
|
|
**2h.** Update `OnRender` to pass the active camera:
|
|
|
|
```csharp
|
|
private void OnRender(double deltaSeconds)
|
|
{
|
|
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
|
if (_cameraController is not null)
|
|
{
|
|
_terrain?.Draw(_cameraController.Active);
|
|
_staticMesh?.Draw(_cameraController.Active, _entities);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 3: Build and manual smoke
|
|
|
|
```bash
|
|
dotnet build
|
|
```
|
|
Expected: 0 warnings, 0 errors.
|
|
|
|
```bash
|
|
dotnet run --project src/AcDream.App -- "references/Asheron's Call"
|
|
```
|
|
|
|
Expected: app opens normally in orbit mode. Pressing F should flip to fly mode (cursor captures, WASD moves the camera). Pressing F again or Escape returns to orbit. A second Escape closes the window.
|
|
|
|
Kill after quick manual check:
|
|
```bash
|
|
taskkill //IM AcDream.App.exe //F 2>&1
|
|
```
|
|
|
|
### Step 4: Commit
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/CameraController.cs src/AcDream.App/Rendering/GameWindow.cs
|
|
git commit -m "feat(app): add CameraController with F toggle and cursor capture"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Plugin abstractions + host impls + WorldEvents TDD
|
|
|
|
**Files:**
|
|
- Create: `src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs`
|
|
- Create: `src/AcDream.Plugin.Abstractions/IGameState.cs`
|
|
- Create: `src/AcDream.Plugin.Abstractions/IEvents.cs`
|
|
- Modify: `src/AcDream.Plugin.Abstractions/IPluginHost.cs`
|
|
- Create: `src/AcDream.App/Plugins/WorldGameState.cs`
|
|
- Create: `src/AcDream.App/Plugins/WorldEvents.cs`
|
|
- Modify: `src/AcDream.App/Plugins/AppPluginHost.cs`
|
|
- Create: `tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs` — wait, `AcDream.Core.Tests` references `AcDream.Core`, not `AcDream.App`. Need to either move `WorldEvents` to `AcDream.Core/Plugins/` (hosting code in Core is weird) OR create a new test project for the App.
|
|
|
|
**Decision:** Move `WorldEvents` to `src/AcDream.Core/Plugins/WorldEvents.cs`. It has no GL dependency. Move `WorldGameState` too. Only `AppPluginHost` stays in the App project.
|
|
|
|
- Create: `src/AcDream.Core/Plugins/WorldEvents.cs`
|
|
- Create: `src/AcDream.Core/Plugins/WorldGameState.cs`
|
|
- Create: `tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs`
|
|
|
|
The `AcDream.Core` project already references `AcDream.Plugin.Abstractions` (Phase 1 established this).
|
|
|
|
### Step 1: Create abstractions
|
|
|
|
```csharp
|
|
// src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.Plugin.Abstractions;
|
|
|
|
public readonly record struct WorldEntitySnapshot(
|
|
uint Id,
|
|
uint SourceId,
|
|
Vector3 Position,
|
|
Quaternion Rotation);
|
|
```
|
|
|
|
```csharp
|
|
// src/AcDream.Plugin.Abstractions/IGameState.cs
|
|
namespace AcDream.Plugin.Abstractions;
|
|
|
|
public interface IGameState
|
|
{
|
|
IReadOnlyList<WorldEntitySnapshot> Entities { get; }
|
|
}
|
|
```
|
|
|
|
```csharp
|
|
// src/AcDream.Plugin.Abstractions/IEvents.cs
|
|
namespace AcDream.Plugin.Abstractions;
|
|
|
|
public interface IEvents
|
|
{
|
|
event Action<WorldEntitySnapshot> EntitySpawned;
|
|
}
|
|
```
|
|
|
|
### Step 2: Update `IPluginHost`
|
|
|
|
Replace `src/AcDream.Plugin.Abstractions/IPluginHost.cs`:
|
|
|
|
```csharp
|
|
namespace AcDream.Plugin.Abstractions;
|
|
|
|
/// <summary>
|
|
/// Entry point for a plugin into the acdream runtime. The surface will grow
|
|
/// across phases as more systems come online.
|
|
/// </summary>
|
|
public interface IPluginHost
|
|
{
|
|
IPluginLogger Log { get; }
|
|
IGameState State { get; }
|
|
IEvents Events { get; }
|
|
}
|
|
```
|
|
|
|
### Step 3: Write failing tests for `WorldEvents`
|
|
|
|
```csharp
|
|
// tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs
|
|
using System.Numerics;
|
|
using AcDream.Core.Plugins;
|
|
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Core.Tests.Plugins;
|
|
|
|
public class WorldEventsTests
|
|
{
|
|
private static WorldEntitySnapshot S(uint id) => new(id, SourceId: 0x01000000u, Position: Vector3.Zero, Rotation: Quaternion.Identity);
|
|
|
|
[Fact]
|
|
public void FireBeforeAnySubscriber_LateSubscribeReceivesReplay()
|
|
{
|
|
var events = new WorldEvents();
|
|
events.FireEntitySpawned(S(1));
|
|
events.FireEntitySpawned(S(2));
|
|
events.FireEntitySpawned(S(3));
|
|
|
|
var seen = new List<uint>();
|
|
events.EntitySpawned += e => seen.Add(e.Id);
|
|
|
|
Assert.Equal(new uint[] { 1, 2, 3 }, seen);
|
|
}
|
|
|
|
[Fact]
|
|
public void FireAfterSubscribe_ReachesSubscriber()
|
|
{
|
|
var events = new WorldEvents();
|
|
var seen = new List<uint>();
|
|
events.EntitySpawned += e => seen.Add(e.Id);
|
|
|
|
events.FireEntitySpawned(S(10));
|
|
events.FireEntitySpawned(S(20));
|
|
|
|
Assert.Equal(new uint[] { 10, 20 }, seen);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplayPlusLive_DeliversExactlyOnceEach()
|
|
{
|
|
var events = new WorldEvents();
|
|
events.FireEntitySpawned(S(1)); // pre-subscribe
|
|
|
|
var seen = new List<uint>();
|
|
events.EntitySpawned += e => seen.Add(e.Id); // replay fires 1
|
|
|
|
events.FireEntitySpawned(S(2)); // live fires 2
|
|
|
|
Assert.Equal(new uint[] { 1, 2 }, seen);
|
|
}
|
|
|
|
[Fact]
|
|
public void Unsubscribe_StopsLiveDelivery()
|
|
{
|
|
var events = new WorldEvents();
|
|
var seen = new List<uint>();
|
|
Action<WorldEntitySnapshot> handler = e => seen.Add(e.Id);
|
|
|
|
events.EntitySpawned += handler;
|
|
events.FireEntitySpawned(S(1));
|
|
events.EntitySpawned -= handler;
|
|
events.FireEntitySpawned(S(2));
|
|
|
|
Assert.Equal(new uint[] { 1 }, seen);
|
|
}
|
|
|
|
[Fact]
|
|
public void HandlerThrowsDuringReplay_OtherReplayEntriesStillDelivered()
|
|
{
|
|
var events = new WorldEvents();
|
|
events.FireEntitySpawned(S(1));
|
|
events.FireEntitySpawned(S(2));
|
|
events.FireEntitySpawned(S(3));
|
|
|
|
var seen = new List<uint>();
|
|
events.EntitySpawned += e =>
|
|
{
|
|
if (e.Id == 2) throw new InvalidOperationException("boom");
|
|
seen.Add(e.Id);
|
|
};
|
|
|
|
// No exception propagates out of the += add; 1 and 3 were still delivered.
|
|
Assert.Contains(1u, seen);
|
|
Assert.Contains(3u, seen);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 4: Run RED
|
|
|
|
```bash
|
|
dotnet test --filter "FullyQualifiedName~WorldEventsTests"
|
|
```
|
|
Expected: compile errors for `WorldEvents` not existing.
|
|
|
|
### Step 5: Implement `WorldEvents`
|
|
|
|
```csharp
|
|
// src/AcDream.Core/Plugins/WorldEvents.cs
|
|
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Core.Plugins;
|
|
|
|
public sealed class WorldEvents : IEvents
|
|
{
|
|
private readonly object _lock = new();
|
|
private readonly List<WorldEntitySnapshot> _alreadySpawned = new();
|
|
private Action<WorldEntitySnapshot>? _subscribers;
|
|
|
|
/// <summary>
|
|
/// Called by the host as each entity is hydrated into the world. Records the
|
|
/// snapshot for later replay and dispatches to current subscribers.
|
|
/// </summary>
|
|
public void FireEntitySpawned(WorldEntitySnapshot snapshot)
|
|
{
|
|
Action<WorldEntitySnapshot>? toNotify;
|
|
lock (_lock)
|
|
{
|
|
_alreadySpawned.Add(snapshot);
|
|
toNotify = _subscribers;
|
|
}
|
|
|
|
if (toNotify is null) return;
|
|
foreach (Action<WorldEntitySnapshot> handler in toNotify.GetInvocationList())
|
|
{
|
|
try { handler(snapshot); }
|
|
catch { /* plugin errors don't propagate out of event dispatch */ }
|
|
}
|
|
}
|
|
|
|
public event Action<WorldEntitySnapshot> EntitySpawned
|
|
{
|
|
add
|
|
{
|
|
WorldEntitySnapshot[] replay;
|
|
lock (_lock)
|
|
{
|
|
_subscribers += value;
|
|
replay = _alreadySpawned.ToArray();
|
|
}
|
|
// Replay outside the lock to avoid deadlock if a handler re-enters.
|
|
foreach (var s in replay)
|
|
{
|
|
try { value(s); }
|
|
catch { /* plugin errors don't propagate out of += */ }
|
|
}
|
|
}
|
|
remove
|
|
{
|
|
lock (_lock)
|
|
_subscribers -= value;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 6: Implement `WorldGameState`
|
|
|
|
```csharp
|
|
// src/AcDream.Core/Plugins/WorldGameState.cs
|
|
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Core.Plugins;
|
|
|
|
public sealed class WorldGameState : IGameState
|
|
{
|
|
private readonly List<WorldEntitySnapshot> _entities = new();
|
|
|
|
public IReadOnlyList<WorldEntitySnapshot> Entities => _entities;
|
|
|
|
/// <summary>Called by the host as each entity is hydrated.</summary>
|
|
public void Add(WorldEntitySnapshot snapshot) => _entities.Add(snapshot);
|
|
}
|
|
```
|
|
|
|
### Step 7: Update `AppPluginHost`
|
|
|
|
Replace `src/AcDream.App/Plugins/AppPluginHost.cs`:
|
|
|
|
```csharp
|
|
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.App.Plugins;
|
|
|
|
public sealed class AppPluginHost : IPluginHost
|
|
{
|
|
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events)
|
|
{
|
|
Log = log;
|
|
State = state;
|
|
Events = events;
|
|
}
|
|
|
|
public IPluginLogger Log { get; }
|
|
public IGameState State { get; }
|
|
public IEvents Events { get; }
|
|
}
|
|
```
|
|
|
|
### Step 8: Update `PluginLoaderTests` StubHost
|
|
|
|
The existing `PluginLoaderTests` has a private `StubHost : IPluginHost` that only implemented `Log`. It now needs `State` and `Events` too. In `tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs`:
|
|
|
|
```csharp
|
|
private sealed class StubHost : IPluginHost
|
|
{
|
|
public IPluginLogger Log { get; } = new StubLogger();
|
|
public IGameState State { get; } = new StubState();
|
|
public IEvents Events { get; } = new StubEvents();
|
|
}
|
|
|
|
private sealed class StubLogger : IPluginLogger
|
|
{
|
|
public void Info(string message) { }
|
|
public void Warn(string message) { }
|
|
public void Error(string message, Exception? exception = null) { }
|
|
}
|
|
|
|
private sealed class StubState : IGameState
|
|
{
|
|
public IReadOnlyList<WorldEntitySnapshot> Entities { get; } = Array.Empty<WorldEntitySnapshot>();
|
|
}
|
|
|
|
private sealed class StubEvents : IEvents
|
|
{
|
|
public event Action<WorldEntitySnapshot>? EntitySpawned;
|
|
}
|
|
```
|
|
|
|
(The unused `EntitySpawned` field may trigger a `CS0067` warning. Add `#pragma warning disable CS0067` above the class and `#pragma warning restore CS0067` after it, OR mark the event as `add { } remove { }` empty accessors to silence the warning.)
|
|
|
|
### Step 9: Run tests
|
|
|
|
```bash
|
|
dotnet test
|
|
```
|
|
Expected: `Passed: 48` (43 previous + 5 new WorldEvents tests).
|
|
|
|
### Step 10: Commit
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(core): add IGameState, IEvents, WorldEvents with replay-on-subscribe"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: `Program.cs` wire + `SmokePlugin` subscribes
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.App/Program.cs`
|
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
|
- Modify: `src/AcDream.Plugins.Smoke/SmokePlugin.cs`
|
|
|
|
### Step 1: Update `Program.cs` to build `WorldGameState` + `WorldEvents` and pass them to `AppPluginHost`
|
|
|
|
In `src/AcDream.App/Program.cs`, replace the host construction line:
|
|
|
|
```csharp
|
|
var host = new AppPluginHost(new SerilogAdapter(Log.Logger));
|
|
```
|
|
|
|
with:
|
|
|
|
```csharp
|
|
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
|
|
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
|
|
var host = new AppPluginHost(
|
|
new SerilogAdapter(Log.Logger),
|
|
worldGameState,
|
|
worldEvents);
|
|
```
|
|
|
|
### Step 2: Pass `worldGameState` and `worldEvents` into `GameWindow`
|
|
|
|
`GameWindow` constructor currently takes `(string datDir)`. Extend it to:
|
|
|
|
```csharp
|
|
public GameWindow(
|
|
string datDir,
|
|
AcDream.Core.Plugins.WorldGameState worldGameState,
|
|
AcDream.Core.Plugins.WorldEvents worldEvents)
|
|
{
|
|
_datDir = datDir;
|
|
_worldGameState = worldGameState;
|
|
_worldEvents = worldEvents;
|
|
}
|
|
```
|
|
|
|
Add the fields:
|
|
|
|
```csharp
|
|
private readonly AcDream.Core.Plugins.WorldGameState _worldGameState;
|
|
private readonly AcDream.Core.Plugins.WorldEvents _worldEvents;
|
|
```
|
|
|
|
In `Program.cs`, update the constructor call:
|
|
|
|
```csharp
|
|
using var window = new GameWindow(datDir, worldGameState, worldEvents);
|
|
```
|
|
|
|
### Step 3: Fire events during entity hydration in `GameWindow.OnLoad`
|
|
|
|
Inside the `foreach (var e in allEntities)` loop in `GameWindow.OnLoad`, after the `if (meshRefs.Count > 0) { hydratedEntities.Add(...); }` block, add:
|
|
|
|
```csharp
|
|
if (meshRefs.Count > 0)
|
|
{
|
|
var hydrated = new AcDream.Core.World.WorldEntity { /* ... existing ... */ };
|
|
hydratedEntities.Add(hydrated);
|
|
|
|
// Fire plugin event + update game state snapshot.
|
|
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
|
Id: hydrated.Id,
|
|
SourceId: hydrated.SourceGfxObjOrSetupId,
|
|
Position: hydrated.Position,
|
|
Rotation: hydrated.Rotation);
|
|
_worldGameState.Add(snapshot);
|
|
_worldEvents.FireEntitySpawned(snapshot);
|
|
}
|
|
```
|
|
|
|
(The existing block used `hydratedEntities.Add(new AcDream.Core.World.WorldEntity { ... })` inline. Refactor to capture the entity in a local first so we can build the snapshot from it.)
|
|
|
|
### Step 4: Update `SmokePlugin` to subscribe
|
|
|
|
Replace `src/AcDream.Plugins.Smoke/SmokePlugin.cs`:
|
|
|
|
```csharp
|
|
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Plugins.Smoke;
|
|
|
|
public sealed class SmokePlugin : IAcDreamPlugin
|
|
{
|
|
private IPluginHost? _host;
|
|
private int _entitiesSeen;
|
|
|
|
public void Initialize(IPluginHost host)
|
|
{
|
|
_host = host;
|
|
_host.Log.Info("smoke plugin initialized");
|
|
}
|
|
|
|
public void Enable()
|
|
{
|
|
_host!.Log.Info("smoke plugin enabled");
|
|
_host.Events.EntitySpawned += OnEntitySpawned;
|
|
_host.Log.Info($"smoke plugin sees {_entitiesSeen} entities (replay count at subscribe)");
|
|
}
|
|
|
|
public void Disable()
|
|
{
|
|
if (_host is not null)
|
|
{
|
|
_host.Events.EntitySpawned -= OnEntitySpawned;
|
|
_host.Log.Info($"smoke plugin disabled (saw {_entitiesSeen} entities total)");
|
|
}
|
|
}
|
|
|
|
private void OnEntitySpawned(WorldEntitySnapshot entity) => _entitiesSeen++;
|
|
}
|
|
```
|
|
|
|
### Step 5: Build and test
|
|
|
|
```bash
|
|
dotnet build
|
|
dotnet test
|
|
```
|
|
Expected: clean build, 48 tests passing.
|
|
|
|
### Step 6: End-to-end smoke
|
|
|
|
```bash
|
|
dotnet run --project src/AcDream.App -- "references/Asheron's Call"
|
|
```
|
|
|
|
Expected console output:
|
|
```
|
|
[INF] scanning plugins in ...\plugins
|
|
[INF] smoke plugin initialized
|
|
[INF] loaded plugin acdream.smoke (Smoke Plugin)
|
|
[INF] smoke plugin enabled
|
|
[INF] smoke plugin sees 0 entities (replay count at subscribe)
|
|
loading world view centered on 0xA9B4FFFF
|
|
TerrainAtlas: <N> layers at <W>x<H>
|
|
loaded 9 landblocks in 3x3 grid
|
|
hydrating <several hundred> entities across 9 landblocks
|
|
hydrated <several hundred> entities
|
|
(window opens; user can orbit + F-toggle to fly mode)
|
|
(user closes window)
|
|
[INF] smoke plugin disabled (saw <several hundred> entities total)
|
|
```
|
|
|
|
After ~8 seconds of run, kill:
|
|
```bash
|
|
taskkill //IM AcDream.App.exe //F 2>&1
|
|
```
|
|
|
|
Verify the output file shows both the `sees 0 entities` line AND the `saw <N> entities total` line (where N matches the `hydrated` count). This proves the replay-on-subscribe path correctly delivers live events to a subscriber registered before the world loaded.
|
|
|
|
### Step 7: Commit
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(app): wire IGameState+IEvents into Program and SmokePlugin"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2b done criteria
|
|
|
|
1. `dotnet build` — clean, 0 warnings.
|
|
2. `dotnet test` — 48 passing (42 carried + 6 added: 1 LandblockMesh, 5 WorldEvents).
|
|
3. `dotnet run --project src/AcDream.App -- "references/Asheron's Call"` — opens a window showing Holtburg's 3x3 landblock grid with real terrain textures, populated with static entities across all 9 landblocks. F toggles between orbit and fly camera. Escape in fly mode returns to orbit; Escape in orbit closes.
|
|
4. Console shows both smoke plugin lines (`sees 0 entities (replay)` at enable time, `saw N entities total` at disable time, where N > 100).
|
|
5. 10 task commits on `phase-2b/atlas-neighbors-cameras-events` branch (Task 0 is branch creation only).
|
|
|
|
## Stopping point
|
|
|
|
Task 9 complete → merge to main → update memory → end of session.
|