using System;
using System.Collections.Generic;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
///
/// Called once per frame from GameWindow.OnUpdate. Owns the
/// and uses delegates into
/// so tests can inject fakes. All work
/// happens on the render thread; the streamer itself is background.
///
///
/// Threading: not thread-safe. All calls must happen on the render thread.
///
///
public sealed class StreamingController
{
private readonly Action _enqueueLoad;
private readonly Action _enqueueUnload;
private readonly Func> _drainCompletions;
private readonly Action _applyTerrain;
private readonly Action? _removeTerrain;
private readonly Action? _demoteNearLayer;
private readonly Action? _clearPendingLoads;
///
/// #138: fired after a landblock's entity layer (re)loads — both the full
/// path (initial / dungeon-exit
/// expand) and the path
/// (Far→Near). GameWindow wires it to re-hydrate server objects whose
/// render entities were dropped by the collapse/demote but whose parsed
/// spawns it still retains. Receives the canonical landblock id. Null in
/// tests that don't exercise re-hydration.
///
private readonly Action? _onLandblockLoaded;
// Invoked only after LandblockSpawnAdapter has pinned every synthetic
// EnvCell geometry id. This re-arms the schema-aware preparation path if
// an unowned cached mesh was evicted between worker schedule and publish.
private readonly Action? _ensureEnvCellMeshes;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
// Hard streaming boundaries advance this token. Worker completions carry
// the generation captured at enqueue time, so an old overlapping load or
// unload cannot mutate the replacement window after a portal recenter.
private ulong _generation;
// True while streaming is collapsed to the single dungeon landblock the
// player stands in (the dungeon gate, #133 FPS). AC dungeons have NO
// adjacent landblocks — neighbors are unrelated ocean-grid dungeons that
// are never visible, so we stop loading the 25×25 window entirely.
private bool _collapsed;
// The dungeon landblock id we collapsed onto. Once collapsed we key the
// gate on this STABLE landblock, not the per-frame insideDungeon signal:
// CurrCell can momentarily resolve to null/outdoor mid-frame, and gating
// expand on that flicker thrashes collapse↔expand (reload storms + a light
// leak). We only expand when the observer actually moves to a different
// landblock (teleport/portal out).
private uint _collapsedCenter;
///
/// Near-tier radius (LBs from observer that load full detail: terrain +
/// scenery + entities). Set at construction; readable thereafter.
///
///
/// Mutating after the first has no effect — the
/// internal snapshots both radii on its
/// constructor. Treat as init-only post-Tick.
///
public int NearRadius { get; }
///
/// Far-tier radius (LBs from observer that load terrain only). Set at
/// construction; readable thereafter.
///
///
/// Mutating after the first has no effect — see .
///
public int FarRadius { get; }
///
/// Cap on completions drained per call. The cap is
/// the GPU upload budget for one frame: terrain mesh + per-entity GfxObj
/// sub-mesh uploads + texture uploads for one landblock take a few ms;
/// applying 25 of them in a single frame produces a memory spike
/// (observed: out-of-memory crash on the 5×5 first-frame load).
///
///
/// 4 is the original async-streamer value; it spreads a 5×5 first-frame
/// load over ~7 frames (~116ms at 60fps), which is below the human
/// perception threshold. Spawn races that previously dropped entities
/// while landblocks were in flight are now handled by
/// 's pending-spawn list, so spreading
/// completions doesn't lose any data.
///
///
public int MaxCompletionsPerFrame { get; set; } = 4;
///
/// #138: the teleport destination landblock id. When non-zero,
/// applies this landblock's
/// or
/// completion immediately
/// — even if it sits past the
/// position in the outbox — so the player can materialise at the
/// destination without waiting for every earlier-queued landblock to
/// drain first. Non-priority completions drained past it are buffered
/// in and applied over subsequent frames,
/// so no completions are lost and there is no GPU spike.
///
/// Set by GameWindow when a teleport starts; reset to 0
/// once the destination landblock has been applied (or when the
/// teleport destination changes).
///
public uint PriorityLandblockId { get; set; }
///
/// 2026-06-22: the radius (in landblocks, Chebyshev) around
/// that eager-applies
/// ahead of the per-frame budget. 0 (default) = only the single center landblock — the
/// original priority behaviour. A teleport sets this to the near ring so the player's
/// IMMEDIATE SURROUNDINGS (terrain + collision + scenery) are resident on arrival, not
/// just the one landblock they stand on. Without it, only the destination landblock
/// applies immediately and everything around it drains at ,
/// so the player arrives to a near-empty world (the "Fort Tethana only one landblock
/// loaded" symptom) and their cell-walk can't root into neighbour cells yet (the
/// transient walk-through-walls). The far ring still drains at the budget. The eager
/// apply runs while the portal viewport replaces the world, so the GPU spike is hidden.
///
public int PriorityRadius { get; set; }
// [FRAME-DIAG] (read by GameWindow's ACDREAM_WB_DIAG rollup): the standing
// deferred-LOAD backlog. A non-zero value during/after a teleport is the
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
public int DeferredApplyBacklog => _deferredApply.Count;
///
/// True once every in-bounds landblock in the requested Chebyshev ring has
/// crossed the render-thread publication barrier. Worker completion and
/// world-state registration are not sufficient: all static GfxObj and
/// EnvCell shell meshes must have completed their render-thread upload.
/// Portal-space exit uses this alongside physics residency so the world
/// cannot be revealed while its render slots are still absent.
///
public bool IsRenderNeighborhoodResident(uint cellOrLandblockId, int radius)
{
if (radius < 0)
throw new ArgumentOutOfRangeException(nameof(radius));
// LandDefs::InboundValidCellId validates both map axes and the low-word
// class (outdoor cell, EnvCell, or canonical landblock sentinel).
if (!AcDream.Core.Physics.LandDefs.InboundValidCellId(cellOrLandblockId))
return false;
int cx = (int)((cellOrLandblockId >> 24) & 0xFFu);
int cy = (int)((cellOrLandblockId >> 16) & 0xFFu);
for (int dx = -radius; dx <= radius; dx++)
for (int dy = -radius; dy <= radius; dy++)
{
int nx = cx + dx;
int ny = cy + dy;
// Match PhysicsEngine.IsNeighborhoodTerrainResident: the outer
// 0xFF coordinate has no loadable neighbour beyond it.
if (nx < 0 || nx > 254 || ny < 0 || ny > 254)
continue;
uint canonical = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu;
if (!_state.IsNearTier(canonical) || !_state.IsRenderReady(canonical))
return false;
}
return true;
}
// [FRAME-DIAG]: how many times ForceReloadWindow has fired (each one drops +
// re-uploads the WHOLE window) and the landblock count it dropped last time.
public int ForceReloadCount { get; private set; }
public int LastForceReloadDropCount { get; private set; }
// Completions that were drained past a priority item get buffered here
// so they still apply over subsequent frames without loss.
private readonly List _deferredApply = new();
public StreamingController(
Action enqueueLoad,
Action enqueueUnload,
Func> drainCompletions,
Action applyTerrain,
GpuWorldState state,
int nearRadius,
int farRadius,
Action? removeTerrain = null,
Action? demoteNearLayer = null,
Action? clearPendingLoads = null,
Action? onLandblockLoaded = null,
Action? ensureEnvCellMeshes = null)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_demoteNearLayer = demoteNearLayer;
_clearPendingLoads = clearPendingLoads;
_onLandblockLoaded = onLandblockLoaded;
_ensureEnvCellMeshes = ensureEnvCellMeshes;
_state = state;
NearRadius = nearRadius;
FarRadius = farRadius;
}
///
/// Compatibility constructor for deterministic tests and callers that do
/// not own an asynchronous worker. Production must use the generation-aware
/// overload above so hard-recenter results can be rejected by incarnation.
///
internal StreamingController(
Action enqueueLoad,
Action enqueueUnload,
Func> drainCompletions,
Action applyTerrain,
GpuWorldState state,
int nearRadius,
int farRadius,
Action? removeTerrain = null,
Action? demoteNearLayer = null,
Action? clearPendingLoads = null,
Action? onLandblockLoaded = null,
Action? ensureEnvCellMeshes = null)
: this(
(id, kind, _) => enqueueLoad(id, kind),
(id, _) => enqueueUnload(id),
drainCompletions,
applyTerrain,
state,
nearRadius,
farRadius,
removeTerrain,
demoteNearLayer,
clearPendingLoads,
onLandblockLoaded,
ensureEnvCellMeshes)
{
}
private void EnqueueLoad(uint id, LandblockStreamJobKind kind) =>
_enqueueLoad(id, kind, _generation);
private void EnqueueUnload(uint id) => _enqueueUnload(id, _generation);
private void DemoteLandblock(uint id)
{
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
// App-owned Near resources need the still-present static entity list
// for exact light/translucency owner cleanup. Retire them before
// GpuWorldState drops the static layer and its render pins.
_demoteNearLayer?.Invoke(canonical);
_state.RemoveEntitiesFromLandblock(canonical);
}
private void AdvanceGeneration()
{
_generation = unchecked(_generation + 1);
}
///
/// Advance one frame. /
/// are landblock coordinates (0..255) of the current viewer — the camera
/// in offline mode, the server-sent player position in live.
///
/// Two-tier model (Phase A.5 T13):
///
/// - → enqueue LoadFar (terrain only, no entities)
/// - → enqueue LoadNear (terrain + entities)
/// - → enqueue PromoteToNear (entity layer for already-loaded terrain)
/// - → drop entities on render thread immediately (terrain stays)
/// - → enqueue full unload
///
///
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
{
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
if (_collapsed)
{
// Hysteresis. Cases:
// - Still in the SAME dungeon landblock → hold (sweep stragglers).
// - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon)
// → re-collapse onto it.
// - CurrCell flickered null but the player hasn't gone anywhere: the
// observer landblock reverts to the position-derived value, which for a
// dungeon is only ever the ADJACENT off-by-one landblock (negative cell-
// local Y). Hold — never expand on an adjacent flicker.
// - Genuinely left to a DISTANT landblock (portal/teleport out, always far
// from the ocean-grid dungeon block) → expand.
if (insideDungeon && centerId != _collapsedCenter)
EnterDungeonCollapse(observerCx, observerCy, centerId);
else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1)
ExitDungeonExpand(observerCx, observerCy);
else
SweepCollapsed();
}
else if (insideDungeon)
{
EnterDungeonCollapse(observerCx, observerCy, centerId);
}
else
{
NormalTick(observerCx, observerCy);
}
DrainAndApply();
}
///
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
/// has a chance to bootstrap the full 25×25 window. Called
/// from the login / teleport spawn path the instant the streaming center is
/// recentered onto a SEALED dungeon landblock.
///
/// The per-frame insideDungeon gate keys on the physics
/// CurrCell, which is only set once the player is PLACED — and placement
/// waits for the dungeon landblock to hydrate. So for the whole hydration window
/// (tens of seconds for a ~200-cell dungeon) the gate reads false and
/// would enqueue the ~24 unrelated ocean-grid neighbor
/// dungeons (+ ~19k entities each); the collapse then only mops them up after
/// placement. That mop-up is the 10→high FPS ramp users see at a dungeon login.
///
/// Pre-collapsing means the EXPENSIVE dungeon-neighbour window is never
/// enqueued. On teleport nothing is enqueued at all (this fires before the next
/// Tick recenters). On login a brief Holtburg outdoor window may be enqueued by the
/// frame-1 NormalTick (before the player's spawn arrives) and is immediately
/// cancelled by _clearPendingLoads here — cheap outdoor terrain, not the
/// ocean-grid dungeons, and a handful of already-dequeued loads get swept next
/// frame. Idempotent: a no-op when already collapsed onto this same landblock, so a
/// re-sent spawn or a same-frame double call costs nothing. Render-thread only,
/// same as .
///
public void InitializeKnownLoginCenter(int cx, int cy, bool isSealedDungeon)
{
// StreamingReadinessGate keeps the worker stopped until the player's
// real spawn supplies this center. There is therefore no guessed-center
// window to force-reload here. A sealed dungeon is the one special case:
// pin radius zero before the first Tick so no ocean-grid neighbours are
// ever enqueued.
if (isSealedDungeon)
PreCollapseToDungeon(cx, cy);
}
///
/// Pins streaming to a sealed dungeon before the first normal Tick.
///
public void PreCollapseToDungeon(int cx, int cy)
{
uint centerId = StreamingRegion.EncodeLandblockId(cx, cy);
if (_collapsed && _collapsedCenter == centerId) return;
EnterDungeonCollapse(cx, cy, centerId);
}
///
/// Outdoor / building-interior streaming — the original two-tier model.
///
private void NormalTick(int observerCx, int observerCy)
{
if (_region is null)
{
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
var bootstrap = _region.ComputeFirstTickDiff();
foreach (var id in bootstrap.ToLoadNear) EnqueueLoad(id, LandblockStreamJobKind.LoadNear);
foreach (var id in bootstrap.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
_region.MarkResidentFromBootstrap();
}
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
{
var diff = _region.RecenterTo(observerCx, observerCy);
foreach (var id in diff.ToPromote) EnqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
foreach (var id in diff.ToLoadNear) EnqueueLoad(id, LandblockStreamJobKind.LoadNear);
foreach (var id in diff.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
foreach (var id in diff.ToDemote) DemoteLandblock(id);
foreach (var id in diff.ToUnload) EnqueueUnload(id);
}
}
///
/// Dungeon-entry edge: cancel the in-flight window load, unload every
/// resident neighbor, and pin streaming to the player's single dungeon
/// landblock. Retail-faithful — AC dungeons have no adjacent landblocks
/// (ACE LandblockManager.GetAdjacentIDs returns empty for a dungeon);
/// the 25×25 window was pulling in ~129 unrelated ocean-grid dungeons and
/// their thousands of emitters (#133 FPS). Unloading them also tears down
/// their lights, shrinking the static-light set toward retail's ≤40.
///
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
{
if (!_collapsed || _collapsedCenter != centerId)
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
_collapsed = true;
_collapsedCenter = centerId;
AdvanceGeneration();
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
foreach (var id in _state.LoadedLandblockIds)
if (id != centerId) EnqueueUnload(id);
// Pin a radius-0 region so RecenterTo never re-expands while inside,
// and so the post-exit rebuild starts from a clean, consistent state.
_region = new StreamingRegion(cx, cy, 0, 0);
_region.MarkResidentFromBootstrap();
// The dungeon landblock itself must be (or become) loaded. If a prior
// ClearPendingLoads cancelled its queued load, re-enqueue it.
if (!_state.IsLoaded(centerId))
EnqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
else if (!_state.IsNearTier(centerId))
EnqueueLoad(centerId, LandblockStreamJobKind.PromoteToNear);
}
///
/// While collapsed, unload any landblock that finished loading after the
/// collapse edge — a Load the worker had already dequeued before the
/// control job took
/// effect. At steady state only the dungeon landblock is resident, so this
/// is a no-op.
///
private void SweepCollapsed()
{
// Always preserve the true dungeon landblock (_collapsedCenter), never the
// per-frame observer landblock — a CurrCell flicker must not unload the dungeon.
foreach (var id in _state.LoadedLandblockIds)
if (id != _collapsedCenter) EnqueueUnload(id);
}
/// Chebyshev distance in landblock cells between two landblock ids.
private static int ChebyshevLandblocks(uint a, uint b)
{
int ax = (int)((a >> 24) & 0xFFu), ay = (int)((a >> 16) & 0xFFu);
int bx = (int)((b >> 24) & 0xFFu), by = (int)((b >> 16) & 0xFFu);
return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
}
///
/// True when is the priority center or within
/// landblocks of it (Chebyshev) — i.e. inside the teleport
/// near ring that eager-applies. With the default radius 0
/// this reduces to an exact match on (the original
/// single-landblock priority behaviour).
///
private bool IsWithinPriorityRing(uint id)
=> PriorityLandblockId != 0u
&& ChebyshevLandblocks(id, PriorityLandblockId) <= PriorityRadius;
///
/// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full
/// two-tier window at the new center and unload anything resident from the
/// collapsed state that falls outside it.
///
private void ExitDungeonExpand(int observerCx, int observerCy)
{
Console.WriteLine(
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
$"(was collapsed on 0x{_collapsedCenter:X8})");
_collapsed = false;
AdvanceGeneration();
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
foreach (var id in _state.LoadedLandblockIds)
if (!rebuilt.Resident.Contains(id)) EnqueueUnload(id);
var boot = rebuilt.ComputeFirstTickDiff();
foreach (var id in boot.ToLoadNear)
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadNear);
foreach (var id in boot.ToLoadFar)
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
rebuilt.MarkResidentFromBootstrap();
_region = rebuilt;
}
///
/// 2026-06-22: an OUTDOOR teleport moved the render origin (_liveCenter). Every
/// resident terrain block was baked relative to the OLD origin, so any block that survives
/// an INCREMENTAL recenter — which happens on a NEARBY jump where the old and new windows
/// overlap, e.g. (170,168)→(169,180) — renders shifted by the jump distance: the confirmed
/// "terrain in the sky" arcs (the stale slots were all offset by exactly deltaLB×192).
/// SYNCHRONOUSLY drop every resident landblock (render slot + physics + state) so none
/// survives into the next frame stale, and no async unload can race a re-bake, then null
/// the region so the next re-bootstraps the WHOLE window fresh at
/// the new origin (the near ring is priority-applied during portal travel; the rest streams in).
/// A sealed-dungeon destination uses instead.
///
public void ForceReloadWindow()
{
_collapsed = false;
AdvanceGeneration();
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
var ids = new List(_state.LoadedLandblockIds);
ForceReloadCount++; // [FRAME-DIAG] churn counter
LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume
foreach (var id in ids)
{
_state.RemoveLandblock(id);
_removeTerrain?.Invoke(id); // frees the render slot + physics + cell registries
}
_region = null; // NormalTick re-creates + bootstraps the full window next frame
}
///
/// Apply streamed completions for this frame. LOADS (terrain mesh GPU uploads) are the
/// expensive part, so they are metered at to avoid a
/// GPU-upload spike; the overflow buffers in and drains over
/// subsequent frames. UNLOADS are cheap (they free GPU buffers — no upload) and are applied
/// IMMEDIATELY, never throttled: a teleport produces a whole window of unloads (~600), and
/// metering them at the load rate left the previous location's terrain resident for seconds
/// (rendering at its old world position as "floating terrain at the horizon"), and rapid
/// hops accumulated them faster than they cleared — a runaway resident count (951 observed
/// vs a 625 window) that also dragged FPS. Loads inside the teleport near ring
/// (, applied during portal travel) likewise bypass the budget so the
/// player materialises in a loaded world.
///
private void DrainAndApply()
{
// --- Step 1: drain the outbox in bounded chunks. Apply unloads + priority near-ring
// loads immediately; defer every other (budget-metered) load. Draining the whole
// outbox each frame (bounded by MaxDrainIterations) is what lets unloads flush
// promptly regardless of the load backlog — the throttle is on GPU UPLOADS, not on
// freeing them. The drain cap must NOT be gated behind the per-frame load budget
// (the prior version returned once the budget hit 0, stranding the outbox).
const int MaxDrainIterations = 64; // cap at 64 * MaxCompletionsPerFrame drained/frame
int iter = 0;
while (iter++ < MaxDrainIterations)
{
var chunk = _drainCompletions(MaxCompletionsPerFrame);
if (chunk.Count == 0) break;
foreach (var result in chunk)
{
// Reject obsolete hard-window work before it can occupy the
// metered deferred queue. ApplyResult repeats this invariant
// for direct callers and future drain paths.
if (IsStaleGeneration(result))
continue;
if (result is LandblockStreamResult.Unloaded
|| IsWithinPriorityRing(ResultLandblockId(result)))
ApplyResult(result); // free (unload) or behind-the-fade near ring
else
_deferredApply.Add(result); // a GPU-upload load — meter it in step 2
}
}
// --- Step 2: apply the deferred LOAD backlog at the per-frame budget (FIFO, so
// earlier-queued landblocks win). Caps GPU upload per frame so a big diff doesn't
// spike. _deferredApply now only ever holds loads — unloads were applied in step 1.
int budget = MaxCompletionsPerFrame;
int i = 0;
while (i < budget && i < _deferredApply.Count) { ApplyResult(_deferredApply[i]); i++; }
if (i > 0) _deferredApply.RemoveRange(0, i);
}
///
/// Apply a single with the full side-
/// effects: terrain upload, GPU state, and the re-hydration callback.
/// Extracted from the inline switch in the original DrainAndApply
/// so both the priority-hunt path and the normal drain path share it.
///
private void ApplyResult(LandblockStreamResult result)
{
if (IsStaleGeneration(result))
{
// A worker can finish one old load/unload after a hard recenter.
// Landblock id membership cannot distinguish overlapping windows;
// generation is the logical streaming incarnation boundary.
return;
}
if (result is LandblockStreamResult.Unloaded
&& _region?.TryGetDesiredTier(result.LandblockId, out _) == true)
{
// Normal recenter does not advance the hard-window generation. A
// rapid away->back can therefore re-own an id while its same-
// generation unload is already in the worker outbox. Current
// region ownership wins; the old unload must not tear it down.
return;
}
if (result is LandblockStreamResult.Loaded
or LandblockStreamResult.Promoted)
{
if (_region is null
|| !_region.TryGetDesiredTier(result.LandblockId, out var desiredTier))
{
// A hard recenter/collapse can leave one worker job already in
// flight. Its completion belongs to the old region and must not
// resurrect a landblock the new StreamingRegion never owns.
return;
}
bool isNearCompletion = result is LandblockStreamResult.Promoted
or LandblockStreamResult.Loaded { Tier: LandblockStreamTier.Near };
if (isNearCompletion && _state.IsNearTier(result.LandblockId))
{
// Normal recenter can enqueue a second high-priority Near job
// while an earlier one is already in flight. The streamer
// supersedes Far/Unload work but intentionally does not dedupe
// high-priority jobs. Publication is idempotent at this owner:
// never append statics, replay defaults, or reapply the complete
// Near transaction to an already-Near landblock.
return;
}
if (desiredTier == LandblockStreamTier.Far && isNearCompletion)
{
// Recenter can demote a Near job before its first completion.
// StreamingRegion already considers that landblock Far, so it
// deliberately emits no second LoadFar job. If no terrain is
// resident yet, publish the completed heightmap/mesh as a Far
// result while discarding its entity and EnvCell layers. This
// is the same payload a fresh LoadFar would have produced and
// closes the in-flight Near -> desired Far lifecycle without a
// duplicate worker read or a permanent terrain hole.
if (!_state.IsLoaded(result.LandblockId))
{
switch (result)
{
case LandblockStreamResult.Loaded loaded:
ApplyAsFar(loaded.Build, loaded.MeshData);
break;
case LandblockStreamResult.Promoted promoted:
ApplyAsFar(promoted.Build, promoted.MeshData);
break;
}
}
return;
}
}
switch (result)
{
case LandblockStreamResult.Loaded loaded:
if (loaded.Tier == LandblockStreamTier.Far
&& _state.IsNearTier(loaded.LandblockId))
{
// This Far completion was queued before a newer Near load
// or promotion. Applying it would erase the entity/cell
// layer that now owns the landblock.
break;
}
_applyTerrain(loaded.Build, loaded.MeshData);
_state.AddLandblock(
loaded.Landblock,
loaded.Build.EnvCells?.Shells.Select(shell => shell.GeometryId),
loaded.Tier);
EnsureEnvCellMeshesAfterPin(loaded.Build);
// #138: after the landblock is in _loaded (so live projection
// hot-paths), restore any retained server objects ACE won't
// re-send. Fired AFTER AddLandblock, never before.
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);
break;
case LandblockStreamResult.Promoted promoted:
// PromoteToNear carries a complete build and mesh because the
// streamer deliberately lets it supersede a queued LoadFar. If
// that Far job never started, publish this as the real Near
// landblock; if the base is already resident, merge only the
// Near layer so existing live projections retain identity.
_applyTerrain(promoted.Build, promoted.MeshData);
var promotedRenderIds =
promoted.Build.EnvCells?.Shells.Select(shell => shell.GeometryId);
if (_state.IsLoaded(promoted.LandblockId))
{
_state.AddEntitiesToExistingLandblock(
promoted.LandblockId,
promoted.Entities,
promotedRenderIds);
}
else
{
_state.AddLandblock(
promoted.Landblock,
promotedRenderIds,
LandblockStreamTier.Near);
}
EnsureEnvCellMeshesAfterPin(promoted.Build);
_onLandblockLoaded?.Invoke(promoted.LandblockId);
break;
case LandblockStreamResult.Unloaded unloaded:
_state.RemoveLandblock(unloaded.LandblockId);
_removeTerrain?.Invoke(unloaded.LandblockId);
break;
case LandblockStreamResult.Failed failed:
Console.WriteLine(
$"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}");
break;
case LandblockStreamResult.WorkerCrashed crashed:
Console.WriteLine(
$"streaming: worker CRASHED: {crashed.Error}");
break;
}
}
private void ApplyAsFar(LandblockBuild completedBuild, LandblockMeshData meshData)
{
var completed = completedBuild.Landblock;
var farLandblock = new LoadedLandblock(
completed.LandblockId,
completed.Heightmap,
Array.Empty(),
PhysicsDatBundle.Empty);
var farBuild = new LandblockBuild(farLandblock);
_applyTerrain(farBuild, meshData);
_state.AddLandblock(farLandblock, tier: LandblockStreamTier.Far);
_onLandblockLoaded?.Invoke(farLandblock.LandblockId);
}
private void EnsureEnvCellMeshesAfterPin(LandblockBuild build)
{
if (build.EnvCells is { Shells.Length: > 0 } envCells)
_ensureEnvCellMeshes?.Invoke(envCells);
}
private bool IsStaleGeneration(LandblockStreamResult result) =>
result is not LandblockStreamResult.WorkerCrashed
&& result.Generation != _generation;
///
/// Returns the landblock id associated with .
/// For this is 0 by
/// convention (not tied to a specific landblock).
///
private static uint ResultLandblockId(LandblockStreamResult result) => result.LandblockId;
}