diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 261c158..2cddb8d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -26,7 +26,20 @@ public sealed class GameWindow : IDisposable private StaticMeshRenderer? _staticMesh; private Shader? _meshShader; private TextureCache? _textureCache; - private IReadOnlyList _entities = Array.Empty(); + + // 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? _surfaceCache; /// /// 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(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(); - - 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(); _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(allEntities.Count); - foreach (var e in allEntities) - { - var meshRefs = new List(); + // 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(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(e.SourceGfxObjOrSetupId); - if (setup is not null) - { - var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); - foreach (var mr in flat) - { - var gfx = _dats.Get(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(); - var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale); - - if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u) - { - var gfx = _dats.Get(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(spawn.ObjectId); - if (setup is not null) - { - var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); - foreach (var mr in flat) - { - var gfx = _dats.Get(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((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(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(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(); - if ((stab.Id & 0xFF000000u) == 0x01000000u) - { - var gfx = _dats.Get(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(stab.Id); - if (setup is not null) - { - var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); - foreach (var mr in flat) - { - var gfx = _dats.Get(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(_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 /// 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; } + /// + /// 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. + /// + 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(baseLoaded.Entities.Count); + foreach (var e in baseLoaded.Entities) + { + var meshRefs = new List(); + + if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u) + { + // Single GfxObj stab — identity part transform. + var gfx = _dats.Get(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(e.SourceGfxObjOrSetupId); + if (setup is not null) + { + var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + foreach (var mr in flat) + { + var gfx = _dats.Get(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); + } + + /// + /// 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. + /// + 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(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();