feat(app): Phase A.1 — wire StreamingController into GameWindow (MVP)

Replaces the one-shot 3×3 preload in OnLoad with
StreamingController + LandblockStreamer. Runtime-configurable
window radius via ACDREAM_STREAM_RADIUS (default 2 → 5×5). OnUpdate
drives StreamingController.Tick once per frame with the current
observer landblock coordinates (camera-offset in offline, player
last-known in live).

_entities flat list replaced by GpuWorldState.Entities. Live
CreateObject handler uses GpuWorldState.AppendLiveEntity instead of
the old list-rebuild-and-replace pattern. Streamer is disposed in
OnClosing before GL teardown so the worker thread is joined before
we release resources.

Terrain build dependencies (heightTable, blendCtx, surfaceCache) are
stored as fields so ApplyLoadedTerrain can call LandblockMesh.Build
on the render thread without re-deriving them per landblock.
ICamera.Position fix: offline observer coordinate uses
_cameraController.Fly.Position (FlyCamera exposes Position; ICamera
does not), which is always up-to-date regardless of active camera mode.

MVP scope: stabs only. Scenery (trees/rocks/bushes) and interior
(EnvCell walls/floors + static objects) will land in Task 8 and
are currently DROPPED from streamed landblocks. The offline view
will show terrain + stabs but no vegetation and no building
interiors until Task 8 lands. Live mode is unaffected since
CreateObject spawns come through a different path.

212 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 22:34:34 +02:00
parent 9067c4f60b
commit efcf0c30d0

View file

@ -26,7 +26,20 @@ public sealed class GameWindow : IDisposable
private StaticMeshRenderer? _staticMesh;
private Shader? _meshShader;
private TextureCache? _textureCache;
private IReadOnlyList<AcDream.Core.World.WorldEntity> _entities = Array.Empty<AcDream.Core.World.WorldEntity>();
// Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer;
private readonly AcDream.App.Streaming.GpuWorldState _worldState = new();
private AcDream.App.Streaming.StreamingController? _streamingController;
private int _streamingRadius = 2; // default 5×5
private uint? _lastLivePlayerLandblockId;
// Terrain build context shared across all streamed landblocks. Stored as
// fields so ApplyLoadedTerrain (render-thread callback) can call
// LandblockMesh.Build without re-deriving these each time.
private float[]? _heightTable;
private AcDream.Core.Terrain.TerrainBlendingContext? _blendCtx;
private Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>? _surfaceCache;
/// <summary>
/// Phase 6.4: per-entity animation playback state for entities whose
@ -200,16 +213,12 @@ public sealed class GameWindow : IDisposable
_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);
// Shared blending context + SurfaceInfo cache across all loaded
// landblocks. Palette codes are deterministic so two landblocks that
// happen to share a cell layout hit the cache instead of rebuilding.
// Build blending context from the terrain atlas. Stored as fields so
// ApplyLoadedTerrain (render-thread callback invoked per streamed lb)
// can call LandblockMesh.Build without re-deriving these every time.
var terrainTypeToLayerBytes = new Dictionary<uint, byte>(terrainAtlas.TerrainTypeToLayer.Count);
foreach (var kvp in terrainAtlas.TerrainTypeToLayer)
terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value;
@ -219,7 +228,7 @@ public sealed class GameWindow : IDisposable
? rl
: AcDream.Core.Terrain.SurfaceInfo.None;
var blendCtx = new AcDream.Core.Terrain.TerrainBlendingContext(
_blendCtx = new AcDream.Core.Terrain.TerrainBlendingContext(
TerrainTypeToLayer: terrainTypeToLayerBytes,
RoadLayer: roadLayer,
CornerAlphaLayers: terrainAtlas.CornerAlphaLayers,
@ -229,344 +238,33 @@ public sealed class GameWindow : IDisposable
SideAlphaTCodes: terrainAtlas.SideAlphaTCodes,
RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes);
var surfaceCache = new Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
foreach (var lb in worldView.Landblocks)
{
uint lbX = (lb.LandblockId >> 24) & 0xFFu;
uint lbY = (lb.LandblockId >> 16) & 0xFFu;
var meshData = AcDream.Core.Terrain.LandblockMesh.Build(
lb.Heightmap, lbX, lbY, heightTable, blendCtx, surfaceCache);
// Compute world origin for this landblock relative to the center.
var origin = new System.Numerics.Vector3(
((int)lbX - centerX) * 192f,
((int)lbY - centerY) * 192f,
0f);
_terrain.AddLandblock(lb.LandblockId, meshData, origin);
}
Console.WriteLine($"terrain: {surfaceCache.Count} unique palette codes across {worldView.Landblocks.Count} landblocks");
_heightTable = heightTable;
_surfaceCache = new Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
_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");
// Phase A.1: replace the one-shot 3×3 preload with a streaming controller.
// Parse runtime radius from environment (default 2 → 5×5 window).
// Values outside [0, 8] fall back to the field default of 2.
var radiusEnv = Environment.GetEnvironmentVariable("ACDREAM_STREAM_RADIUS");
if (int.TryParse(radiusEnv, out var r) && r >= 0 && r <= 8)
_streamingRadius = r;
Console.WriteLine($"streaming: radius={_streamingRadius} (window={2*_streamingRadius+1}×{2*_streamingRadius+1})");
var hydratedEntities = new List<AcDream.Core.World.WorldEntity>(allEntities.Count);
foreach (var e in allEntities)
{
var meshRefs = new List<AcDream.Core.World.MeshRef>();
// The streamer's load delegate wraps LandblockLoader.Load + stab
// hydration. Scenery + interior will land in Task 8.
_streamer = new AcDream.App.Streaming.LandblockStreamer(
loadLandblock: id => BuildLandblockForStreaming(id));
_streamer.Start();
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, _dats);
_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, _dats);
_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);
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = e.Id,
SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
Position = e.Position + worldOffset,
Rotation = e.Rotation,
MeshRefs = meshRefs,
};
hydratedEntities.Add(hydrated);
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);
}
}
// Phase 2c: procedural scenery — trees, bushes, rocks, fences from
// Region.SceneInfo. These aren't stored as explicit Stab entries; they're
// generated deterministically from per-vertex TerrainInfo.Scenery bits.
int scenerySpawned = 0;
uint sceneryIdCounter = 0x80000000u; // high bit set to avoid colliding with Stab ids
foreach (var lb in worldView.Landblocks)
{
var spawns = AcDream.Core.World.SceneryGenerator.Generate(
_dats, region!, lb.Heightmap, lb.LandblockId);
if (spawns.Count == 0) continue;
int lbX = (int)((lb.LandblockId >> 24) & 0xFFu);
int lbY = (int)((lb.LandblockId >> 16) & 0xFFu);
var lbOffset = new System.Numerics.Vector3(
(lbX - centerX) * 192f,
(lbY - centerY) * 192f,
0f);
foreach (var spawn in spawns)
{
// Resolve the object to a mesh (same GfxObj/Setup logic as Stabs).
// Scale is baked into the root transform by wrapping each part's
// transform with a scale matrix.
var meshRefs = new List<AcDream.Core.World.MeshRef>();
var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale);
if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(spawn.ObjectId);
if (gfx is not null)
{
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
_staticMesh.EnsureUploaded(spawn.ObjectId, subMeshes);
meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat));
}
}
else if ((spawn.ObjectId & 0xFF000000u) == 0x02000000u)
{
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.ObjectId);
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, _dats);
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
// Compose: part's own transform, then the spawn's scale.
meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, mr.PartTransform * scaleMat));
}
}
}
if (meshRefs.Count == 0) continue;
// Sample terrain Z at (localX, localY) to lift scenery onto the ground.
float localX = spawn.LocalPosition.X;
float localY = spawn.LocalPosition.Y;
float groundZ = SampleTerrainZ(lb.Heightmap, heightTable, localX, localY);
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = sceneryIdCounter++,
SourceGfxObjOrSetupId = spawn.ObjectId,
Position = new System.Numerics.Vector3(localX, localY, groundZ) + lbOffset,
Rotation = spawn.Rotation,
MeshRefs = meshRefs,
};
hydratedEntities.Add(hydrated);
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);
scenerySpawned++;
}
}
Console.WriteLine($"scenery: spawned {scenerySpawned} entities across {worldView.Landblocks.Count} landblocks");
// Phase 2d: walk interior EnvCells and add their StaticObjects. Buildings'
// rooftop statues, doors, interior decorations, and other in-building static
// objects live here rather than in LandBlockInfo.Objects. EnvCell ids for a
// landblock are packed at 0xAAAABBBB where AAAA is the landblock id high word
// and BBBB starts at 0x0100 — documented on LandBlockInfo.NumCells.
// Phase 7.1: also build each EnvCell's room geometry (walls/floors/ceilings)
// from CellStruct.Polygons + EnvCell.Surfaces.
int interiorSpawned = 0;
int cellMeshSpawned = 0;
uint interiorIdCounter = 0x40000000u; // distinct from scenery (0x80000000+) and stabs
foreach (var lb in worldView.Landblocks)
{
// Re-fetch LandBlockInfo to get NumCells. WorldView.LoadedLandblock exposes
// Heightmap + Entities but not the raw info record.
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((lb.LandblockId & 0xFFFF0000u) | 0xFFFEu);
if (lbInfo is null || lbInfo.NumCells == 0) continue;
int lbX = (int)((lb.LandblockId >> 24) & 0xFFu);
int lbY = (int)((lb.LandblockId >> 16) & 0xFFu);
var lbOffset = new System.Numerics.Vector3(
(lbX - centerX) * 192f,
(lbY - centerY) * 192f,
0f);
// Interior cells start at 0xAAAA0100 and run for NumCells.
uint firstCellId = (lb.LandblockId & 0xFFFF0000u) | 0x0100u;
for (uint offset = 0; offset < lbInfo.NumCells; offset++)
{
uint envCellId = firstCellId + offset;
var envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(envCellId);
if (envCell is null) continue;
// Phase 7.1: build and register room geometry for this EnvCell.
// Each EnvCell has an EnvironmentId that points to an Environment dat
// containing CellStruct geometry (vertex arrays + polygons). The surfaces
// list on the EnvCell (not the CellStruct) maps polygon PosSurface indices
// to unqualified surface ids (OR with 0x08000000 for the full dat id).
if (envCell.EnvironmentId != 0)
{
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
if (environment is not null
&& environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct))
{
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
if (cellSubMeshes.Count > 0)
{
// Use the EnvCell dat id as the GPU upload key. EnvCell ids
// live in 0xAAAA01xx space which is disjoint from GfxObj
// (0x01xxxxxx) and Setup (0x02xxxxxx) ids — no collision risk.
_staticMesh.EnsureUploaded(envCellId, cellSubMeshes);
// Cell vertices are in env-local space. The per-cell world
// transform is: rotate(envCell.Position.Orientation) then
// translate(envCell.Position.Origin + landblock offset).
// We bake the full transform into PartTransform and leave
// the WorldEntity at identity so the renderer's
// model = PartTransform * entityRoot = cellTransform * I
// gives the correctly positioned cell mesh.
//
// Z lift: buildings sit ON the terrain mesh, so the ground
// floor of every building is coincident in Z with the terrain
// polygon beneath it. Without a bias the two fight for the
// same depth and flicker as the camera moves. A small lift
// (2 cm) is invisible from human scale but breaks the tie
// cleanly in the cell mesh's favor.
var cellOrigin = envCell.Position.Origin + lbOffset
+ new System.Numerics.Vector3(0f, 0f, 0.02f);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransform);
var cellEntity = new AcDream.Core.World.WorldEntity
{
Id = interiorIdCounter++,
SourceGfxObjOrSetupId = envCellId,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = new[] { cellMeshRef },
};
hydratedEntities.Add(cellEntity);
var cellSnapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
Id: cellEntity.Id,
SourceId: cellEntity.SourceGfxObjOrSetupId,
Position: cellEntity.Position,
Rotation: cellEntity.Rotation);
_worldGameState.Add(cellSnapshot);
_worldEvents.FireEntitySpawned(cellSnapshot);
cellMeshSpawned++;
}
}
}
foreach (var stab in envCell.StaticObjects)
{
// Resolve stab id to mesh (same as LandBlockInfo.Objects).
var meshRefs = new List<AcDream.Core.World.MeshRef>();
if ((stab.Id & 0xFF000000u) == 0x01000000u)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
if (gfx is not null)
{
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
_staticMesh.EnsureUploaded(stab.Id, subMeshes);
meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity));
}
}
else if ((stab.Id & 0xFF000000u) == 0x02000000u)
{
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(stab.Id);
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, _dats);
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
meshRefs.Add(mr);
}
}
}
if (meshRefs.Count == 0) continue;
// Stabs inside EnvCells are already in landblock-local coordinates
// (same coordinate space as LandBlockInfo.Objects stabs) — NOT
// cell-local. The EnvCell.Position field tells the physics engine
// which cell owns the object, but it doesn't translate coordinates.
// Adding cellOrigin was a wrong assumption that left interior objects
// floating ~150 units in the air.
var worldPos = stab.Frame.Origin + lbOffset;
var worldRot = stab.Frame.Orientation;
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = interiorIdCounter++,
SourceGfxObjOrSetupId = stab.Id,
Position = worldPos,
Rotation = worldRot,
MeshRefs = meshRefs,
};
hydratedEntities.Add(hydrated);
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);
interiorSpawned++;
}
}
}
Console.WriteLine($"interior: spawned {interiorSpawned} static objects from EnvCells");
Console.WriteLine($"interior: built {cellMeshSpawned} cell room meshes");
_entities = hydratedEntities;
Console.WriteLine($"hydrated {_entities.Count} entities total (stabs + buildings + scenery + interior)");
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: _streamer.EnqueueLoad,
enqueueUnload: _streamer.EnqueueUnload,
drainCompletions: _streamer.DrainCompletions,
applyTerrain: ApplyLoadedTerrain,
state: _worldState,
radius: _streamingRadius);
// Phase 4.7: optional live-mode startup. Connect to the ACE server,
// enter the world as the first character on the account, and stream
@ -936,10 +634,10 @@ public sealed class GameWindow : IDisposable
_worldGameState.Add(snapshot);
_worldEvents.FireEntitySpawned(snapshot);
// Extend the render list so the next frame picks up the new entity.
// We copy into a new list because _entities is typed as IReadOnlyList.
var extended = new List<AcDream.Core.World.WorldEntity>(_entities) { entity };
_entities = extended;
// Phase A.1: register entity into GpuWorldState so the next frame picks
// it up. AppendLiveEntity is a no-op if the landblock isn't loaded yet
// (can happen if the server sends CreateObjects before we finish loading).
_worldState.AppendLiveEntity(spawn.Position!.Value.LandblockId, entity);
_liveSpawnHydrated++;
// Phase 6.6/6.7: remember the server-guid → WorldEntity mapping so
@ -1081,6 +779,11 @@ public sealed class GameWindow : IDisposable
/// </summary>
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
// Phase A.1: track the most recently updated entity's landblock so the
// streaming controller can follow the player. TODO: filter by our own
// character guid once we reliably know it from CharacterList.
_lastLivePlayerLandblockId = update.Position.LandblockId;
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
var p = update.Position;
@ -1097,6 +800,138 @@ public sealed class GameWindow : IDisposable
entity.Rotation = rot;
}
/// <summary>
/// Phase A.1: streaming load delegate, runs on the worker thread.
/// Reads the landblock from the dats, hydrates its stab entities (same
/// path as the old preload), and returns a fully-populated LoadedLandblock.
/// Thread-safe: uses only DatCollection reads (documented thread-safe by
/// DatReaderWriter) and pure CPU work. No GL calls here.
///
/// MVP scope: stabs only. Scenery + interior added in Task 8.
/// </summary>
private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreaming(uint landblockId)
{
if (_dats is null) return null;
var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId);
if (baseLoaded is null) return null;
int lbX = (int)((landblockId >> 24) & 0xFFu);
int lbY = (int)((landblockId >> 16) & 0xFFu);
var worldOffset = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
0f);
// Hydrate the stabs: same logic as the old OnLoad preload. Each stab
// entity from LandblockLoader carries a SourceGfxObjOrSetupId that we
// expand into per-part MeshRefs via SetupMesh.Flatten / GfxObjMesh.Build.
// GPU upload (EnsureUploaded) happens on the render thread in
// ApplyLoadedTerrain — NOT here.
var hydrated = new List<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count);
foreach (var e in baseLoaded.Entities)
{
var meshRefs = new List<AcDream.Core.World.MeshRef>();
if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
{
// Single GfxObj stab — identity part transform.
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId);
if (gfx is not null)
meshRefs.Add(new AcDream.Core.World.MeshRef(
e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
}
else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
// Multi-part Setup — flatten to per-part GfxObj refs.
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;
meshRefs.Add(mr);
}
}
}
if (meshRefs.Count == 0) continue;
var entity = new AcDream.Core.World.WorldEntity
{
Id = e.Id,
SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
Position = e.Position + worldOffset,
Rotation = e.Rotation,
MeshRefs = meshRefs,
};
hydrated.Add(entity);
}
return new AcDream.Core.World.LoadedLandblock(
baseLoaded.LandblockId,
baseLoaded.Heightmap,
hydrated);
}
/// <summary>
/// Phase A.1: render-thread callback from StreamingController.Tick
/// whenever a new landblock's terrain + entities are ready for GPU upload.
/// Mirrors the terrain-build + entity-upload part of the old preload.
/// Must only be called from the render thread.
/// </summary>
private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb)
{
if (_terrain is null || _dats is null || _blendCtx is null
|| _heightTable is null || _surfaceCache is null) return;
uint lbXu = (lb.LandblockId >> 24) & 0xFFu;
uint lbYu = (lb.LandblockId >> 16) & 0xFFu;
int lbX = (int)lbXu;
int lbY = (int)lbYu;
var origin = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
0f);
// Build terrain mesh data on the render thread (pure CPU; acceptable
// for the MVP; a future pass can move it to the worker thread).
var meshData = AcDream.Core.Terrain.LandblockMesh.Build(
lb.Heightmap, lbXu, lbYu, _heightTable, _blendCtx, _surfaceCache);
_terrain.AddLandblock(lb.LandblockId, meshData, origin);
// Upload every GfxObj referenced by this landblock's entities.
// EnsureUploaded is idempotent so duplicates across landblocks are free.
if (_staticMesh is not null)
{
foreach (var entity in lb.Entities)
{
foreach (var meshRef in entity.MeshRefs)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(meshRef.GfxObjId);
if (gfx is null) continue;
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
_staticMesh.EnsureUploaded(meshRef.GfxObjId, subMeshes);
}
}
}
// Register each stab as a plugin snapshot so the plugin host has
// visibility into the streaming world state.
foreach (var entity in lb.Entities)
{
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
Id: entity.Id,
SourceId: entity.SourceGfxObjOrSetupId,
Position: entity.Position,
Rotation: entity.Rotation);
_worldGameState.Add(snapshot);
_worldEvents.FireEntitySpawned(snapshot);
}
}
private void OnUpdate(double dt)
{
// Drain any pending live-session traffic. Non-blocking — returns
@ -1104,6 +939,37 @@ public sealed class GameWindow : IDisposable
// EntitySpawned events synchronously on this thread.
_liveSession?.Tick();
// Phase A.1: advance the streaming controller. Computes the observer's
// current landblock coordinates and feeds new load/unload diffs to the
// streamer, then drains any completed landblocks into GpuWorldState.
if (_streamingController is not null && _cameraController is not null)
{
int observerCx = _liveCenterX;
int observerCy = _liveCenterY;
if (_liveSession is not null
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld
&& _lastLivePlayerLandblockId is { } lid)
{
// Live mode: follow the server's last-known player position.
observerCx = (int)((lid >> 24) & 0xFFu);
observerCy = (int)((lid >> 16) & 0xFFu);
}
else
{
// Offline: project the fly camera's world-space position back into
// landblock coordinates. OrbitCamera doesn't expose Position via
// ICamera, but FlyCamera.Position is always updated (even when the
// orbit camera is Active), so this is safe in both modes.
// Each landblock is 192 world units wide.
var camPos = _cameraController.Fly.Position;
observerCx = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f);
observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
}
_streamingController.Tick(observerCx, observerCy);
}
if (_cameraController is null || _input is null) return;
if (!_cameraController.IsFlyMode) return;
@ -1141,7 +1007,7 @@ public sealed class GameWindow : IDisposable
if (_cameraController is not null)
{
_terrain?.Draw(_cameraController.Active);
_staticMesh?.Draw(_cameraController.Active, _entities);
_staticMesh?.Draw(_cameraController.Active, _worldState.Entities);
}
}
@ -1249,6 +1115,10 @@ public sealed class GameWindow : IDisposable
private void OnClosing()
{
// Phase A.1: join the streamer worker thread before tearing down GL
// state. The worker may still be processing a load job that references
// _dats; Dispose cancels the token and waits up to 2s for the thread.
_streamer?.Dispose();
_liveSession?.Dispose();
_staticMesh?.Dispose();
_textureCache?.Dispose();