fix(portal): synchronize destination presentation state

This commit is contained in:
Erik 2026-07-16 21:17:13 +02:00
parent 4b1bceefbb
commit e95f55f25b
42 changed files with 2815 additions and 288 deletions

View file

@ -1133,7 +1133,7 @@ public sealed class PlayerMovementController
body: _body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: LocalEntityId);
_body.Position = resolved.Position;
_body.CommitTransitionPosition(resolved.CellId, resolved.Position);
PhysicsObjUpdate.CommitSetPositionTransition(
_body,
resolved.InContact,
@ -1660,7 +1660,7 @@ public sealed class PlayerMovementController
bool prevContact = _body.InContact;
bool prevOnWalkable = _body.OnWalkable;
_body.Position = resolveResult.Position;
_body.CommitTransitionPosition(resolveResult.CellId, resolveResult.Position);
if (physicsTickRan)
{
_prevPhysicsPos = oldTickEndPos;

View file

@ -2714,8 +2714,8 @@ public sealed class GameWindow : IDisposable
_streamer.Start();
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
enqueueUnload: _streamer.EnqueueUnload,
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation),
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
drainCompletions: _streamer.DrainCompletions,
applyTerrain: ApplyLoadedTerrain,
state: _worldState,
@ -2743,10 +2743,12 @@ public sealed class GameWindow : IDisposable
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
_envCellRenderer?.RemoveLandblock(id); // Phase A8
},
demoteNearLayer: DemoteLoadedLandblockToTerrain,
// #138: restore retained server objects when a landblock reloads
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the
// objects it thinks we still know, so we re-project them ourselves.
onLandblockLoaded: RehydrateServerEntitiesForLandblock);
onLandblockLoaded: RehydrateServerEntitiesForLandblock,
ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin);
// A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
@ -7058,14 +7060,12 @@ public sealed class GameWindow : IDisposable
}
/// <summary>
/// worldReady for the TAS transit: is the player's teleport destination resident so we
/// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell
/// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around
/// the player, <see cref="TeleportNearRingRadius"/>) being registered — not just the
/// single destination landblock — so portal space exits onto a loaded, collidable world
/// (the player's cell-walk can root into neighbour cells; no walk-through-walls and no
/// "only one landblock loaded"). The streaming controller priority-applies that same ring,
/// so it flips fast. An impossible claim (indoor cell id outside the dat's NumCells)
/// worldReady for the TAS transit: is the player's teleport destination BOTH rendered
/// and collidable so we can materialize? Retail crosses this edge after its blocking cell
/// load. acdream's async equivalent must join the render publication barrier
/// (<see cref="StreamingController.IsRenderNeighborhoodResident"/>) with physics
/// residency. Indoor destinations require the center render landblock + EnvCell; outdoor
/// destinations require both domains for the priority near ring. An impossible claim
/// returns true so the TAS stops holding and the forced placement surfaces the failure
/// loudly rather than holding forever.
/// </summary>
@ -7073,9 +7073,14 @@ public sealed class GameWindow : IDisposable
{
if (IsSpawnClaimUnhydratable(destCell)) return true;
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
int renderRadius = indoor ? 0 : TeleportNearRingRadius;
bool renderReady = _streamingController?.IsRenderNeighborhoodResident(
destCell,
renderRadius) == true;
return indoor
? _physicsEngine.IsSpawnCellReady(destCell)
: _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
? renderReady && _physicsEngine.IsSpawnCellReady(destCell)
: renderReady
&& _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
}
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
@ -7560,6 +7565,13 @@ public sealed class GameWindow : IDisposable
completedEnvCells);
}
private void EnsureEnvCellMeshesAfterPin(
AcDream.App.Rendering.Wb.EnvCellLandblockBuild build)
{
if (_wbMeshAdapter?.MeshManager is { } meshManager)
AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(build, meshManager);
}
/// <summary>
/// Pre-reads (under the worker's <c>_datLock</c>) every dat object
/// <see cref="ApplyLoadedTerrainLocked"/> consumes, so the apply makes zero
@ -7682,15 +7694,13 @@ public sealed class GameWindow : IDisposable
(lbY - _liveCenterY) * 192f,
0f);
// Per-landblock id namespace. Landblock IDs are formatted 0xXXYYFFFF
// where XX = landblock X coord (bits 24-31), YY = Y coord (bits 16-23).
// Both must go into our ID so landblocks don't collide.
// Format: 0x80 | XX | YY | local_index(8 bits) = 0x80XXYY_II.
// 256 slots per landblock is enough (SceneryGenerator caps ~200).
// Per-landblock id namespace. The top nibble identifies procedural
// scenery and the full X/Y bytes plus a 12-bit counter prevent dense
// DAT-generated landblocks from overflowing into a neighbour's range.
// See ProceduralSceneryIdAllocator for the audited 0x8XXYYIII layout.
uint lbXByte = (lb.LandblockId >> 24) & 0xFFu;
uint lbYByte = (lb.LandblockId >> 16) & 0xFFu;
uint sceneryIdBase = 0x80000000u | (lbXByte << 16) | (lbYByte << 8);
uint localIndex = 0;
uint sceneryCounter = 0;
foreach (var spawn in spawns)
{
@ -7836,13 +7846,12 @@ public sealed class GameWindow : IDisposable
}
}
if (localIndex > 0xFFu)
throw new InvalidDataException(
$"Landblock 0x{lb.LandblockId:X8} exceeds the 256-entry procedural scenery id namespace.");
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = sceneryIdBase + localIndex++,
Id = AcDream.Core.World.ProceduralSceneryIdAllocator.Allocate(
lbXByte,
lbYByte,
ref sceneryCounter),
SourceGfxObjOrSetupId = spawn.ObjectId,
Position = new System.Numerics.Vector3(localX, localY, finalZ) + lbOffset,
Rotation = spawn.Rotation,
@ -8135,6 +8144,31 @@ public sealed class GameWindow : IDisposable
return result;
}
/// <summary>
/// Retire the Near-only presentation and collision layer while preserving
/// the terrain slot/surface used by Far streaming. StreamingController calls
/// this before GpuWorldState drops static entities so owner-based resources
/// can be released exactly once.
/// </summary>
private void DemoteLoadedLandblockToTerrain(uint landblockId)
{
if (_worldState.TryGetLandblock(landblockId, out var landblock))
{
foreach (var entity in landblock!.Entities)
{
if (entity.ServerGuid != 0)
continue;
_lightingSink?.UnregisterOwner(entity.Id);
_translucencyFades.ClearEntity(entity.Id);
}
}
_physicsEngine.DemoteLandblockToTerrain(landblockId);
_cellVisibility.RemoveLandblock((landblockId >> 16) & 0xFFFFu);
_buildingRegistries.Remove(landblockId & 0xFFFF0000u);
_envCellRenderer?.RemoveLandblock(landblockId);
}
/// <summary>
/// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick
/// whenever a new landblock's terrain + entities are ready for GPU upload.
@ -8568,7 +8602,7 @@ public sealed class GameWindow : IDisposable
// 0xC0XXYY00+n layout per LandblockLoader.cs:55. Their BSP
// collision covers the whole structure; the mesh-AABB-fallback
// path below is for canopy-only-BSP procedural scenery
// (0x80XXYY00+n) and produces a redundant 1.5m-clamped
// (0x8XXYYIII) and produces a redundant 1.5m-clamped
// invisible disc at the stab's mesh origin — the user-reported
// "thin air" collision inside cottages. Gate the fallback to
// exclude stabs. Spec:
@ -10746,20 +10780,15 @@ public sealed class GameWindow : IDisposable
&& _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false)
continue;
// Retail UpdatePositionInternal keeps a narrow Hidden path alive:
// no PartArray animation and no physics integration, but
// PositionManager::adjust_offset plus the manager tail still run.
// Scripts and particles tick in their shared passes below.
if (serverGuid != 0 && _liveEntities?.IsHidden(serverGuid) == true)
{
// Hidden suppresses the PartArray/mesh, not the effect owner.
// Remote PositionManager composition already ran in the
// unconditional live-entity pass; publish again for the local
// player and for retained objects without remote motion.
// Frozen part-local poses remain unchanged until UnHide.
_effectPoses.UpdateRoot(ae.Entity);
continue;
}
// Retail Hidden suppresses PartArray TIME ADVANCE, not part-pose
// composition. set_hidden -> HandleEnterWorld can move CSequence's
// cursor from a finished link/action to the first cyclic node; the
// next hidden CPhysicsObj::UpdateObjectInternal (0x005156B0)
// performs UpdatePositionInternal and then reaches set_frame ->
// CPartArray::UpdateParts, publishing that newly-current pose before
// the zero-time Hidden PES creates its silhouette particles.
bool hidden = serverGuid != 0
&& _liveEntities?.IsHidden(serverGuid) == true;
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
// The server broadcasts UpdatePosition at ~5-10Hz for distant
@ -10774,7 +10803,8 @@ public sealed class GameWindow : IDisposable
// runaway when the sequencer's velocity and the server's reality
// disagree (e.g. server is rubber-banding the entity). Retail
// uses a similar clamp at PhysicsObj::IsInterpolationComplete.
if (ae.Sequencer is not null
if (!hidden
&& ae.Sequencer is not null
&& serverGuid != 0
&& serverGuid != _playerServerGuid
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm)
@ -10839,7 +10869,9 @@ public sealed class GameWindow : IDisposable
rmDiag.LastSeqStateLogTime = nowSec;
}
}
seqFrames = ae.Sequencer.Advance(dt);
seqFrames = hidden
? ae.Sequencer.SampleCurrentPose()
: ae.Sequencer.Advance(dt);
// Capture hooks now, but deliver them only after every final
// part transform and equipped-child root has been published.
@ -10847,21 +10879,25 @@ public sealed class GameWindow : IDisposable
// CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
// reads this frame's pose, never the previous frame's pose.
// AnimationDone/UseTime remain paired with the deferred drain.
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
if (!hidden)
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
}
else
{
// Legacy path (entities without a MotionTable / sequencer).
int span = ae.HighFrame - ae.LowFrame;
if (span <= 0) continue;
ae.CurrFrame += dt * ae.Framerate;
if (ae.CurrFrame > ae.HighFrame)
if (span <= 0 && !hidden) continue;
if (!hidden)
{
float over = ae.CurrFrame - ae.LowFrame;
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
ae.CurrFrame += dt * ae.Framerate;
if (ae.CurrFrame > ae.HighFrame)
{
float over = ae.CurrFrame - ae.LowFrame;
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
}
else if (ae.CurrFrame < ae.LowFrame)
ae.CurrFrame = ae.LowFrame;
}
else if (ae.CurrFrame < ae.LowFrame)
ae.CurrFrame = ae.LowFrame;
}
int partCount = ae.PartTemplate.Count;
@ -12357,8 +12393,8 @@ public sealed class GameWindow : IDisposable
_streamer.Start();
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
enqueueUnload: _streamer.EnqueueUnload,
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation),
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
drainCompletions: _streamer.DrainCompletions,
applyTerrain: ApplyLoadedTerrain,
state: _worldState,
@ -12381,7 +12417,9 @@ public sealed class GameWindow : IDisposable
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
_envCellRenderer?.RemoveLandblock(id); // Phase A8
});
},
demoteNearLayer: DemoteLoadedLandblockToTerrain,
ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin);
_streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame;
Console.WriteLine(

View file

@ -11,12 +11,11 @@ namespace AcDream.App.Rendering.Wb;
/// <para>
/// <b>Key composition:</b> entries are keyed by the tuple
/// <c>(EntityId, LandblockHint)</c>, NOT by <c>EntityId</c> alone. Issue #53
/// uncovered that <c>entity.Id</c> is NOT globally unique across all
/// static-entity hydration paths: scenery (<c>0x80XXYY00 + localIndex</c>)
/// and interior cells (<c>0x40XXYY00 + localCounter</c>, X-byte fixed
/// 2026-06-11 — it used to be discarded entirely, #119) overflow at >256
/// items per landblock, wrapping into the <c>lbY</c> byte and producing
/// cross-LB collisions in dense forest/urban LBs outside Holtburg. Keying
/// uncovered that older hydration IDs were not globally unique across all
/// static-entity paths: their former byte-aligned counters overflowed at
/// more than 256 items per landblock and wrapped into the <c>lbY</c> byte.
/// The current <c>0x8XXYYIII</c> scenery and <c>0x4XXYYIII</c> interior
/// allocators reserve 12-bit counters and fail before aliasing. Keying
/// by the tuple is correct-by-construction ONLY when the hint identifies the
/// entity's OWNING landblock — callers must derive it via
/// <c>WbDrawDispatcher.ResolveCacheLandblockHint</c> (the entity's
@ -64,8 +63,8 @@ internal sealed class EntityClassificationCache
/// <summary>
/// Look up an entity's cached classification. Keyed by both
/// <paramref name="entityId"/> AND <paramref name="landblockHint"/> to
/// disambiguate entities whose Ids collide across landblocks (e.g.,
/// scenery's <c>0x80LLBB00 + localIndex</c> overflow at >256 items/LB).
/// preserve defensive isolation if a future hydration path introduces an
/// entity-id collision across landblocks.
/// Returns <c>true</c> with the entry on hit; <c>false</c> with
/// <paramref name="entry"/> set to <c>null</c> on miss.
/// </summary>

View file

@ -9,4 +9,19 @@ public interface IWbMeshAdapter
{
void IncrementRefCount(ulong id);
void DecrementRefCount(ulong id);
/// <summary>
/// Pins render data whose CPU preparation is owned by a specialized
/// pipeline (for example synthetic EnvCell geometry). Unlike ordinary
/// registration, production implementations must not start a generic
/// GfxObj decode for this id.
/// </summary>
void PinPreparedRenderData(ulong id) => IncrementRefCount(id);
/// <summary>
/// True once the mesh has crossed the render-thread upload barrier and is
/// available to draw. The default keeps lifecycle-only test doubles
/// source-compatible; production adapters override it.
/// </summary>
bool IsRenderDataReady(ulong id) => true;
}

View file

@ -15,13 +15,14 @@ namespace AcDream.App.Rendering.Wb;
/// <para>
/// On load: walks the landblock's atlas-tier entities, collects unique
/// GfxObj ids from their <c>MeshRefs</c>, calls
/// <c>IncrementRefCount</c> per id. Snapshots the id-set per landblock so
/// unload can match the load 1:1.
/// <c>IncrementRefCount</c> per id, and pins each specialized EnvCell geometry
/// id without starting generic GfxObj decode. Snapshots both id-sets per
/// landblock so unload can match the load 1:1.
/// </para>
///
/// <para>
/// On unload: looks up the snapshot, calls <c>DecrementRefCount</c> per id,
/// drops the snapshot. Unknown / never-loaded landblocks no-op.
/// On unload: looks up both snapshots, calls <c>DecrementRefCount</c> per id,
/// drops the snapshots. Unknown / never-loaded landblocks no-op.
/// </para>
///
/// <para>
@ -34,11 +35,9 @@ namespace AcDream.App.Rendering.Wb;
/// </para>
///
/// <para>
/// Thread safety: the underlying <see cref="IWbMeshAdapter"/> implementation
/// uses <c>ConcurrentDictionary</c>, so the streaming worker thread may call
/// this safely. The internal snapshot dictionary is NOT thread-safe and must
/// be called from a single streaming thread (the same thread that fires
/// AddLandblock / RemoveLandblock events).
/// Thread safety: the internal snapshots are intentionally not synchronized.
/// <see cref="AcDream.App.Streaming.GpuWorldState"/> invokes every load, unload, and readiness query
/// on the owning render/update thread.
/// </para>
/// </summary>
public sealed class LandblockSpawnAdapter
@ -48,6 +47,10 @@ public sealed class LandblockSpawnAdapter
// Maps landblock id → unique GfxObj ids registered for that landblock.
// Written on load, read+cleared on unload. Single-threaded (streaming worker).
private readonly Dictionary<uint, HashSet<ulong>> _idsByLandblock = new();
// EnvCell shells are prepared through PrepareEnvCellGeomMeshDataAsync rather
// than generic GfxObj loading, but still require explicit lifetime pins.
// Keep their synthetic ids separate so registration uses the no-decode pin.
private readonly Dictionary<uint, HashSet<ulong>> _additionalReadinessIdsByLandblock = new();
public LandblockSpawnAdapter(IWbMeshAdapter adapter)
{
@ -61,7 +64,9 @@ public sealed class LandblockSpawnAdapter
/// unique atlas-tier GfxObj id that has not already been registered for
/// this landblock.
/// </summary>
public void OnLandblockLoaded(LoadedLandblock landblock)
public void OnLandblockLoaded(
LoadedLandblock landblock,
IEnumerable<ulong>? additionalReadinessIds = null)
{
System.ArgumentNullException.ThrowIfNull(landblock);
@ -80,14 +85,51 @@ public sealed class LandblockSpawnAdapter
{
_idsByLandblock[landblock.LandblockId] = unique;
foreach (var id in unique) _adapter.IncrementRefCount(id);
return;
}
else
{
foreach (var id in unique)
{
if (registered.Add(id))
_adapter.IncrementRefCount(id);
}
}
foreach (var id in unique)
if (!_additionalReadinessIdsByLandblock.TryGetValue(
landblock.LandblockId,
out var additional))
{
if (registered.Add(id))
_adapter.IncrementRefCount(id);
additional = new HashSet<ulong>();
_additionalReadinessIdsByLandblock[landblock.LandblockId] = additional;
}
if (additionalReadinessIds is not null)
{
foreach (var id in additionalReadinessIds)
if (additional.Add(id))
_adapter.PinPreparedRenderData(id);
}
}
/// <summary>
/// True only after every render mesh required by this published landblock
/// is drawable. This includes both ref-counted static GfxObjs and EnvCell
/// shell geometry prepared by the independent indoor pipeline.
/// </summary>
public bool IsLandblockRenderReady(uint landblockId)
{
if (!_idsByLandblock.TryGetValue(landblockId, out var registered)
|| !_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional))
{
return false;
}
foreach (var id in registered)
if (!_adapter.IsRenderDataReady(id))
return false;
foreach (var id in additional)
if (!_adapter.IsRenderDataReady(id))
return false;
return true;
}
/// <summary>
@ -99,6 +141,9 @@ public sealed class LandblockSpawnAdapter
{
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
foreach (var id in unique) _adapter.DecrementRefCount(id);
if (_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional))
foreach (var id in additional) _adapter.DecrementRefCount(id);
_idsByLandblock.Remove(landblockId);
_additionalReadinessIdsByLandblock.Remove(landblockId);
}
}

View file

@ -0,0 +1,117 @@
using System.Collections.Concurrent;
using AcDream.Content;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Deduplicated CPU-to-GPU staging queue. One object id may be queued or
/// in-flight at a time; a retry retains ownership until upload succeeds or
/// exhausts its retry budget.
/// </summary>
internal sealed class MeshUploadStagingQueue
{
private readonly ConcurrentQueue<ObjectMeshData> _queue = new();
private readonly ConcurrentDictionary<ulong, byte> _ownedIds = new();
public bool Stage(ObjectMeshData data)
{
if (!_ownedIds.TryAdd(data.ObjectId, 0))
return false;
data.UploadAttempts = 0;
_queue.Enqueue(data);
return true;
}
public bool TryDequeue(out ObjectMeshData? data) => _queue.TryDequeue(out data);
public void Requeue(ObjectMeshData data) => _queue.Enqueue(data);
public void Complete(ulong objectId) => _ownedIds.TryRemove(objectId, out _);
}
/// <summary>
/// Logical render-data ownership, deliberately independent from GPU upload
/// existence. Completing or repeating an upload never manufactures an owner;
/// only a live entity/landblock pin changes this count.
/// </summary>
internal sealed class MeshOwnershipCounter
{
private readonly ConcurrentDictionary<ulong, int> _counts = new();
public int Acquire(ulong id) => _counts.AddOrUpdate(id, 1, (_, count) => count + 1);
public int Release(ulong id) => _counts.AddOrUpdate(id, 0, (_, count) => Math.Max(0, count - 1));
public int Count(ulong id) => _counts.TryGetValue(id, out int count) ? count : 0;
public bool IsOwned(ulong id) => Count(id) > 0;
/// <summary>
/// Reports whether freshly uploaded data has a live owner. This is a query,
/// not an acquire: upload existence and logical ownership are independent.
/// </summary>
public bool MarkUploadComplete(ulong id) => IsOwned(id);
public bool Remove(ulong id) => _counts.TryRemove(id, out _);
}
/// <summary>
/// Bounded prepared-mesh cache. A cache hit is not merely a CPU return value:
/// when GPU render data is absent it re-stages that mesh for upload.
/// </summary>
internal sealed class CpuMeshUploadCache
{
private readonly Dictionary<ulong, ObjectMeshData> _data = new();
private readonly LinkedList<ulong> _lru = new();
private readonly int _capacity;
public CpuMeshUploadCache(int capacity)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
_capacity = capacity;
}
public bool TryGetAndStage(
ulong id,
MeshUploadStagingQueue staging,
out ObjectMeshData? data)
{
lock (_data)
{
if (!_data.TryGetValue(id, out data))
return false;
_lru.Remove(id);
_lru.AddLast(id);
staging.Stage(data);
return true;
}
}
public void Store(ObjectMeshData data)
{
lock (_data)
{
if (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity)
{
ulong oldest = _lru.First!.Value;
_lru.RemoveFirst();
_data.Remove(oldest);
}
_data[data.ObjectId] = data;
_lru.Remove(data.ObjectId);
_lru.AddLast(data.ObjectId);
}
}
public void Clear()
{
lock (_data)
{
_data.Clear();
_lru.Clear();
}
}
}

View file

@ -105,7 +105,7 @@ namespace AcDream.App.Rendering.Wb {
public bool IsDisposed { get; private set; }
private readonly ConcurrentDictionary<ulong, ObjectRenderData> _renderData = new();
private readonly ConcurrentDictionary<ulong, int> _usageCount = new();
private readonly MeshOwnershipCounter _ownership = new();
private readonly ConcurrentDictionary<ulong, (Vector3 Min, Vector3 Max)?> _boundsCache = new();
private readonly ConcurrentDictionary<ulong, Task<ObjectMeshData?>> _preparationTasks = new();
@ -119,12 +119,10 @@ namespace AcDream.App.Rendering.Wb {
private readonly Dictionary<(int Width, int Height, TextureFormat Format), List<TextureAtlasManager>> _globalAtlases = new();
// CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT)
private readonly Dictionary<ulong, ObjectMeshData> _cpuMeshCache = new();
private readonly LinkedList<ulong> _cpuLruList = new();
private readonly int _maxCpuCacheSize = 100;
private readonly CpuMeshUploadCache _cpuMeshCache;
private readonly ConcurrentQueue<ObjectMeshData> _stagedMeshData = new();
public ConcurrentQueue<ObjectMeshData> StagedMeshData => _stagedMeshData;
private readonly MeshUploadStagingQueue _stagedMeshData = new();
/// <summary>#125: how many times a failed GL upload is re-staged before
/// giving up loudly. Small — a transient GL error clears on the next
@ -141,17 +139,28 @@ namespace AcDream.App.Rendering.Wb {
/// on re-prepare) and gives up loudly past <see cref="MaxUploadRetries"/>.
/// </summary>
public bool UploadOrRequeue(ObjectMeshData meshData) {
if (UploadMeshData(meshData) is not null)
if (UploadMeshData(meshData) is not null) {
_stagedMeshData.Complete(meshData.ObjectId);
return false; // success (incl. legitimate 0-vertex → empty render data)
if (HasRenderData(meshData.ObjectId))
}
if (HasRenderData(meshData.ObjectId)) {
_stagedMeshData.Complete(meshData.ObjectId);
return false; // raced to present by another path
}
meshData.UploadAttempts++;
if (meshData.UploadAttempts < MaxUploadRetries)
return true; // re-stage for next frame
_stagedMeshData.Complete(meshData.ObjectId);
Console.WriteLine($"[up-retry] 0x{meshData.ObjectId:X10} upload failed {meshData.UploadAttempts}x — giving up (was the #125 silent sticky drop; a GL error is being surfaced, not hidden)");
return false;
}
internal bool TryDequeueStagedMeshData(out ObjectMeshData? data) =>
_stagedMeshData.TryDequeue(out data);
internal void RequeueStagedMeshData(ObjectMeshData data) =>
_stagedMeshData.Requeue(data);
public GlobalMeshBuffer? GlobalBuffer { get; }
private readonly bool _useModernRendering;
@ -167,7 +176,8 @@ namespace AcDream.App.Rendering.Wb {
// did — immediate enqueue, surviving a later throw in the same
// Prepare* call. ConcurrentQueue.Enqueue is thread-safe for the
// extractor's up-to-4 concurrent decode workers.
_extractor = new MeshExtractor(_dats, _logger, data => _stagedMeshData.Enqueue(data));
_cpuMeshCache = new CpuMeshUploadCache(_maxCpuCacheSize);
_extractor = new MeshExtractor(_dats, _logger, data => _stagedMeshData.Stage(data));
_useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless;
if (_useModernRendering) {
GlobalBuffer = new GlobalMeshBuffer(_graphicsDevice.GL);
@ -180,7 +190,7 @@ namespace AcDream.App.Rendering.Wb {
/// </summary>
public ObjectRenderData? GetRenderData(ulong id) {
if (_renderData.TryGetValue(id, out var data)) {
_usageCount.AddOrUpdate(id, 1, (_, count) => count + 1);
_ownership.Acquire(id);
if (data.IsSetup) {
foreach (var (partId, _) in data.SetupParts) {
@ -223,7 +233,7 @@ namespace AcDream.App.Rendering.Wb {
/// Increment reference count for an object (e.g. when a landblock starts using it).
/// </summary>
public void IncrementRefCount(ulong id) {
_usageCount.AddOrUpdate(id, 1, (_, count) => count + 1);
_ownership.Acquire(id);
lock (_lruList) {
_lruList.Remove(id);
}
@ -258,7 +268,7 @@ namespace AcDream.App.Rendering.Wb {
/// Decrement reference count and unload GPU resources if no longer needed.
/// </summary>
public void DecrementRefCount(ulong id) {
var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1);
var newCount = _ownership.Release(id);
if (newCount <= 0) {
// Instead of unloading, move to LRU
lock (_lruList) {
@ -272,8 +282,8 @@ namespace AcDream.App.Rendering.Wb {
/// Decrement reference count and unload if no longer needed.
/// </summary>
public void ReleaseRenderData(ulong id) {
if (_usageCount.TryGetValue(id, out var count) && count > 0) {
var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1);
if (_ownership.IsOwned(id)) {
var newCount = _ownership.Release(id);
if (newCount <= 0) {
// Instead of unloading, move to LRU
lock (_lruList) {
@ -291,9 +301,9 @@ namespace AcDream.App.Rendering.Wb {
var idToEvict = _lruList.First!.Value;
_lruList.RemoveFirst();
if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) {
if (!_ownership.IsOwned(idToEvict)) {
UnloadObject(idToEvict);
_usageCount.TryRemove(idToEvict, out _);
_ownership.Remove(idToEvict);
}
}
}
@ -309,18 +319,15 @@ namespace AcDream.App.Rendering.Wb {
var idToEvict = _lruList.First!.Value;
_lruList.RemoveFirst();
if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) {
if (!_ownership.IsOwned(idToEvict)) {
UnloadObject(idToEvict);
_usageCount.TryRemove(idToEvict, out _);
_ownership.Remove(idToEvict);
}
}
}
// Also clear CPU mesh cache
lock (_cpuMeshCache) {
_cpuMeshCache.Clear();
_cpuLruList.Clear();
}
_cpuMeshCache.Clear();
}
public struct EnvCellGeomRequest {
@ -338,13 +345,8 @@ namespace AcDream.App.Rendering.Wb {
if (IsDisposed || HasRenderData(geomId)) return Task.FromResult<ObjectMeshData?>(null);
// Check CPU cache first
lock (_cpuMeshCache) {
if (_cpuMeshCache.TryGetValue(geomId, out var cachedData)) {
_cpuLruList.Remove(geomId);
_cpuLruList.AddLast(geomId);
return Task.FromResult<ObjectMeshData?>(cachedData);
}
}
if (_cpuMeshCache.TryGetAndStage(geomId, _stagedMeshData, out var cachedData))
return Task.FromResult(cachedData);
// Return existing task if already running or queued
if (_preparationTasks.TryGetValue(geomId, out var existing)) {
@ -381,13 +383,8 @@ namespace AcDream.App.Rendering.Wb {
if (IsDisposed || HasRenderData(id)) return Task.FromResult<ObjectMeshData?>(null);
// Check CPU cache first
lock (_cpuMeshCache) {
if (_cpuMeshCache.TryGetValue(id, out var cachedData)) {
_cpuLruList.Remove(id);
_cpuLruList.AddLast(id);
return Task.FromResult<ObjectMeshData?>(cachedData);
}
}
if (_cpuMeshCache.TryGetAndStage(id, _stagedMeshData, out var cachedData))
return Task.FromResult(cachedData);
// Return existing task if already running or queued
if (_preparationTasks.TryGetValue(id, out var existing)) {
@ -476,16 +473,8 @@ namespace AcDream.App.Rendering.Wb {
data = _extractor.PrepareMeshData(id, isSetup, CancellationToken.None);
}
if (data != null) {
lock (_cpuMeshCache) {
if (_cpuMeshCache.Count >= _maxCpuCacheSize) {
var oldest = _cpuLruList.First!.Value;
_cpuLruList.RemoveFirst();
_cpuMeshCache.Remove(oldest);
}
_cpuMeshCache[id] = data;
_cpuLruList.AddLast(id);
}
_stagedMeshData.Enqueue(data);
_cpuMeshCache.Store(data);
_stagedMeshData.Stage(data);
}
tcs.TrySetResult(data);
}
@ -538,26 +527,7 @@ namespace AcDream.App.Rendering.Wb {
try {
if (_renderData.TryGetValue(meshData.ObjectId, out var existing)) {
_preparationTasks.TryRemove(meshData.ObjectId, out _);
if (existing.IsSetup) {
foreach (var (partId, _) in existing.SetupParts) {
IncrementRefCount(partId);
lock (_lruList) {
_lruList.Remove(partId);
}
}
}
else {
// Increment ref counts for all textures in this GfxObj
foreach (var batch in existing.Batches) {
if (batch.Atlas != null) {
batch.Atlas.AddTexture(batch.Key, Array.Empty<byte>());
}
}
}
IncrementRefCount(meshData.ObjectId);
lock (_lruList) {
_lruList.Remove(meshData.ObjectId);
}
UpdateLruAfterUpload(meshData.ObjectId);
return existing;
}
@ -588,7 +558,6 @@ namespace AcDream.App.Rendering.Wb {
MemorySize = 1024 // Small overhead for the setup itself
};
_renderData.TryAdd(meshData.ObjectId, data);
IncrementRefCount(meshData.ObjectId);
_currentGpuMemory += data.MemorySize;
// Increment ref counts for all parts
@ -596,6 +565,8 @@ namespace AcDream.App.Rendering.Wb {
IncrementRefCount(partId);
}
UpdateLruAfterUpload(meshData.ObjectId);
return data;
}
@ -619,15 +590,12 @@ namespace AcDream.App.Rendering.Wb {
renderData.DIDDegrade = meshData.DIDDegrade;
renderData.SelectionSphere = meshData.SelectionSphere;
_renderData.TryAdd(meshData.ObjectId, renderData);
IncrementRefCount(meshData.ObjectId);
_currentGpuMemory += renderData.MemorySize;
UpdateLruAfterUpload(meshData.ObjectId);
// Clear texture data after upload to save RAM
foreach (var batchList in meshData.TextureBatches.Values) {
foreach (var batch in batchList) {
batch.TextureData = Array.Empty<byte>();
}
}
// Keep the bounded CPU cache's texture payload intact. GPU LRU
// eviction may need to upload this same prepared mesh again;
// clearing these bytes made a cache hit produce blank textures.
return renderData;
}
catch (Exception ex) {
@ -636,6 +604,14 @@ namespace AcDream.App.Rendering.Wb {
}
}
private void UpdateLruAfterUpload(ulong id) {
lock (_lruList) {
_lruList.Remove(id);
if (!_ownership.MarkUploadComplete(id))
_lruList.AddLast(id);
}
}
/// <summary>
/// Gets bounding box for an object (for frustum culling).
/// </summary>

View file

@ -151,6 +151,15 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
return _meshManager.TryGetRenderData(id);
}
/// <inheritdoc/>
public bool IsRenderDataReady(ulong id)
{
// An uninitialized adapter owns no render pipeline, so it must not
// manufacture a readiness wait that can never complete. The modern
// production path is always initialized at startup.
return _isUninitialized || _meshManager?.TryGetRenderData(id) is not null;
}
/// <inheritdoc/>
public void IncrementRefCount(ulong id)
{
@ -199,6 +208,16 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_meshManager.DecrementRefCount(id);
}
/// <inheritdoc/>
public void PinPreparedRenderData(ulong id)
{
if (_isUninitialized || _meshManager is null) return;
// EnvCell geometry has its own schema-aware preparation request.
// Only establish lifecycle ownership here; IncrementRefCount's normal
// generic GfxObj preparation would decode the synthetic id incorrectly.
_meshManager.IncrementRefCount(id);
}
/// <summary>
/// #128 self-heal (2026-06-11): re-request a mesh load at the POINT OF
/// USE. Registration-time re-arming was insufficient — a preparation
@ -252,14 +271,16 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
// a genuine defect surfaces loudly instead of the old silent sticky drop.
List<ObjectMeshData>? requeue = null;
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
while (_meshManager!.TryDequeueStagedMeshData(out var meshData))
{
if (meshData is null)
continue;
if (_meshManager.UploadOrRequeue(meshData))
(requeue ??= new()).Add(meshData);
}
if (requeue is not null)
foreach (var m in requeue)
_meshManager.StagedMeshData.Enqueue(m);
_meshManager.RequeueStagedMeshData(m);
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
var pendingBefore = texProbe

View file

@ -67,6 +67,7 @@ public sealed class GpuWorldState
}
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
private readonly Dictionary<uint, LandblockStreamTier> _tierByLandblock = new();
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
/// <summary>
@ -75,6 +76,12 @@ public sealed class GpuWorldState
/// Drained into <see cref="_loaded"/> in <see cref="AddLandblock"/>.
/// </summary>
private readonly Dictionary<uint, List<WorldEntity>> _pendingByLandblock = new();
// Far-to-near promotion can complete before the base far landblock is
// published. Preserve its independently-prepared EnvCell geometry ids
// alongside the parked entity layer so the later AddLandblock transaction
// cannot open the render gate before those shells upload.
private readonly Dictionary<uint, HashSet<ulong>> _pendingRenderIdsByLandblock = new();
private readonly HashSet<uint> _pendingNearTierLandblocks = new();
/// <summary>
/// Entities that must survive landblock unloads (e.g. the player character).
@ -96,6 +103,14 @@ public sealed class GpuWorldState
public event Action<uint, bool>? LiveProjectionVisibilityChanged;
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
public bool IsNearTier(uint landblockId) =>
_tierByLandblock.TryGetValue(landblockId, out var tier)
&& tier == LandblockStreamTier.Near;
public bool IsNearTierOrPending(uint landblockId) =>
IsNearTier(landblockId) || _pendingNearTierLandblocks.Contains(landblockId);
public bool IsRenderReady(uint landblockId) =>
_loaded.ContainsKey(landblockId)
&& (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true);
public bool IsLiveEntityVisible(uint serverGuid) =>
serverGuid != 0 && _visibleLiveGuids.Contains(serverGuid);
@ -204,8 +219,17 @@ public sealed class GpuWorldState
public int PersistentGuidCount => _persistentGuids.Count;
public int PendingVisibilityTransitionCount => _visibilityTransitions.Count;
public void AddLandblock(LoadedLandblock landblock)
public void AddLandblock(
LoadedLandblock landblock,
IEnumerable<ulong>? additionalRenderIds = null,
LandblockStreamTier tier = LandblockStreamTier.Near)
{
// A stale Far completion must never replace a newer Near entity layer.
// StreamingController normally filters it before render-side apply;
// keep the state boundary independently monotonic as well.
if (tier == LandblockStreamTier.Far && IsNearTier(landblock.LandblockId))
return;
// If pending live entities have been waiting for this landblock,
// merge them into the LoadedLandblock record before storing. The
// record's Entities field is IReadOnlyList; we replace the whole
@ -222,9 +246,29 @@ public sealed class GpuWorldState
_pendingByLandblock.Remove(landblock.LandblockId);
}
HashSet<ulong>? mergedRenderIds = additionalRenderIds is null
? null
: new HashSet<ulong>(additionalRenderIds);
if (_pendingRenderIdsByLandblock.Remove(landblock.LandblockId, out var pendingRenderIds))
{
mergedRenderIds ??= new HashSet<ulong>();
mergedRenderIds.UnionWith(pendingRenderIds);
}
bool pendingNear = _pendingNearTierLandblocks.Remove(landblock.LandblockId);
if (pendingNear
|| (_tierByLandblock.TryGetValue(landblock.LandblockId, out var currentTier)
&& currentTier == LandblockStreamTier.Near))
{
tier = LandblockStreamTier.Near;
}
_loaded[landblock.LandblockId] = landblock;
_tierByLandblock[landblock.LandblockId] = tier;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
_wbSpawnAdapter.OnLandblockLoaded(
_loaded[landblock.LandblockId],
mergedRenderIds);
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
// LiveEntityRuntime owns activation for live objects. This static-only
@ -397,10 +441,13 @@ public sealed class GpuWorldState
}
_pendingByLandblock.Remove(canonical);
_pendingRenderIdsByLandblock.Remove(canonical);
_pendingNearTierLandblocks.Remove(canonical);
if (retainedLive.Count > 0)
_pendingByLandblock[canonical] = retainedLive;
_aabbs.Remove(canonical);
_tierByLandblock.Remove(canonical);
if (_loaded.Remove(canonical))
RebuildFlatView();
}
@ -612,6 +659,18 @@ public sealed class GpuWorldState
// protects against future callers that mirror live projection placement's
// cell-resolved-id pattern.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
// A promotion may have parked its Near layer before the Far base load
// exists. Demotion must retire that pending tier just as completely as
// an installed tier, while preserving live server projections.
_pendingNearTierLandblocks.Remove(canonical);
_pendingRenderIdsByLandblock.Remove(canonical);
if (_pendingByLandblock.TryGetValue(canonical, out var pending))
{
pending.RemoveAll(entity => entity.ServerGuid == 0);
if (pending.Count == 0)
_pendingByLandblock.Remove(canonical);
}
if (!_loaded.TryGetValue(canonical, out var lb)) return;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(canonical);
@ -642,12 +701,7 @@ public sealed class GpuWorldState
lb.LandblockId,
lb.Heightmap,
retainedLive);
if (_pendingByLandblock.TryGetValue(canonical, out var pending))
{
pending.RemoveAll(entity => entity.ServerGuid == 0);
if (pending.Count == 0)
_pendingByLandblock.Remove(canonical);
}
_tierByLandblock[canonical] = LandblockStreamTier.Far;
RebuildFlatView();
}
@ -664,7 +718,10 @@ public sealed class GpuWorldState
/// callers may pass cell-resolved ids and they will key correctly.
/// </para>
/// </summary>
public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList<WorldEntity> entities)
public bool AddEntitiesToExistingLandblock(
uint landblockId,
IReadOnlyList<WorldEntity> entities,
IEnumerable<ulong>? additionalRenderIds = null)
{
// A.5 T14 follow-up: canonicalize for symmetry with live projection placement.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
@ -677,14 +734,25 @@ public sealed class GpuWorldState
_pendingByLandblock[canonical] = bucket;
}
bucket.AddRange(entities);
return;
_pendingNearTierLandblocks.Add(canonical);
if (additionalRenderIds is not null)
{
if (!_pendingRenderIdsByLandblock.TryGetValue(canonical, out var renderIds))
{
renderIds = new HashSet<ulong>();
_pendingRenderIdsByLandblock[canonical] = renderIds;
}
renderIds.UnionWith(additionalRenderIds);
}
return false;
}
var merged = new List<WorldEntity>(lb.Entities.Count + entities.Count);
merged.AddRange(lb.Entities);
merged.AddRange(entities);
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
_tierByLandblock[canonical] = LandblockStreamTier.Near;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]);
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical], additionalRenderIds);
// C.1.5b: fire DefaultScript for each promoted dat-hydrated entity.
// All entities arriving via this path are atlas-tier by construction
@ -697,6 +765,7 @@ public sealed class GpuWorldState
}
RebuildFlatView();
return true;
}
private void RebuildFlatView()

View file

@ -12,8 +12,13 @@ namespace AcDream.App.Streaming;
/// </summary>
public abstract record LandblockStreamJob(uint LandblockId)
{
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
public sealed record Load(
uint LandblockId,
LandblockStreamJobKind Kind,
ulong Generation = 0) : LandblockStreamJob(LandblockId);
public sealed record Unload(
uint LandblockId,
ulong Generation = 0) : LandblockStreamJob(LandblockId);
/// <summary>
/// Control job: drop every queued (not-yet-started) Load from the worker's
@ -32,7 +37,7 @@ public abstract record LandblockStreamJob(uint LandblockId)
/// an unload notification (tells the render thread to release GPU state
/// for this landblock id).
/// </summary>
public abstract record LandblockStreamResult(uint LandblockId)
public abstract record LandblockStreamResult(uint LandblockId, ulong Generation)
{
/// <summary>
/// A landblock load completed. <see cref="Tier"/> distinguishes Far
@ -43,15 +48,17 @@ public abstract record LandblockStreamResult(uint LandblockId)
uint LandblockId,
LandblockStreamTier Tier,
LandblockBuild Build,
LandblockMeshData MeshData
) : LandblockStreamResult(LandblockId)
LandblockMeshData MeshData,
ulong Generation = 0
) : LandblockStreamResult(LandblockId, Generation)
{
public Loaded(
uint landblockId,
LandblockStreamTier tier,
LoadedLandblock landblock,
LandblockMeshData meshData)
: this(landblockId, tier, new LandblockBuild(landblock), meshData)
LandblockMeshData meshData,
ulong generation = 0)
: this(landblockId, tier, new LandblockBuild(landblock), meshData, generation)
{
}
@ -69,14 +76,16 @@ public abstract record LandblockStreamResult(uint LandblockId)
public sealed record Promoted(
uint LandblockId,
LandblockBuild Build,
LandblockMeshData MeshData
) : LandblockStreamResult(LandblockId)
LandblockMeshData MeshData,
ulong Generation = 0
) : LandblockStreamResult(LandblockId, Generation)
{
public Promoted(
uint landblockId,
LoadedLandblock landblock,
LandblockMeshData meshData)
: this(landblockId, new LandblockBuild(landblock), meshData)
LandblockMeshData meshData,
ulong generation = 0)
: this(landblockId, new LandblockBuild(landblock), meshData, generation)
{
}
@ -84,8 +93,13 @@ public abstract record LandblockStreamResult(uint LandblockId)
public IReadOnlyList<WorldEntity> Entities => Landblock.Entities;
}
public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId);
public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId);
public sealed record Failed(
uint LandblockId,
string Error,
ulong Generation = 0) : LandblockStreamResult(LandblockId, Generation);
public sealed record Unloaded(
uint LandblockId,
ulong Generation = 0) : LandblockStreamResult(LandblockId, Generation);
/// <summary>
/// The worker loop itself crashed with an unhandled exception. Not tied
@ -94,5 +108,5 @@ public abstract record LandblockStreamResult(uint LandblockId)
/// than retrying a single landblock later. LandblockId is 0 by
/// convention; readers should pattern-match on the type, not the id.
/// </summary>
public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0);
public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0, 0);
}

View file

@ -143,23 +143,26 @@ public sealed class LandblockStreamer : IDisposable
/// <see cref="LandblockStreamResult.Loaded"/> (or
/// <see cref="LandblockStreamResult.Failed"/>) to the outbox.
/// </summary>
public void EnqueueLoad(uint landblockId, LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear)
public void EnqueueLoad(
uint landblockId,
LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear,
ulong generation = 0)
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("ENQ", landblockId, $"kind={kind}");
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind));
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind, generation));
}
/// <summary>
/// Non-blocking enqueue. The worker posts a
/// <see cref="LandblockStreamResult.Unloaded"/> to the outbox.
/// </summary>
public void EnqueueUnload(uint landblockId)
public void EnqueueUnload(uint landblockId, ulong generation = 0)
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId, generation));
}
/// <summary>
@ -335,7 +338,7 @@ public sealed class LandblockStreamer : IDisposable
if (build is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "LandblockLoader.Load returned null"));
load.LandblockId, "LandblockLoader.Load returned null", load.Generation));
break;
}
var lb = build.Landblock;
@ -345,18 +348,18 @@ public sealed class LandblockStreamer : IDisposable
if (promotedMesh is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "buildMeshOrNull returned null"));
load.LandblockId, "buildMeshOrNull returned null", load.Generation));
break;
}
_outbox.Writer.TryWrite(new LandblockStreamResult.Promoted(
load.LandblockId, build, promotedMesh));
load.LandblockId, build, promotedMesh, load.Generation));
break;
}
var mesh = _buildMeshOrNull(load.LandblockId, lb);
if (mesh is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "buildMeshOrNull returned null"));
load.LandblockId, "buildMeshOrNull returned null", load.Generation));
break;
}
var tier = load.Kind == LandblockStreamJobKind.LoadFar
@ -375,17 +378,19 @@ public sealed class LandblockStreamer : IDisposable
build = new LandblockBuild(lb);
}
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
load.LandblockId, tier, build, mesh));
load.LandblockId, tier, build, mesh, load.Generation));
}
catch (Exception ex)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, ex.ToString()));
load.LandblockId, ex.ToString(), load.Generation));
}
break;
case LandblockStreamJob.Unload unload:
_outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded(unload.LandblockId));
_outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded(
unload.LandblockId,
unload.Generation));
break;
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
using AcDream.Core.World;
@ -17,11 +18,12 @@ namespace AcDream.App.Streaming;
/// </summary>
public sealed class StreamingController
{
private readonly Action<uint, LandblockStreamJobKind> _enqueueLoad;
private readonly Action<uint> _enqueueUnload;
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
private readonly Action<uint, ulong> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LandblockBuild, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _removeTerrain;
private readonly Action<uint>? _demoteNearLayer;
private readonly Action? _clearPendingLoads;
/// <summary>
@ -34,8 +36,16 @@ public sealed class StreamingController
/// tests that don't exercise re-hydration.
/// </summary>
private readonly Action<uint>? _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<EnvCellLandblockBuild>? _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
@ -127,6 +137,43 @@ public sealed class StreamingController
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
public int DeferredApplyBacklog => _deferredApply.Count;
/// <summary>
/// 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.
/// </summary>
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; }
@ -135,8 +182,40 @@ public sealed class StreamingController
// Completions that were drained past a priority item get buffered here
// so they still apply over subsequent frames without loss.
private readonly List<LandblockStreamResult> _deferredApply = new();
public StreamingController(
Action<uint, LandblockStreamJobKind, ulong> enqueueLoad,
Action<uint, ulong> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
Action<LandblockBuild, LandblockMeshData> applyTerrain,
GpuWorldState state,
int nearRadius,
int farRadius,
Action<uint>? removeTerrain = null,
Action<uint>? demoteNearLayer = null,
Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? 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;
}
/// <summary>
/// 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.
/// </summary>
internal StreamingController(
Action<uint, LandblockStreamJobKind> enqueueLoad,
Action<uint> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
@ -145,19 +224,44 @@ public sealed class StreamingController
int nearRadius,
int farRadius,
Action<uint>? removeTerrain = null,
Action<uint>? demoteNearLayer = null,
Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null)
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null)
: this(
(id, kind, _) => enqueueLoad(id, kind),
(id, _) => enqueueUnload(id),
drainCompletions,
applyTerrain,
state,
nearRadius,
farRadius,
removeTerrain,
demoteNearLayer,
clearPendingLoads,
onLandblockLoaded,
ensureEnvCellMeshes)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_clearPendingLoads = clearPendingLoads;
_onLandblockLoaded = onLandblockLoaded;
_state = state;
NearRadius = nearRadius;
FarRadius = farRadius;
}
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);
}
/// <summary>
@ -263,18 +367,18 @@ public sealed class StreamingController
{
_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);
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) _state.RemoveEntitiesFromLandblock(id);
foreach (var id in diff.ToUnload) _enqueueUnload(id);
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);
}
}
@ -293,10 +397,12 @@ public sealed class StreamingController
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);
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.
@ -306,7 +412,9 @@ public sealed class StreamingController
// 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);
EnqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
else if (!_state.IsNearTier(centerId))
EnqueueLoad(centerId, LandblockStreamJobKind.PromoteToNear);
}
/// <summary>
@ -321,7 +429,7 @@ public sealed class StreamingController
// 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);
if (id != _collapsedCenter) EnqueueUnload(id);
}
/// <summary>Chebyshev distance in landblock cells between two landblock ids.</summary>
@ -354,16 +462,17 @@ public sealed class StreamingController
$"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);
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);
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadNear);
foreach (var id in boot.ToLoadFar)
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
rebuilt.MarkResidentFromBootstrap();
_region = rebuilt;
}
@ -383,6 +492,9 @@ public sealed class StreamingController
public void ForceReloadWindow()
{
_collapsed = false;
AdvanceGeneration();
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
var ids = new List<uint>(_state.LoadedLandblockIds);
ForceReloadCount++; // [FRAME-DIAG] churn counter
@ -424,6 +536,11 @@ public sealed class StreamingController
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
@ -449,19 +566,120 @@ public sealed class StreamingController
/// </summary>
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);
_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);
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
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:
@ -479,6 +697,31 @@ public sealed class StreamingController
}
}
private void ApplyAsFar(LandblockBuild completedBuild, LandblockMeshData meshData)
{
var completed = completedBuild.Landblock;
var farLandblock = new LoadedLandblock(
completed.LandblockId,
completed.Heightmap,
Array.Empty<WorldEntity>(),
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;
/// <summary>
/// Returns the landblock id associated with <paramref name="result"/>.
/// For <see cref="LandblockStreamResult.WorkerCrashed"/> this is 0 by

View file

@ -171,6 +171,25 @@ public sealed class StreamingRegion
internal static uint EncodeLandblockIdForTest(int lbX, int lbY)
=> EncodeLandblockId(lbX, lbY);
/// <summary>
/// Returns the tier currently owned by this region after bootstrap/recenter
/// hysteresis has been applied. Late worker completions use this to avoid
/// resurrecting a Near layer after the region has demoted it to Far.
/// </summary>
internal bool TryGetDesiredTier(uint landblockId, out LandblockStreamTier tier)
{
if (_tierResidence.TryGetValue(landblockId, out var residence))
{
tier = residence == TierResidence.Near
? LandblockStreamTier.Near
: LandblockStreamTier.Far;
return true;
}
tier = default;
return false;
}
/// <summary>
/// Two-tier recenter: computes the 5-list diff per Phase A.5 spec §4.2.
/// Hysteresis: NearRadius+2 for Near→Far demote; FarRadius+2 for Far→null