refactor(streaming): complete landblock presentation cutover

Remove the legacy GameWindow apply path and make the concrete render, physics, and static publishers the only production owner graph. Serialize full-window retirement with shared-origin teleport and session boundaries so old coordinate-frame resources cannot survive into a new world or login.
This commit is contained in:
Erik 2026-07-21 22:47:30 +02:00
parent 801d8a189c
commit c79d0a49da
12 changed files with 1373 additions and 402 deletions

View file

@ -291,8 +291,8 @@ public sealed class CellVisibility
/// <summary>
/// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a
/// landblock prefix. Used by <c>GameWindow.ApplyLoadedTerrainLocked</c> when
/// building the per-landblock <c>BuildingRegistry</c> — the per-frame
/// landblock prefix. Used by <c>LandblockRenderPublisher</c> when building
/// the per-landblock <c>BuildingRegistry</c> — the per-frame
/// <c>drainedCells</c> dict misses cells loaded on prior frames, so the
/// stamping loop in <see cref="Wb.BuildingLoader.Build"/> needs access to
/// every cell currently in the landblock to ensure <c>BuildingId</c> is set.

View file

@ -93,46 +93,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private int _terrainCpuSampleCursor;
private long _terrainLastDiagTick;
// [FRAME-DIAG] (ACDREAM_WB_DIAG=1): per-frame cost split for the FPS deep-dive.
// The crux this measures: terrain APPLY runs in OnUpdate (under _datLock —
// dat reads + physics/ShadowObjects/BSP registration + the terrain GPU
// upload), while the DRAW + the title-bar ms run in OnRender, so a heavy
// apply stalls the loop turn yet is INVISIBLE to the render-only ms. We
// accumulate apply cost across each OnUpdate and flush it next to the
// [TERRAIN-DIAG] draw cost. Samples are µs×100 over a rolling 256-ring,
// reusing the [TERRAIN-DIAG] median/p95 helpers. Removable (mirrors the
// probes stripped in 92e95be). Gated once at construction — zero cost off.
// [FRAME-DIAG]: retain only the render-thread entity upload distribution.
// Landblock publication timing/counts live with the focused publishers and
// are read from their typed snapshots when diagnostics flush.
private readonly bool _frameDiag = string.Equals(
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", System.StringComparison.Ordinal);
private long _applyAccumTicks; // apply CPU accumulated across this OnUpdate
private long _applyUploadAccumTicks; // terrain glBufferSubData sub-span this OnUpdate
private int _appliesThisUpdate; // # landblock LOADS applied this OnUpdate
private int _frameDiagMaxAppliesPerUpdate; // burst size over the 5s window
private readonly long[] _applyCpuSamples = new long[256]; // apply µs×100 / Update
private int _applyCpuSampleCursor;
private readonly long[] _applyUploadSamples = new long[256]; // terrain-upload µs×100 / Update
private int _applyUploadSampleCursor;
private readonly long[] _entityUploadSamples = new long[256]; // _wbMeshAdapter.Tick µs×100 / Render
private int _entityUploadSampleCursor;
// Apply CPU split into three sub-spans (to name WHICH part of the 40-110ms
// apply dominates): cell-build (EnvCell dat reads + physics surfaces +
// buildings), gfxobj-BSP (per-entity physics BSP cache), ShadowObjects+lights
// registration. Accumulated per OnUpdate via checkpoints in ApplyLoadedTerrainLocked.
private long _applyCellAccumTicks;
private long _applyBspAccumTicks;
private long _applyShadowAccumTicks;
private readonly long[] _applyCellSamples = new long[256];
private int _applyCellSampleCursor;
private readonly long[] _applyBspSamples = new long[256];
private int _applyBspSampleCursor;
private readonly long[] _applyShadowSamples = new long[256];
private int _applyShadowSampleCursor;
// Direct measure of the _datLock acquisition wait (the streaming worker holds
// the lock for its full per-landblock build, stalling this apply). Confirms the
// apply≫sub-span gap is lock contention, not in-lock work.
private long _applyLockWaitAccumTicks;
private readonly long[] _applyLockWaitSamples = new long[256];
private int _applyLockWaitSampleCursor;
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"),
"1",
System.StringComparison.Ordinal);
private readonly long[] _entityUploadSamples = new long[256];
private int _entityUploadSampleCursor;
// MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call
// per OnRender + three stage scopes. All logic lives in
@ -150,10 +119,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer;
private AcDream.App.Streaming.GpuWorldState _worldState = new();
private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher;
private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher;
private AcDream.App.Streaming.LandblockStaticPresentationPublisher?
_landblockStaticPresentationPublisher;
private AcDream.App.Streaming.LandblockPresentationPipeline?
_landblockPresentationPipeline;
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
private AcDream.App.Streaming.StreamingController? _streamingController;
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
@ -1630,9 +1597,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
// Build blending context from the terrain atlas. Stored as fields so
// ApplyLoadedTerrain (render-thread callback invoked per streamed lb)
// can call LandblockMesh.Build without re-deriving these every time.
// Build blending context from the terrain atlas. The worker-side
// LandblockBuildFactory captures this immutable table for each build.
var terrainTypeToLayerBytes = new Dictionary<uint, byte>(terrainAtlas.TerrainTypeToLayer.Count);
foreach (var kvp in terrainAtlas.TerrainTypeToLayer)
terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value;
@ -2410,7 +2376,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer(
_gl, _wbMeshAdapter!.MeshManager!, _envCellFrustum);
_envCellRenderer.Initialize(_meshShader!);
_landblockRenderPublisher = new AcDream.App.Streaming.LandblockRenderPublisher(
var landblockRenderPublisher = new AcDream.App.Streaming.LandblockRenderPublisher(
publishTerrain: (landblockId, meshData, origin) =>
_terrain!.AddLandblockWithMesh(landblockId, meshData, origin),
removeTerrain: landblockId => _terrain!.RemoveLandblock(landblockId),
@ -2422,16 +2388,34 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
build,
_wbMeshAdapter.MeshManager!),
removeEnvCells: _envCellRenderer.RemoveLandblock);
_landblockPhysicsPublisher =
var landblockPhysicsPublisher =
new AcDream.App.Streaming.LandblockPhysicsPublisher(
_physicsEngine,
_heightTable!);
_landblockStaticPresentationPublisher =
var landblockStaticPresentationPublisher =
new AcDream.App.Streaming.LandblockStaticPresentationPublisher(
_lightingSink!,
_translucencyFades,
_worldGameState,
_worldEvents);
var landblockRetirementOwner =
new AcDream.App.Streaming.LandblockPresentationRetirementOwner(
landblockRenderPublisher,
landblockPhysicsPublisher,
landblockStaticPresentationPublisher,
_lightingSink!,
_translucencyFades);
_landblockPresentationPipeline =
new AcDream.App.Streaming.LandblockPresentationPipeline(
landblockRenderPublisher,
landblockPhysicsPublisher,
landblockStaticPresentationPublisher,
_worldState,
landblockRetirementOwner,
onLandblockLoaded: loadedLandblockId =>
_liveEntityHydration!.OnLandblockLoaded(loadedLandblockId),
ensureEnvCellMeshes:
landblockRenderPublisher.PrepareAfterRenderPins);
_clipFrame ??= ClipFrame.NoClip();
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(
@ -2523,24 +2507,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
});
_streamer.Start();
var landblockRetirementOwner =
new AcDream.App.Streaming.LandblockPresentationRetirementOwner(
_landblockRenderPublisher!,
_landblockPhysicsPublisher!,
_landblockStaticPresentationPublisher!,
_lightingSink!,
_translucencyFades);
var landblockPresentationPipeline =
new AcDream.App.Streaming.LandblockPresentationPipeline(
_landblockRenderPublisher!,
_landblockPhysicsPublisher!,
_landblockStaticPresentationPublisher!,
_worldState,
landblockRetirementOwner,
onLandblockLoaded: loadedLandblockId =>
_liveEntityHydration!.OnLandblockLoaded(loadedLandblockId),
ensureEnvCellMeshes:
_landblockRenderPublisher!.PrepareAfterRenderPins);
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(
new AcDream.App.Streaming.LandblockBuildRequest(
@ -2559,7 +2525,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// Retained-object recovery runs after each
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the
// objects it still considers known.
presentationPipeline: landblockPresentationPipeline);
presentationPipeline: _landblockPresentationPipeline!);
_streamingOriginRecenter =
new AcDream.App.Streaming.StreamingOriginRecenterCoordinator(
_streamingController,
_liveWorldOrigin);
// A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
_worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator(
@ -3297,6 +3267,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private readonly TeleportViewPlaneController _teleportViewPlane = new();
private readonly AcDream.App.Streaming.TeleportTransitCoordinator<
AcDream.Core.Net.WorldSession.EntityPositionUpdate> _teleportTransit = new();
private AcDream.App.Streaming.StreamingOriginRecenterCoordinator?
_streamingOriginRecenter;
private System.Numerics.Vector3 _pendingTeleportPos;
private uint _pendingTeleportCell;
private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout)
@ -3365,22 +3337,21 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
System.Numerics.Vector3 newWorldPos;
if (streamingCenterChanges)
{
// #145: drop the stale STREAMING center landblock from the physics engine
// BEFORE recentering. The destination loads at world-offset (0,0) (the new
// center), but the prior center was ALSO loaded at offset (0,0) and its
// offset is never re-based — so the two overlap, and the Z-agnostic outdoor
// cell-snap resolves the player into the stale center. On a replacement this
// can be the abandoned first destination, not the unplaced player's source.
_landblockPhysicsPublisher!.RemoveLandblock(streamingOriginLandblockId);
_liveWorldOrigin.Recenter(lbX, lbY);
// #145: retire the complete old streaming window while its shared
// origin is still current. Destination streaming remains blocked
// until every retained presentation ticket converges. This prevents
// old terrain and collision, which were baked in the prior frame,
// from overlapping the destination after the origin changes.
var recenter = _streamingOriginRecenter
?? throw new InvalidOperationException(
"A teleport changed streaming center before the recenter coordinator was wired.");
recenter.Begin(
lbX,
lbY,
IsSealedDungeonCell(p.LandblockId));
newWorldPos = new System.Numerics.Vector3(
p.PositionX, p.PositionY, p.PositionZ);
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
_streamingController.PreCollapseToDungeon(lbX, lbY);
else
_streamingController?.ForceReloadWindow();
}
else
{
@ -3418,6 +3389,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// </summary>
private void ResetTeleportTransitState(bool clearSession = false)
{
bool originRecenterConverged =
_streamingOriginRecenter?.Reset(sessionEnding: clearSession) ?? true;
if (clearSession)
{
_teleportTransit.ClearSession();
@ -3440,6 +3413,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_streamingController.PriorityLandblockId = 0u;
_streamingController.PriorityRadius = 0;
}
if (clearSession && !originRecenterConverged)
{
throw new InvalidOperationException(
"The ending session's streaming-origin retirement has not converged.");
}
}
// #145: the LANDBLOCK-relative (cell-local) position used to SEED the player
@ -3801,183 +3780,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
$"live: AdminEnvirons sound cue = {name} " +
$"(0x{environChangeType:X2}) — audio binding pending");
}
/// <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.
/// Phase A.5 T12: the worker pre-builds <paramref name="meshData"/> off the
/// render thread via <see cref="AcDream.Core.Terrain.LandblockMesh.Build"/>;
/// this callback no longer pays that CPU cost.
/// Must only be called from the render thread.
/// </summary>
private void ApplyLoadedTerrain(AcDream.App.Streaming.LandblockBuild build,
AcDream.Core.Terrain.LandblockMeshData meshData)
{
var lb = build.Landblock;
if (_terrain is null || _dats is null) return;
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("APPLY", lb.LandblockId);
// datLock fix (2026-06-23): ApplyLoadedTerrainLocked now makes ZERO
// DatCollection calls — every dat it needs was pre-read by the streaming
// worker into lb.PhysicsDats. Its remaining mutations are update-thread-
// only (physics engine, ShadowObjects, renderer, _worldState) or already
// concurrent-safe (PhysicsDataCache is ConcurrentDictionary). _datLock's
// only cross-thread job was serializing DatCollection, so with no Get call
// here the lock is unnecessary — removing it eliminates the measured
// 24ms-median / 88ms-p95 lockwait stall (the FPS 30↔200 swing). The method
// keeps its historical "Locked" name; the worker still serializes its OWN
// dat reads on _datLock — only the apply stops contending for it.
// [FRAME-DIAG]: lockwait now measures ~0 — the before/after proof.
long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
if (_frameDiag)
_applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
try
{
ApplyLoadedTerrainLocked(build, meshData);
}
catch (AcDream.App.Streaming.StreamingMutationException)
{
throw;
}
catch (Exception error)
{
// This legacy body spans multiple concrete owners and cannot yet
// identify an exact failed suffix. Conservatively report that a
// prefix may have committed so the transaction coordinator never
// replays terrain/cells/collision/lights/plugin events. Slice-5
// checkpoints C-E replace this wrapper with per-owner receipts.
throw new AcDream.App.Streaming.StreamingMutationException(
$"Landblock 0x{lb.LandblockId:X8} presentation failed after an unknown commit point.",
mutationCommitted: true,
error);
}
if (_frameDiag)
{
_applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
_appliesThisUpdate++;
}
}
private void PublishLandblockStaticLightingBeforeCollision(
AcDream.Core.World.WorldEntity entity,
AcDream.Core.World.PhysicsDatBundle datBundle)
{
if (_lightingSink is null || _dats is null)
return;
uint sourceId = entity.SourceGfxObjOrSetupId;
if ((sourceId & 0xFF000000u) != 0x02000000u)
return;
if (!datBundle.Setups.TryGetValue(sourceId, out var setup)
|| setup.Lights.Count == 0)
{
return;
}
// A landblock can reapply without an unload (#168 ForceReloadWindow
// and Far-to-Near promotion). Static IDs are deterministic, so replace
// the prior owner state before registering the Setup lights.
uint effectOwnerId = entity.Id;
_lightingSink.UnregisterOwner(effectOwnerId);
_translucencyFades.ClearEntity(effectOwnerId);
var lights = AcDream.Core.Lighting.LightInfoLoader.Load(
setup,
ownerId: effectOwnerId,
entityPosition: entity.Position,
entityRotation: entity.Rotation,
cellId: entity.ParentCellId ?? 0u);
foreach (var light in lights)
_lightingSink.RegisterOwnedLight(light);
}
private void ApplyLoadedTerrainLocked(
AcDream.App.Streaming.LandblockBuild build,
AcDream.Core.Terrain.LandblockMeshData meshData)
{
AcDream.Core.World.LoadedLandblock landblock = build.Landblock;
if (_landblockRenderPublisher is null || _landblockPhysicsPublisher is null)
return;
AcDream.Core.World.PhysicsDatBundle datBundle =
landblock.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty;
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderBefore =
_landblockRenderPublisher.Diagnostics;
AcDream.App.Streaming.LandblockRenderPublication renderPublication =
_landblockRenderPublisher.BeginPublication(build, meshData);
System.Numerics.Vector3 origin = renderPublication.Origin;
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderPrefix =
_landblockRenderPublisher.Diagnostics;
if (_frameDiag)
{
long terrainTicks =
renderPrefix.TerrainPublishTicks - renderBefore.TerrainPublishTicks;
_applyUploadAccumTicks += terrainTicks;
_applyCellAccumTicks +=
renderPrefix.BeginPublishTicks
- renderBefore.BeginPublishTicks
- terrainTicks;
}
AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physicsBefore =
_landblockPhysicsPublisher.Diagnostics;
AcDream.App.Streaming.LandblockPhysicsPublication physicsPublication =
_landblockPhysicsPublisher.BeginPublication(renderPublication);
// Retail initializes buildings/visible cells before ordinary static
// collision and scripts. The two receipts retain one captured origin
// while each focused owner commits its exact stage.
_landblockRenderPublisher.CompletePublication(renderPublication);
_landblockPhysicsPublisher.CompletePublication(
physicsPublication,
entity => PublishLandblockStaticLightingBeforeCollision(
entity,
datBundle));
if (_frameDiag)
{
AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physicsAfter =
_landblockPhysicsPublisher.Diagnostics;
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderAfter =
_landblockRenderPublisher.Diagnostics;
long gfxCacheTicks =
physicsAfter.GfxCacheTicks - physicsBefore.GfxCacheTicks;
_applyCellAccumTicks +=
physicsAfter.BasePublishTicks - physicsBefore.BasePublishTicks;
_applyCellAccumTicks +=
renderAfter.CompletePublishTicks
- renderPrefix.CompletePublishTicks;
_applyBspAccumTicks += gfxCacheTicks;
_applyShadowAccumTicks +=
physicsAfter.CompletePublishTicks
- physicsBefore.CompletePublishTicks
- gfxCacheTicks;
}
long pluginStarted =
_frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
foreach (AcDream.Core.World.WorldEntity entity in landblock.Entities)
{
// Live objects publish through LiveEntityRuntime exactly once per
// accepted incarnation. This callback owns DAT statics only.
if (entity.ServerGuid != 0)
continue;
var snapshot =
new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
Id: entity.Id,
SourceId: entity.SourceGfxObjOrSetupId,
Position: entity.Position,
Rotation: entity.Rotation);
_worldGameState.Add(snapshot);
_worldEvents.FireEntitySpawned(snapshot);
}
if (_frameDiag)
{
_applyShadowAccumTicks +=
System.Diagnostics.Stopwatch.GetTimestamp() - pluginStarted;
}
}
private void OnUpdate(double dt)
{
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update);
@ -4009,31 +3811,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_physicsScriptGameTime += frameSeconds;
_scriptRunner?.PublishTime(_physicsScriptGameTime);
// [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick;
// flush the PREVIOUS OnUpdate's accumulated apply cost here (a clean per-frame
// boundary, independent of where in OnUpdate the applies landed) and reset.
if (_frameDiag)
{
if (_appliesThisUpdate > 0)
{
FrameDiagPush(_applyCpuSamples, ref _applyCpuSampleCursor, _applyAccumTicks);
FrameDiagPush(_applyUploadSamples, ref _applyUploadSampleCursor, _applyUploadAccumTicks);
FrameDiagPush(_applyCellSamples, ref _applyCellSampleCursor, _applyCellAccumTicks);
FrameDiagPush(_applyBspSamples, ref _applyBspSampleCursor, _applyBspAccumTicks);
FrameDiagPush(_applyShadowSamples, ref _applyShadowSampleCursor, _applyShadowAccumTicks);
FrameDiagPush(_applyLockWaitSamples, ref _applyLockWaitSampleCursor, _applyLockWaitAccumTicks);
if (_appliesThisUpdate > _frameDiagMaxAppliesPerUpdate)
_frameDiagMaxAppliesPerUpdate = _appliesThisUpdate;
}
_applyAccumTicks = 0;
_applyUploadAccumTicks = 0;
_applyCellAccumTicks = 0;
_applyBspAccumTicks = 0;
_applyShadowAccumTicks = 0;
_applyLockWaitAccumTicks = 0;
_appliesThisUpdate = 0;
}
// Phase A.1: advance the streaming controller FIRST so the initial
// landblocks are loaded into GpuWorldState before live-session
// CreateObject events drain. The earlier order (live tick first,
@ -4059,6 +3836,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// The world-geometry RENDER gate (IsLiveModeWaitingForLogin at the
// draw site) is unchanged — the pre-entry screen still shows sky
// only.
// Recenter cancellation/session-reset requests must keep converging
// even when ordinary streaming is temporarily gated off. This call is
// a no-op outside a pending teleport or session-boundary origin
// retirement transaction.
_streamingOriginRecenter?.Advance();
bool liveInWorld = _liveSessionController?.IsInWorld == true;
// #192: liveInWorld alone used to be sufficient here — but InWorld fires
// right after the login handshake, before the player's own spawn
@ -4078,10 +3861,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace)
{
// Teleport hold (#135): the local player position is frozen at the
// PRE-teleport spot, expressed in the OLD center frame, but
// _liveCenterX/_liveCenterY were already recentered onto the
// destination landblock (OnLivePositionUpdated). Follow the
// destination directly — the stale position-derived offset
// PRE-teleport spot, expressed in the OLD center frame. The
// origin coordinator commits _liveCenterX/Y to the destination
// only after old-window presentation retirement converges; the
// streaming controller remains gated until that edge. Follow
// the active shared origin directly — the stale position-derived offset
// (_liveCenterX + floor(frozenPos/192)) could land ≥2 landblocks off
// the dungeon and trip ExitDungeonExpand, re-streaming the very
// neighbor window the pre-collapse just suppressed. Correct for an
@ -4266,8 +4050,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
if (_teleportTransit.IsActive)
{
bool haveDest = _pendingTeleportCell != 0u;
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
if (haveDest && !ready)
bool originReady = _streamingOriginRecenter?.IsPending != true;
bool ready = haveDest
&& originReady
&& TeleportWorldReady(_pendingTeleportCell);
if (haveDest && originReady && !ready)
{
_teleportHoldSeconds += frameDelta;
if (_teleportHoldSeconds >= TeleportMaxHoldSeconds)
@ -5059,7 +4846,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// routes interior-root look-ins to its landscape-stage sub-pass
// (DrawBuildingLookIns); the root's own building self-excludes
// via the seed eye-side test.
foreach (var registry in _landblockRenderPublisher?.BuildingRegistries
foreach (var registry in _landblockPresentationPipeline?.BuildingRegistries
?? Array.Empty<AcDream.App.Rendering.Wb.BuildingRegistry>())
{
foreach (var b in registry.All())
@ -8712,43 +8499,38 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
$"loaded={_terrain?.LoadedSlots ?? 0} " +
$"capacity={_terrain?.CapacitySlots ?? 0}");
// [FRAME-DIAG]: the FPS-deep-dive per-frame cost split + leak/churn counters.
// apply_us = terrain-apply CPU in OnUpdate (dat reads + physics/ShadowObjects
// registration + terrain upload) — INVISIBLE to the title-bar ms (H1).
// upl_us = the terrain glBufferSubData sub-span of that apply (expected tiny).
// entUpl_us= the OnRender _wbMeshAdapter.Tick entity-mesh GPU drain (H2).
// For entity-DRAW CPU/GPU see the [WB-DIAG] line; walked = resident-N proxy (H3).
double applyMedUs = TerrainDiagMedianMicros(_applyCpuSamples) / 100.0;
double applyP95Us = TerrainDiagPercentile95Micros(_applyCpuSamples) / 100.0;
double uplMedUs = TerrainDiagMedianMicros(_applyUploadSamples) / 100.0;
double uplP95Us = TerrainDiagPercentile95Micros(_applyUploadSamples) / 100.0;
// Publication metrics are cumulative typed snapshots from the focused
// owners. The only GameWindow-local distribution left is the render-
// thread entity upload drain.
AcDream.App.Streaming.LandblockPresentationDiagnostics presentation =
_landblockPresentationPipeline?.Diagnostics ?? default;
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics render =
presentation.Render;
AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physics =
presentation.Physics;
AcDream.App.Streaming.LandblockStaticPresentationDiagnostics statics =
presentation.Statics;
double ticksToMicros =
1_000_000.0 / System.Diagnostics.Stopwatch.Frequency;
double entUplMedUs = TerrainDiagMedianMicros(_entityUploadSamples) / 100.0;
double entUplP95Us = TerrainDiagPercentile95Micros(_entityUploadSamples) / 100.0;
// apply CPU split: cell-build / gfxobj-BSP / ShadowObjects+lights — names
// WHICH part of the apply dominates (decides the fix shape).
double cellMedUs = TerrainDiagMedianMicros(_applyCellSamples) / 100.0;
double cellP95Us = TerrainDiagPercentile95Micros(_applyCellSamples) / 100.0;
double bspMedUs = TerrainDiagMedianMicros(_applyBspSamples) / 100.0;
double bspP95Us = TerrainDiagPercentile95Micros(_applyBspSamples) / 100.0;
double shadMedUs = TerrainDiagMedianMicros(_applyShadowSamples) / 100.0;
double shadP95Us = TerrainDiagPercentile95Micros(_applyShadowSamples) / 100.0;
double lockMedUs = TerrainDiagMedianMicros(_applyLockWaitSamples) / 100.0;
double lockP95Us = TerrainDiagPercentile95Micros(_applyLockWaitSamples) / 100.0;
int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0;
Console.WriteLine(
$"[FRAME-DIAG] apply_us={applyMedUs:F1}m/{applyP95Us:F1}p95 " +
$"lockwait={lockMedUs:F1}m/{lockP95Us:F1}p95 " +
$"[cell={cellMedUs:F1}m/{cellP95Us:F1}p95 bsp={bspMedUs:F1}m/{bspP95Us:F1}p95 " +
$"shadow={shadMedUs:F1}m/{shadP95Us:F1}p95] " +
$"(upl={uplMedUs:F1}m/{uplP95Us:F1}p95) " +
$"[FRAME-DIAG] publish={render.BeginCount}/{render.CompleteCount} " +
$"render_total_us=[terrain={render.TerrainPublishTicks * ticksToMicros:F1} " +
$"begin={render.BeginPublishTicks * ticksToMicros:F1} " +
$"complete={render.CompletePublishTicks * ticksToMicros:F1}] " +
$"physics_total_us=[base={physics.BasePublishTicks * ticksToMicros:F1} " +
$"gfx={physics.GfxCacheTicks * ticksToMicros:F1} " +
$"complete={physics.CompletePublishTicks * ticksToMicros:F1}] " +
$"static={statics.BeginCount}/{statics.CompleteCount}" +
$"(active={statics.ActiveEntityCount}) " +
$"entUpl_us={entUplMedUs:F1}m/{entUplP95Us:F1}p95 " +
$"applies_max/upd={_frameDiagMaxAppliesPerUpdate} " +
$"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " +
$"forceReload={_streamingController?.ForceReloadCount ?? 0}" +
$"(drop={_streamingController?.LastForceReloadDropCount ?? 0}) " +
$"fullRetire={_streamingController?.FullWindowRetirementCount ?? 0}" +
$"(landblocks={_streamingController?.LastFullWindowRetirementLandblockCount ?? 0}) " +
$"esg={_entitiesByServerGuid.Count} spawn={LastSpawns.Count} " +
$"resident={_worldState.Entities.Count} walked={walked}");
_frameDiagMaxAppliesPerUpdate = 0; // reset the per-window burst tracker
_terrainLastDiagTick = now;
}

View file

@ -129,8 +129,8 @@ public sealed class LandblockBuildFactory
// Hydrate the stabs: same logic as the old OnLoad preload. Each stab
// entity from LandblockLoader carries a SourceGfxObjOrSetupId that we
// expand into per-part MeshRefs via SetupMesh.Flatten / GfxObjMesh.Build.
// GPU upload (EnsureUploaded) happens on the render thread in
// ApplyLoadedTerrain — NOT here.
// GPU upload happens later through the render-thread presentation
// pipeline, never in this worker-side build.
var hydrated = new List<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count);
foreach (var e in baseLoaded.Entities)
{
@ -364,7 +364,8 @@ public sealed class LandblockBuildFactory
if (gfx is not null)
{
_physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx);
// Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain.
// Sub-meshes are CPU-built here; the presentation pipeline
// defers upload to the render thread.
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value);
@ -417,9 +418,8 @@ public sealed class LandblockBuildFactory
// _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at
// build time this landblock is NOT registered in physics yet, so that query
// could only return null (→ this same own-heightmap) or a STALE neighbor's
// height — the previous location's terrain, still registered after a teleport
// recenter (which drops only the single stale CENTER landblock, GameWindow
// :5444) until streaming unloads it — planting scenery at the old location's
// height — the previous location's terrain before the full old-window
// recenter retirement converges — planting scenery at the old location's
// altitude (trees-in-sky, deltaZ up to +500m; confirmed via the
// [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every
// case, so the global query is removed (also drops its per-spawn cost).
@ -526,7 +526,7 @@ public sealed class LandblockBuildFactory
/// Portal cells and drawable shell placements are accumulated in the
/// transaction-local <paramref name="envCellBuild"/>. The render thread
/// cannot observe this landblock until the streaming completion carries the
/// finished transaction to ApplyLoadedTerrain.
/// finished transaction to <c>LandblockPresentationPipeline</c>.
///
/// Ported from pre-streaming preload lines 407-565.
/// </summary>
@ -683,8 +683,8 @@ public sealed class LandblockBuildFactory
// Setup carries real Lights — a "light attach point" fixture (e.g. the Town
// Network fountain room's ceiling light, Setup 0x02000365). Track the dat
// Setup's Lights.Count here so the meshRefs==0 gate below doesn't also drop
// the entity that would otherwise carry those lights to the registration
// pass (GameWindow.cs ~7900).
// the entity that otherwise carries those lights to the static
// presentation publisher.
int stabLightCount = 0;
if ((stab.Id & 0xFF000000u) == 0x01000000u)
{

View file

@ -326,9 +326,9 @@ public sealed class LandblockPhysicsPublisher
/// <summary>
/// Publishes ordinary static collision and refloods after the caller has
/// completed the render-owned building/EnvCell suffix. The callback keeps
/// the shipped per-entity light-before-collision order until checkpoint E
/// moves static presentation into the transaction coordinator.
/// completed the render-owned building/EnvCell suffix. The concrete
/// presentation pipeline supplies the static-presentation owner callback
/// that preserves per-entity light-before-collision order.
/// </summary>
public void CompletePublication(
LandblockPhysicsPublication publication,

View file

@ -4,14 +4,23 @@ using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Read-only presentation state needed by render traversal and diagnostics.
/// Keeping this projection on the pipeline prevents the composition root from
/// retaining each concrete publication owner independently.
/// </summary>
public readonly record struct LandblockPresentationDiagnostics(
LandblockRenderPublisherDiagnostics Render,
LandblockPhysicsPublisherDiagnostics Physics,
LandblockStaticPresentationDiagnostics Statics);
/// <summary>
/// Render-thread transaction coordinator for one accepted landblock streaming
/// result. Streaming policy (desired tier, generation, priority, and apply
/// budget) remains in <see cref="StreamingController"/>; spatial buckets remain
/// in <see cref="GpuWorldState"/>. This owner fixes the presentation ordering
/// independently of those policies so the concrete render, physics, and static
/// publishers can be extracted behind it without returning callbacks to the
/// game window.
/// independently of those policies. The concrete render, physics, and static
/// publishers remain private implementation participants behind this boundary.
/// </summary>
/// <remarks>
/// Retail establishes cells/buildings before static objects in
@ -19,9 +28,9 @@ namespace AcDream.App.Streaming;
/// <c>CLandBlock::release_all @ 0x0052FCF0</c> (objects and visible cells) from
/// later destruction in <c>CLandBlock::Destroy @ 0x0052FAA0</c> (static objects
/// and buildings). Acdream's detach-first retry ledger is the existing
/// asynchronous lifetime adaptation. The injected publication operation
/// currently contains the already-shipped production transaction; later
/// Slice-5 checkpoints replace it with focused publishers.
/// asynchronous lifetime adaptation. Production always uses the focused owner
/// graph; the internal delegate constructor exists only for hermetic policy and
/// retry characterization tests.
/// </remarks>
public sealed class LandblockPresentationPipeline
{
@ -63,7 +72,7 @@ public sealed class LandblockPresentationPipeline
private readonly Dictionary<LandblockStreamResult, PublicationTransaction> _publications =
new(ReferenceEqualityComparer.Instance);
public LandblockPresentationPipeline(
internal LandblockPresentationPipeline(
Action<LandblockBuild, LandblockMeshData> publishBeforeSpatialCommit,
GpuWorldState state,
Action<uint>? onLandblockLoaded = null,
@ -137,6 +146,21 @@ public sealed class LandblockPresentationPipeline
retirementOwner.Advance);
}
/// <summary>
/// Building visibility registries published by the render owner. Legacy
/// test pipelines have no render owner and therefore expose an empty view.
/// </summary>
public IReadOnlyCollection<BuildingRegistry> BuildingRegistries =>
_renderPublisher?.BuildingRegistries ?? Array.Empty<BuildingRegistry>();
/// <summary>
/// Cumulative diagnostics from the three concrete presentation owners.
/// </summary>
public LandblockPresentationDiagnostics Diagnostics => new(
_renderPublisher?.Diagnostics ?? default,
_physicsPublisher?.Diagnostics ?? default,
_staticPublisher?.Diagnostics ?? default);
public int PendingRetirementCount => _retirements.PendingCount;
public int PendingPublicationCount => _publications.Count;

View file

@ -18,6 +18,21 @@ namespace AcDream.App.Streaming;
/// </summary>
public sealed class StreamingController
{
private sealed class OriginRecenterRetirement
{
public bool RadiiConverged;
public bool GenerationAdvanced;
public bool PendingLoadsCleared;
public bool DeferredApplyCleared;
public bool RegionCleared;
public List<uint>? ResidentIds;
public int RetirementCursor;
public bool PreparationCommitted;
public (int X, int Y, bool IsSealedDungeon)? Destination;
public bool DestinationConfigured;
public bool DestinationLoadEnqueued;
}
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
private readonly Action<uint, ulong> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
@ -29,6 +44,8 @@ public sealed class StreamingController
private RadiiReconfiguration? _pendingRadiiReconfiguration;
private bool _advancingRadiiReconfiguration;
private (int NearRadius, int FarRadius)? _deferredRadiiRequest;
private OriginRecenterRetirement? _originRecenterRetirement;
private bool _advancingOriginRecenter;
// 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.
@ -116,6 +133,7 @@ public sealed class StreamingController
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
public int DeferredApplyBacklog => _deferredApply.Count;
public int PendingRetirementCount => _presentation.PendingRetirementCount;
internal bool IsCollapsedToDungeon => _collapsed;
/// <summary>
/// True once every in-bounds landblock in the requested Chebyshev ring has
@ -154,15 +172,21 @@ public sealed class StreamingController
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; }
// [FRAME-DIAG]: full-window presentation retirements include explicit
// reloads and shared-origin recenter barriers. Both retire and later
// re-upload the complete resident window.
public int FullWindowRetirementCount { get; private set; }
public int LastFullWindowRetirementLandblockCount { 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<LandblockStreamResult> _deferredApply = new();
public StreamingController(
/// <summary>
/// Internal compatibility seam for hermetic controller policy tests. Live
/// composition cannot supply presentation callbacks; it must use the
/// concrete pipeline constructor below.
/// </summary>
internal StreamingController(
Action<uint, LandblockStreamJobKind, ulong> enqueueLoad,
Action<uint, ulong> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
@ -245,6 +269,15 @@ public sealed class StreamingController
nameof(farRadius),
"Far radius must be greater than or equal to near radius.");
// A radius transaction may not admit work after an origin-recenter
// snapshot has been captured. Retain only the latest requested radii;
// the first Tick after origin commit applies them to the new frame.
if (_originRecenterRetirement is not null)
{
_deferredRadiiRequest = (nearRadius, farRadius);
return;
}
// Queue callbacks are injected seams and can synchronously reenter the
// controller. Last-request-wins deferral keeps the active mutation
// cursor stable; the request is applied after the current transaction
@ -427,6 +460,12 @@ public sealed class StreamingController
/// </summary>
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
{
if (_originRecenterRetirement is not null)
{
_presentation.AdvanceRetirements();
return;
}
if (_pendingRadiiReconfiguration is not null)
AdvanceRadiiReconfiguration();
else if (_deferredRadiiRequest is { } deferred)
@ -762,18 +801,262 @@ public sealed class StreamingController
}
/// <summary>
/// 2026-06-22: an OUTDOOR teleport moved the render origin (<c>_liveCenter</c>). 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 <see cref="NormalTick"/> 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 <see cref="PreCollapseToDungeon"/> instead.
/// Starts a reload of the current streaming window. Logical residency is
/// detached immediately; render, physics, and static-lighting owners then
/// converge through the retained presentation-retirement ledger before
/// <see cref="NormalTick"/> bootstraps the window again.
/// Shared-origin teleports use <see cref="BeginOriginRecenter"/> instead so
/// every old-window owner retires while the old coordinate frame is active.
/// </summary>
public void ForceReloadWindow()
{
BeginFullWindowRetirement();
}
/// <summary>
/// Starts the old-window half of a shared-origin recenter. All accepted
/// worker work is invalidated and every resident landblock is detached
/// through the retryable presentation ledger while the old coordinate
/// frame is still active. <see cref="Tick"/> remains blocked until
/// <see cref="TryCommitOriginRecenter"/> succeeds.
/// </summary>
internal void BeginOriginRecenter()
{
_originRecenterRetirement ??= new OriginRecenterRetirement();
}
/// <summary>
/// Advances every retained old-window teardown and reports whether the
/// composition root may safely change the shared world origin.
/// </summary>
internal bool IsOriginRecenterRetirementComplete()
{
if (_originRecenterRetirement is null)
throw new InvalidOperationException(
"No streaming-origin recenter transaction is pending.");
if (!TryAdvanceOriginRecenterPreparation())
return false;
_presentation.AdvanceRetirements();
return _presentation.PendingRetirementCount == 0;
}
/// <summary>
/// Releases the streaming bootstrap gate after the composition root has
/// committed the new shared origin. No old-window presentation owner may
/// still be pending at this edge.
/// </summary>
internal bool TryCommitOriginRecenter(
int destinationX,
int destinationY,
bool isSealedDungeon)
{
if (_advancingOriginRecenter)
return false;
_advancingOriginRecenter = true;
try
{
return TryCommitOriginRecenterCore(
destinationX,
destinationY,
isSealedDungeon);
}
finally
{
_advancingOriginRecenter = false;
}
}
private bool TryCommitOriginRecenterCore(
int destinationX,
int destinationY,
bool isSealedDungeon)
{
OriginRecenterRetirement transaction = _originRecenterRetirement
?? throw new InvalidOperationException(
"No streaming-origin recenter transaction is pending.");
if (!transaction.PreparationCommitted)
throw new InvalidOperationException(
"Streaming-origin retirement preparation has not completed.");
if (_presentation.PendingRetirementCount != 0)
throw new InvalidOperationException(
"The streaming origin cannot change while old-window presentation retirement is pending.");
var destination = (destinationX, destinationY, isSealedDungeon);
if (transaction.Destination is { } retained && retained != destination)
{
throw new InvalidOperationException(
"A recenter transaction cannot commit two different destinations.");
}
transaction.Destination ??= destination;
if (!transaction.DestinationConfigured)
{
_collapsed = isSealedDungeon;
_collapsedCenter = isSealedDungeon
? StreamingRegion.EncodeLandblockId(destinationX, destinationY)
: 0u;
if (isSealedDungeon)
{
_region = new StreamingRegion(
destinationX,
destinationY,
nearRadius: 0,
farRadius: 0);
_region.MarkResidentFromBootstrap();
}
else
{
_region = null;
}
transaction.DestinationConfigured = true;
}
if (isSealedDungeon && !transaction.DestinationLoadEnqueued)
{
try
{
EnqueueLoad(
StreamingRegion.EncodeLandblockId(destinationX, destinationY),
LandblockStreamJobKind.LoadNear);
transaction.DestinationLoadEnqueued = true;
}
catch (StreamingMutationException error) when (error.MutationCommitted)
{
transaction.DestinationLoadEnqueued = true;
Console.WriteLine(
$"streaming: committed dungeon recenter enqueue reported failure: {error}");
return false;
}
catch (Exception error)
{
Console.WriteLine(
$"streaming: dungeon recenter enqueue will resume: {error}");
return false;
}
}
_originRecenterRetirement = null;
return true;
}
/// <summary>
/// Releases a fully retired origin transaction at a session boundary
/// without bootstrapping a destination from the ending session.
/// </summary>
internal bool TryCancelOriginRecenter()
{
if (_originRecenterRetirement is not { PreparationCommitted: true })
return false;
if (_presentation.PendingRetirementCount != 0)
return false;
_collapsed = false;
_collapsedCenter = 0u;
_region = null;
_originRecenterRetirement = null;
return true;
}
private bool TryAdvanceOriginRecenterPreparation()
{
OriginRecenterRetirement transaction = _originRecenterRetirement
?? throw new InvalidOperationException(
"No streaming-origin recenter transaction is pending.");
if (transaction.PreparationCommitted)
return true;
if (_advancingOriginRecenter)
return false;
_advancingOriginRecenter = true;
try
{
if (!transaction.RadiiConverged)
{
if (_pendingRadiiReconfiguration is not null)
AdvanceRadiiReconfiguration();
if (_pendingRadiiReconfiguration is not null)
return false;
transaction.RadiiConverged = true;
}
if (!transaction.GenerationAdvanced)
{
AdvanceGeneration();
transaction.GenerationAdvanced = true;
}
if (!transaction.PendingLoadsCleared)
{
try
{
_clearPendingLoads?.Invoke();
transaction.PendingLoadsCleared = true;
}
catch (StreamingMutationException error) when (error.MutationCommitted)
{
transaction.PendingLoadsCleared = true;
Console.WriteLine(
$"streaming: committed pending-load clear reported failure: {error}");
return false;
}
}
if (!transaction.DeferredApplyCleared)
{
_deferredApply.Clear();
transaction.DeferredApplyCleared = true;
}
if (!transaction.RegionCleared)
{
_collapsed = false;
_region = null;
transaction.RegionCleared = true;
}
if (transaction.ResidentIds is null)
{
transaction.ResidentIds = new List<uint>(_state.LoadedLandblockIds);
FullWindowRetirementCount++;
LastFullWindowRetirementLandblockCount = transaction.ResidentIds.Count;
}
while (transaction.RetirementCursor < transaction.ResidentIds.Count)
{
uint id = transaction.ResidentIds[transaction.RetirementCursor];
try
{
_presentation.BeginFullRetirement(id);
transaction.RetirementCursor++;
}
catch (Exception error)
{
// A delivered visibility-observer failure may surface after
// detachment has committed. Advance that exact cursor only
// when world state proves the old resident is unreachable;
// otherwise retain it for the next frame.
if (!_state.IsLoaded(id))
transaction.RetirementCursor++;
Console.WriteLine(
$"streaming: origin-recenter retirement for 0x{id:X8} " +
$"will resume: {error}");
return false;
}
}
transaction.PreparationCommitted = true;
return true;
}
catch (Exception error)
{
Console.WriteLine(
$"streaming: origin-recenter preparation will resume: {error}");
return false;
}
finally
{
_advancingOriginRecenter = false;
}
}
private void BeginFullWindowRetirement()
{
AdvanceGeneration();
_collapsed = false;
@ -785,8 +1068,8 @@ public sealed class StreamingController
_region = null;
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
var ids = new List<uint>(_state.LoadedLandblockIds);
ForceReloadCount++; // [FRAME-DIAG] churn counter
LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume
FullWindowRetirementCount++; // [FRAME-DIAG] churn counter
LastFullWindowRetirementLandblockCount = ids.Count; // upcoming re-upload volume
foreach (var id in ids)
_presentation.BeginFullRetirement(id);
}
@ -838,14 +1121,13 @@ public sealed class StreamingController
}
catch
{
// The presentation pipeline retains its exact committed
// stage after the coarse presentation callback commits.
// Preserve it and every untouched completion from this
// already-drained chunk in original FIFO order. A
// presentation-stage failure is deliberately fail-fast
// until the focused publishers replace that callback;
// its result is not retryable, but the untouched suffix
// must still survive.
// The concrete presentation pipeline retains the exact
// unfinished stage. Preserve it and every untouched
// completion from this already-drained chunk in
// original FIFO order. Internal compatibility tests may
// use a callback pipeline that cannot retain a receipt;
// its failed result is consumed, but the untouched
// suffix must still survive.
if (_presentation.HasPendingPublication(result))
_deferredApply.Add(result);
for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++)
@ -890,12 +1172,12 @@ public sealed class StreamingController
}
catch
{
// A suffix-stage failure keeps its pipeline receipt and
// must remain at this exact FIFO position. The still-
// monolithic presentation callback deliberately removes
// its receipt on failure because its internal committed
// prefix is unknown; consume only that failed result so a
// later frame cannot replay it automatically.
// A concrete suffix-stage failure keeps its pipeline
// receipt and must remain at this exact FIFO position. The
// internal compatibility callback cannot report its exact
// committed prefix and therefore removes its receipt;
// consume only that failed result so a later frame cannot
// replay it automatically.
if (!_presentation.HasPendingPublication(result))
read++;
throw;

View file

@ -0,0 +1,192 @@
using AcDream.App.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Coordinates streaming-origin lifetime boundaries. Presentation must retire
/// the complete old window first; a teleport then changes
/// <see cref="LiveWorldOriginState"/> before destination streaming resumes,
/// while a session boundary releases the controller without recentering. The
/// transaction remains pending across frames when an owner teardown needs retry.
/// </summary>
internal sealed class StreamingOriginRecenterCoordinator
{
private readonly record struct Request(
int DestinationX,
int DestinationY,
bool IsSealedDungeon,
bool CancelAtSessionBoundary = false);
private readonly StreamingController _streaming;
private readonly LiveWorldOriginState _origin;
private Request? _pending;
private Request? _replacement;
private bool _originCommitted;
private bool _acceptReplacement;
private bool _sourceWasSealedDungeon;
private bool _advancing;
public StreamingOriginRecenterCoordinator(
StreamingController streaming,
LiveWorldOriginState origin)
{
_streaming = streaming ?? throw new ArgumentNullException(nameof(streaming));
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
}
public bool IsPending => _pending is not null;
/// <summary>
/// Begins a new recenter transaction and performs its first retirement
/// attempt immediately. A teleport replacement must call <see cref="Reset"/>
/// before supplying a different destination.
/// </summary>
public bool Begin(int destinationX, int destinationY, bool isSealedDungeon)
{
var request = new Request(destinationX, destinationY, isSealedDungeon);
if (_pending is { } pending && pending != request)
{
if (!_acceptReplacement)
{
throw new InvalidOperationException(
"A different streaming-origin recenter is already pending.");
}
_acceptReplacement = false;
if (_originCommitted)
_replacement = request;
else
_pending = request;
}
if (_pending is null)
{
_pending = request;
_acceptReplacement = false;
_sourceWasSealedDungeon = _streaming.IsCollapsedToDungeon;
_streaming.BeginOriginRecenter();
}
return Advance();
}
/// <summary>
/// Advances retained presentation teardown. Returns true only after the
/// new origin is committed and destination streaming has been unblocked.
/// </summary>
public bool Advance()
{
if (_advancing)
return false;
_advancing = true;
try
{
while (_pending is { } request)
{
if (!_originCommitted)
{
if (!_streaming.IsOriginRecenterRetirementComplete())
return false;
if (_pending is not { } current || current != request)
continue;
// A session-boundary cancellation only releases the old
// controller gate. Session identity owns resetting and
// initializing the next live origin, so an asynchronously
// converging teardown must never overwrite it.
if (!request.CancelAtSessionBoundary)
_origin.Recenter(request.DestinationX, request.DestinationY);
_originCommitted = true;
}
bool destinationCommitted = request.CancelAtSessionBoundary
? _streaming.TryCancelOriginRecenter()
: _streaming.TryCommitOriginRecenter(
request.DestinationX,
request.DestinationY,
request.IsSealedDungeon);
if (!destinationCommitted)
return false;
if (_pending is not { } committed || committed != request)
{
_originCommitted = false;
_streaming.BeginOriginRecenter();
continue;
}
_pending = null;
_originCommitted = false;
_acceptReplacement = false;
if (_replacement is not { } replacement)
return true;
_replacement = null;
_pending = replacement;
_sourceWasSealedDungeon = _streaming.IsCollapsedToDungeon;
_streaming.BeginOriginRecenter();
}
return true;
}
finally
{
_advancing = false;
}
}
/// <summary>
/// Converts an uncommitted request into a same-origin convergence request
/// at a teleport-lifetime boundary. This cannot orphan the controller's
/// bootstrap gate: a replacement may reuse the retained old-window barrier,
/// while cancellation/session reset completes it at the current origin.
/// </summary>
public bool Reset(bool sessionEnding = false)
{
if (_pending is null)
{
if (!sessionEnding)
return true;
// Every character-session boundary invalidates geometry baked in
// the old shared coordinate frame, even when no teleport was in
// flight. Retire the complete window before session identity may
// allow the next player spawn to initialize a different origin.
_pending = new Request(
_origin.CenterX,
_origin.CenterY,
IsSealedDungeon: false,
CancelAtSessionBoundary: true);
_replacement = null;
_originCommitted = false;
_acceptReplacement = false;
_sourceWasSealedDungeon = _streaming.IsCollapsedToDungeon;
_streaming.BeginOriginRecenter();
return Advance();
}
_replacement = null;
_acceptReplacement = true;
if (!_originCommitted)
{
_pending = new Request(
_origin.CenterX,
_origin.CenterY,
IsSealedDungeon: !sessionEnding && _sourceWasSealedDungeon,
CancelAtSessionBoundary: sessionEnding);
}
else if (sessionEnding)
{
_pending = new Request(
_origin.CenterX,
_origin.CenterY,
IsSealedDungeon: false,
CancelAtSessionBoundary: true);
}
return Advance();
}
}

View file

@ -127,7 +127,8 @@ public sealed class PhysicsEngine
DataCache?.RemoveBuildingsForLandblock(landblockId);
// #145: if the player's current cell belonged to the landblock being removed (a teleport
// drops the stale source center via OnLivePositionUpdated), clear it. Otherwise CurrCell
// retires the stale source through StreamingOriginRecenterCoordinator and the presentation
// pipeline), clear it. Otherwise CurrCell
// dangles on an orphaned cell and the dungeon-streaming gate — keyed on CurrCell — keeps
// streaming collapsed onto the gone landblock, so the destination never streams in and
// only the skybox renders. Clearing it lets the gate read "not in a dungeon" → the

View file

@ -6,11 +6,10 @@ using DatEnvironment = DatReaderWriter.DBObjs.Environment;
namespace AcDream.Core.World;
/// <summary>
/// The parsed dat objects <c>ApplyLoadedTerrainLocked</c> needs, pre-read by the
/// streaming worker under <c>_datLock</c> so the apply makes ZERO DatCollection
/// calls and the update thread never blocks on the worker's lock (the FPS
/// 30↔200 swing was that lock-wait). Keyed by the same dat id the apply would
/// have passed to <c>_dats.Get&lt;T&gt;</c>.
/// Parsed DAT closure produced by <c>LandblockBuildFactory</c> under the shared
/// reader gate and consumed by the render-thread presentation publishers.
/// Publication makes zero DatCollection calls, so the update thread never
/// blocks on worker DAT access. Entries retain their original DAT IDs.
/// </summary>
public sealed record PhysicsDatBundle(
LandBlockInfo? Info,

View file

@ -101,6 +101,66 @@ public sealed class LiveSessionResetPlanTests
Assert.Equal(2, secondCalls);
}
[Fact]
public void Execute_BlocksNextSessionUntilOriginRetirementConverges()
{
const uint sourceId = 0x5353FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
sourceId,
new LandBlock(),
Array.Empty<WorldEntity>()));
var origin = new LiveWorldOriginState();
Assert.True(origin.TryInitialize(0x53, 0x53));
bool failRetirement = true;
var controller = new StreamingController(
enqueueLoad: static (_, _) => { },
enqueueUnload: static _ => { },
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
applyTerrain: static (_, _) => { },
state,
nearRadius: 0,
farRadius: 0,
removeTerrain: _ =>
{
if (failRetirement)
throw new InvalidOperationException("injected ending-session failure");
});
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
int identityResets = 0;
var plan = new LiveSessionResetPlan(
[
new("teleport transit", () =>
{
if (!recenter.Reset(sessionEnding: true))
{
throw new InvalidOperationException(
"streaming-origin retirement remains pending");
}
}),
new("session identity", () =>
{
identityResets++;
origin.Reset();
}),
]);
AggregateException blocked = Assert.Throws<AggregateException>(plan.Execute);
LiveSessionResetStageException failure = Assert.IsType<LiveSessionResetStageException>(
Assert.Single(blocked.InnerExceptions));
Assert.Equal("teleport transit", failure.StageName);
Assert.Equal(1, identityResets);
failRetirement = false;
plan.Execute();
Assert.Equal(2, identityResets);
Assert.False(recenter.IsPending);
Assert.False(origin.IsKnown);
Assert.True(origin.TryInitialize(0x61, 0x62));
Assert.Equal((0x61, 0x62), (origin.CenterX, origin.CenterY));
}
[Fact]
public void Execute_ReentrantAttemptIsReportedButLaterStagesStillRun()
{

View file

@ -232,7 +232,7 @@ public sealed class LandblockBuildOriginTests
}
[Fact]
public void BuildFactoryAndGameWindowPublication_UseCapturedOriginInsteadOfLiveCenter()
public void BuildFactoryAndRenderPublisher_UseCapturedOriginWithoutGameWindowFacade()
{
string root = FindRepoRoot();
string gameWindowSource = File.ReadAllText(Path.Combine(
@ -253,11 +253,12 @@ public sealed class LandblockBuildOriginTests
"AcDream.App",
"Streaming",
"LandblockRenderPublisher.cs"));
string publicationSection = Slice(
gameWindowSource,
"private void ApplyLoadedTerrainLocked(",
"private void OnUpdate(double dt)");
string recenterSource = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Streaming",
"StreamingOriginRecenterCoordinator.cs"));
Assert.Contains("request.Origin", buildSource, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterX", buildSource, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterY", buildSource, StringComparison.Ordinal);
@ -277,12 +278,68 @@ public sealed class LandblockBuildOriginTests
"BuildPhysicsDatBundle",
gameWindowSource,
StringComparison.Ordinal);
Assert.DoesNotContain(
"ApplyLoadedTerrain",
gameWindowSource,
StringComparison.Ordinal);
Assert.DoesNotContain(
"PublishLandblockStaticLightingBeforeCollision",
gameWindowSource,
StringComparison.Ordinal);
Assert.DoesNotContain(
"_landblockPhysicsPublisher!.RemoveLandblock",
gameWindowSource,
StringComparison.Ordinal);
Assert.DoesNotContain(
"_landblockRenderPublisher",
gameWindowSource,
StringComparison.Ordinal);
Assert.DoesNotContain(
"_landblockPhysicsPublisher",
gameWindowSource,
StringComparison.Ordinal);
Assert.DoesNotContain(
"_landblockStaticPresentationPublisher",
gameWindowSource,
StringComparison.Ordinal);
Assert.Contains(
"_landblockPresentationPipeline",
gameWindowSource,
StringComparison.Ordinal);
Assert.Contains(
"new AcDream.App.Streaming.LandblockRenderPublisher(",
gameWindowSource,
StringComparison.Ordinal);
Assert.Contains(
"new AcDream.App.Streaming.LandblockPhysicsPublisher(",
gameWindowSource,
StringComparison.Ordinal);
Assert.Contains(
"new AcDream.App.Streaming.LandblockStaticPresentationPublisher(",
gameWindowSource,
StringComparison.Ordinal);
Assert.DoesNotContain("applyTerrain:", gameWindowSource, StringComparison.Ordinal);
Assert.DoesNotContain("demoteNearLayer:", gameWindowSource, StringComparison.Ordinal);
Assert.DoesNotContain("retirementCoordinator:", gameWindowSource, StringComparison.Ordinal);
Assert.DoesNotContain(
"_liveWorldOrigin.Recenter(lbX, lbY)",
gameWindowSource,
StringComparison.Ordinal);
int retirementBarrier = recenterSource.IndexOf(
"IsOriginRecenterRetirementComplete()",
StringComparison.Ordinal);
int originCommit = recenterSource.IndexOf(
"_origin.Recenter(",
StringComparison.Ordinal);
int destinationCommit = recenterSource.IndexOf(
"_streaming.TryCommitOriginRecenter(",
StringComparison.Ordinal);
Assert.True(retirementBarrier >= 0);
Assert.True(originCommit > retirementBarrier);
Assert.True(destinationCommit > originCommit);
Assert.Contains("ComputeOrigin(landblockId, build.Origin)", renderPublisherSource, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterX", renderPublisherSource, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterY", renderPublisherSource, StringComparison.Ordinal);
Assert.Contains("renderPublication.Origin", publicationSection, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterX", publicationSection, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterY", publicationSection, StringComparison.Ordinal);
}
private static LandblockBuild EmptyBuild(uint landblockId, LandblockBuildOrigin origin) =>
@ -316,15 +373,6 @@ public sealed class LandblockBuildOriginTests
return results;
}
private static string Slice(string source, string startMarker, string endMarker)
{
int start = source.IndexOf(startMarker, StringComparison.Ordinal);
Assert.True(start >= 0, $"Missing source marker: {startMarker}");
int end = source.IndexOf(endMarker, start, StringComparison.Ordinal);
Assert.True(end > start, $"Missing source marker: {endMarker}");
return source[start..end];
}
private static string FindRepoRoot()
{
string? dir = AppContext.BaseDirectory;

View file

@ -3,6 +3,7 @@ using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
@ -47,7 +48,7 @@ public sealed class LandblockPresentationPipelineTests
}
[Fact]
public void ControllerConcreteConstructorHasNoLegacyPresentationParameters()
public void PublicPresentationConstructorsExposeOnlyConcreteOwnerGraph()
{
string[] legacyNames =
[
@ -59,14 +60,595 @@ public sealed class LandblockPresentationPipelineTests
"retirementCoordinator",
];
System.Reflection.ConstructorInfo concrete = Assert.Single(
typeof(StreamingController).GetConstructors(),
constructor => constructor.GetParameters().Any(
parameter => parameter.Name == "presentationPipeline"));
string[] names = concrete.GetParameters()
System.Reflection.ConstructorInfo controller = Assert.Single(
typeof(StreamingController).GetConstructors());
Assert.Contains(
controller.GetParameters(),
parameter => parameter.Name == "presentationPipeline");
string[] names = controller.GetParameters()
.Select(parameter => parameter.Name!)
.ToArray();
Assert.DoesNotContain(names, legacyNames.Contains);
System.Reflection.ConstructorInfo pipeline = Assert.Single(
typeof(LandblockPresentationPipeline).GetConstructors());
Type[] pipelineParameterTypes = pipeline.GetParameters()
.Select(parameter => parameter.ParameterType)
.ToArray();
Assert.Contains(typeof(LandblockRenderPublisher), pipelineParameterTypes);
Assert.Contains(typeof(LandblockPhysicsPublisher), pipelineParameterTypes);
Assert.Contains(typeof(LandblockStaticPresentationPublisher), pipelineParameterTypes);
Assert.DoesNotContain(
pipeline.GetParameters(),
parameter => parameter.Name == "publishBeforeSpatialCommit");
string[] coordinatorOnlyMethods =
[
"BeginOriginRecenter",
"IsOriginRecenterRetirementComplete",
"TryCommitOriginRecenter",
"TryCancelOriginRecenter",
];
foreach (string methodName in coordinatorOnlyMethods)
{
Assert.Null(typeof(StreamingController).GetMethod(methodName));
Assert.NotNull(typeof(StreamingController).GetMethod(
methodName,
System.Reflection.BindingFlags.Instance |
System.Reflection.BindingFlags.NonPublic));
}
}
[Fact]
public void OriginRecenter_BlocksOriginAndBootstrapUntilEveryOldOwnerConverges()
{
const uint centerId = 0x1919FFFFu;
const uint neighborId = 0x191AFFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
centerId,
new LandBlock(),
Array.Empty<WorldEntity>()));
state.AddLandblock(new LoadedLandblock(
neighborId,
new LandBlock(),
Array.Empty<WorldEntity>()));
var origin = new LiveWorldOriginState();
Assert.True(origin.TryInitialize(0x19, 0x19));
var enqueued = new List<uint>();
bool failNeighbor = true;
int neighborAttempts = 0;
var controller = new StreamingController(
enqueueLoad: (id, _) => enqueued.Add(id),
enqueueUnload: static _ => { },
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
applyTerrain: static (_, _) => { },
state,
nearRadius: 0,
farRadius: 0,
removeTerrain: id =>
{
if (id != neighborId)
return;
neighborAttempts++;
if (failNeighbor)
throw new InvalidOperationException("injected non-center failure");
});
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x22, 0x23, isSealedDungeon: false));
Assert.Equal((0x19, 0x19), (origin.CenterX, origin.CenterY));
Assert.False(state.IsLoaded(centerId));
Assert.False(state.IsLoaded(neighborId));
Assert.Equal(1, controller.PendingRetirementCount);
Assert.Equal(2, controller.LastFullWindowRetirementLandblockCount);
controller.Tick(0x19, 0x19);
Assert.Empty(enqueued);
Assert.True(neighborAttempts >= 2);
failNeighbor = false;
Assert.True(recenter.Advance());
Assert.Equal((0x22, 0x23), (origin.CenterX, origin.CenterY));
Assert.Equal(0, controller.PendingRetirementCount);
controller.Tick(0x22, 0x23);
Assert.Equal([0x2223FFFFu], enqueued);
}
[Fact]
public void OriginRecenter_PreparationFailureBeforeDetachRetainsOldOriginAndResidents()
{
const uint landblockId = 0x3030FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblockId,
new LandBlock(),
Array.Empty<WorldEntity>()));
var origin = new LiveWorldOriginState();
Assert.True(origin.TryInitialize(0x30, 0x30));
bool failClear = true;
int clearAttempts = 0;
var controller = new StreamingController(
enqueueLoad: static (_, _) => { },
enqueueUnload: static _ => { },
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
applyTerrain: static (_, _) => { },
state,
nearRadius: 0,
farRadius: 0,
clearPendingLoads: () =>
{
clearAttempts++;
if (failClear)
throw new InvalidOperationException("injected clear failure");
});
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x40, 0x41, isSealedDungeon: false));
Assert.Equal((0x30, 0x30), (origin.CenterX, origin.CenterY));
Assert.True(state.IsLoaded(landblockId));
Assert.Equal(0, controller.PendingRetirementCount);
failClear = false;
Assert.True(recenter.Advance());
Assert.Equal(2, clearAttempts);
Assert.False(state.IsLoaded(landblockId));
Assert.Equal((0x40, 0x41), (origin.CenterX, origin.CenterY));
}
[Fact]
public void OriginRecenter_ObserverFailureHalfwayRetainsBarrierUntilSnapshotFinishes()
{
const uint firstId = 0x4242FFFFu;
const uint secondId = 0x4243FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
firstId,
new LandBlock(),
Array.Empty<WorldEntity>()));
state.AddLandblock(new LoadedLandblock(
secondId,
new LandBlock(),
[Entity(2, serverGuid: 0x70000042u)]));
int observerCalls = 0;
state.LiveProjectionVisibilityChanged += (_, _) =>
{
observerCalls++;
throw new InvalidOperationException("injected observer failure");
};
var origin = new LiveWorldOriginState();
Assert.True(origin.TryInitialize(0x42, 0x42));
var removed = new List<uint>();
var controller = new StreamingController(
enqueueLoad: static (_, _) => { },
enqueueUnload: static _ => { },
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
applyTerrain: static (_, _) => { },
state,
nearRadius: 0,
farRadius: 0,
removeTerrain: id => removed.Add(id));
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x43, 0x44, isSealedDungeon: false));
Assert.Equal((0x42, 0x42), (origin.CenterX, origin.CenterY));
Assert.False(state.IsLoaded(firstId));
Assert.False(state.IsLoaded(secondId));
Assert.Equal(1, observerCalls);
Assert.True(recenter.Advance());
Assert.Equal((0x43, 0x44), (origin.CenterX, origin.CenterY));
Assert.Equal([firstId, secondId], removed);
}
[Fact]
public void OriginRecenter_RetryPreservesLiveIdentityAndDoesNotRescueReusedGuid()
{
const uint oldId = 0x4545FFFFu;
const uint destinationId = 0x4646FFFFu;
const uint playerGuid = 0x50000045u;
const uint remoteGuid = 0x70000045u;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
oldId,
new LandBlock(),
Array.Empty<WorldEntity>()));
WorldEntity player = Entity(10, playerGuid);
WorldEntity remote = Entity(11, remoteGuid);
state.MarkPersistent(playerGuid);
state.PlaceLiveEntityProjection(oldId, player);
state.PlaceLiveEntityProjection(oldId, remote);
var origin = new LiveWorldOriginState();
Assert.True(origin.TryInitialize(0x45, 0x45));
bool failRetirement = true;
var controller = new StreamingController(
enqueueLoad: static (_, _) => { },
enqueueUnload: static _ => { },
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
applyTerrain: static (_, _) => { },
state,
nearRadius: 0,
farRadius: 0,
removeTerrain: _ =>
{
if (failRetirement)
throw new InvalidOperationException("injected live-owner retry");
});
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x46, 0x46, isSealedDungeon: false));
Assert.Same(player, Assert.Single(state.DrainRescued()));
Assert.Equal(1, state.PendingLiveEntityCount);
Assert.Empty(state.Entities);
failRetirement = false;
Assert.True(recenter.Advance());
Assert.Empty(state.DrainRescued());
state.AddLandblock(new LoadedLandblock(
destinationId,
new LandBlock(),
Array.Empty<WorldEntity>()));
state.RebucketLiveEntity(player, destinationId);
Assert.Same(player, Assert.Single(state.Entities));
state.AddLandblock(new LoadedLandblock(
oldId,
new LandBlock(),
Array.Empty<WorldEntity>()));
Assert.Contains(state.Entities, entity => ReferenceEquals(entity, remote));
state.ForgetLiveEntity(playerGuid);
WorldEntity replacement = Entity(12, playerGuid);
state.MarkPersistent(playerGuid);
state.PlaceLiveEntityProjection(destinationId, replacement);
state.RemoveLandblock(destinationId);
Assert.Same(replacement, Assert.Single(state.DrainRescued()));
Assert.DoesNotContain(state.Entities, entity => ReferenceEquals(entity, player));
}
[Fact]
public void OriginRecenter_ResetWithoutReplacementConvergesAtCurrentOrigin()
{
const uint landblockId = 0x5050FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblockId,
new LandBlock(),
Array.Empty<WorldEntity>()));
var origin = new LiveWorldOriginState();
Assert.True(origin.TryInitialize(0x50, 0x50));
bool failRetirement = true;
var controller = new StreamingController(
enqueueLoad: static (_, _) => { },
enqueueUnload: static _ => { },
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
applyTerrain: static (_, _) => { },
state,
nearRadius: 0,
farRadius: 0,
removeTerrain: _ =>
{
if (failRetirement)
throw new InvalidOperationException("injected retirement failure");
});
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x60, 0x60, isSealedDungeon: false));
recenter.Reset();
Assert.True(recenter.IsPending);
failRetirement = false;
Assert.True(recenter.Advance());
Assert.False(recenter.IsPending);
Assert.Equal((0x50, 0x50), (origin.CenterX, origin.CenterY));
controller.Tick(0x50, 0x50);
}
[Fact]
public void OriginRecenter_ResetBackToSealedSourceKeepsRadiusZeroMode()
{
const uint sourceId = 0x5252FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
sourceId,
new LandBlock(),
Array.Empty<WorldEntity>()));
var origin = new LiveWorldOriginState();
Assert.True(origin.TryInitialize(0x52, 0x52));
var enqueued = new List<(uint Id, LandblockStreamJobKind Kind)>();
bool failRetirement = true;
var controller = new StreamingController(
enqueueLoad: (id, kind) => enqueued.Add((id, kind)),
enqueueUnload: static _ => { },
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
applyTerrain: static (_, _) => { },
state,
nearRadius: 2,
farRadius: 4,
removeTerrain: _ =>
{
if (failRetirement)
throw new InvalidOperationException("injected retirement failure");
});
controller.PreCollapseToDungeon(0x52, 0x52);
enqueued.Clear();
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x60, 0x60, isSealedDungeon: false));
recenter.Reset();
failRetirement = false;
Assert.True(recenter.Advance());
Assert.Equal((0x52, 0x52), (origin.CenterX, origin.CenterY));
Assert.Equal([(sourceId, LandblockStreamJobKind.LoadNear)], enqueued);
controller.Tick(0x52, 0x52, insideDungeon: true);
Assert.Single(enqueued);
}
[Fact]
public void OriginRecenter_SessionResetConvergesWithoutBootstrappingEndingWorld()
{
const uint sourceId = 0x5353FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
sourceId,
new LandBlock(),
Array.Empty<WorldEntity>()));
var origin = new LiveWorldOriginState();
Assert.True(origin.TryInitialize(0x53, 0x53));
var enqueued = new List<uint>();
bool failRetirement = true;
var controller = new StreamingController(
enqueueLoad: (id, _) => enqueued.Add(id),
enqueueUnload: static _ => { },
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
applyTerrain: static (_, _) => { },
state,
nearRadius: 0,
farRadius: 0,
removeTerrain: _ =>
{
if (failRetirement)
throw new InvalidOperationException("injected session reset failure");
});
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x54, 0x54, isSealedDungeon: true));
recenter.Reset(sessionEnding: true);
failRetirement = false;
Assert.True(recenter.Advance());
Assert.Empty(enqueued);
Assert.Equal((0x53, 0x53), (origin.CenterX, origin.CenterY));
controller.Tick(0x53, 0x53);
Assert.Equal([sourceId], enqueued);
}
[Fact]
public void OriginRecenter_SessionResetCannotOverwriteNextSessionOrigin()
{
const uint sourceId = 0x5353FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
sourceId,
new LandBlock(),
Array.Empty<WorldEntity>()));
var origin = new LiveWorldOriginState();
Assert.True(origin.TryInitialize(0x53, 0x53));
bool failRetirement = true;
var controller = new StreamingController(
enqueueLoad: static (_, _) => { },
enqueueUnload: static _ => { },
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
applyTerrain: static (_, _) => { },
state,
nearRadius: 0,
farRadius: 0,
removeTerrain: _ =>
{
if (failRetirement)
throw new InvalidOperationException("injected session reset failure");
});
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x54, 0x54, isSealedDungeon: true));
recenter.Reset(sessionEnding: true);
origin.Reset();
Assert.True(origin.TryInitialize(0x61, 0x62));
failRetirement = false;
Assert.True(recenter.Advance());
Assert.Equal((0x61, 0x62), (origin.CenterX, origin.CenterY));
}
[Fact]
public void OriginRecenter_CommittedPendingClearFailureDoesNotReplayClear()
{
var state = new GpuWorldState();
var origin = new LiveWorldOriginState();
Assert.True(origin.TryInitialize(0x10, 0x10));
int clearAttempts = 0;
var controller = new StreamingController(
enqueueLoad: static (_, _) => { },
enqueueUnload: static _ => { },
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
applyTerrain: static (_, _) => { },
state,
nearRadius: 0,
farRadius: 0,
clearPendingLoads: () =>
{
clearAttempts++;
throw new StreamingMutationException(
"injected committed clear failure",
mutationCommitted: true);
});
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x11, 0x11, isSealedDungeon: false));
Assert.True(recenter.Advance());
Assert.Equal(1, clearAttempts);
Assert.Equal((0x11, 0x11), (origin.CenterX, origin.CenterY));
}
[Fact]
public void OriginRecenter_ReentrantClearAndRetirementDoNotRecurseOrSkipResidents()
{
const uint firstId = 0x7070FFFFu;
const uint secondId = 0x7071FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
firstId,
new LandBlock(),
Array.Empty<WorldEntity>()));
state.AddLandblock(new LoadedLandblock(
secondId,
new LandBlock(),
Array.Empty<WorldEntity>()));
var origin = new LiveWorldOriginState();
Assert.True(origin.TryInitialize(0x70, 0x70));
StreamingController? controller = null;
int clearCalls = 0;
var removed = new List<uint>();
controller = new StreamingController(
enqueueLoad: static (_, _) => { },
enqueueUnload: static _ => { },
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
applyTerrain: static (_, _) => { },
state,
nearRadius: 0,
farRadius: 0,
clearPendingLoads: () =>
{
clearCalls++;
Assert.False(controller!.IsOriginRecenterRetirementComplete());
},
removeTerrain: id =>
{
removed.Add(id);
if (id == firstId)
Assert.False(controller!.IsOriginRecenterRetirementComplete());
});
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.True(recenter.Begin(0x71, 0x71, isSealedDungeon: false));
Assert.Equal(1, clearCalls);
Assert.Equal([firstId, secondId], removed);
Assert.False(state.IsLoaded(firstId));
Assert.False(state.IsLoaded(secondId));
}
[Fact]
public void OriginRecenter_DefersRadiusChangeUntilDestinationCommit()
{
const uint oldId = 0x7272FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
oldId,
new LandBlock(),
Array.Empty<WorldEntity>()));
var origin = new LiveWorldOriginState();
Assert.True(origin.TryInitialize(0x72, 0x72));
var enqueued = new List<uint>();
bool failRetirement = true;
var controller = new StreamingController(
enqueueLoad: (id, _) => enqueued.Add(id),
enqueueUnload: static _ => { },
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
applyTerrain: static (_, _) => { },
state,
nearRadius: 0,
farRadius: 0,
removeTerrain: _ =>
{
if (failRetirement)
throw new InvalidOperationException("injected radius barrier failure");
});
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x73, 0x73, isSealedDungeon: false));
controller.ReconfigureRadii(nearRadius: 1, farRadius: 2);
controller.Tick(0x72, 0x72);
Assert.Equal(0, controller.NearRadius);
Assert.Equal(0, controller.FarRadius);
Assert.Empty(enqueued);
failRetirement = false;
Assert.True(recenter.Advance());
controller.Tick(0x73, 0x73);
Assert.Equal(1, controller.NearRadius);
Assert.Equal(2, controller.FarRadius);
Assert.Equal(25, enqueued.Count);
}
[Fact]
public void OriginRecenter_SealedDungeonPinsRadiusZeroBeforeCenterCompletion()
{
const uint oldId = 0x2020FFFFu;
const uint dungeonId = 0x3030FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
oldId,
new LandBlock(),
Array.Empty<WorldEntity>()));
var origin = new LiveWorldOriginState();
Assert.True(origin.TryInitialize(0x20, 0x20));
var outbox = new Queue<LandblockStreamResult>();
var enqueued = new List<(uint Id, LandblockStreamJobKind Kind)>();
bool failDungeonEnqueue = true;
int enqueueAttempts = 0;
int publications = 0;
var controller = new StreamingController(
enqueueLoad: (id, kind) =>
{
enqueueAttempts++;
if (failDungeonEnqueue)
throw new InvalidOperationException("injected dungeon enqueue failure");
enqueued.Add((id, kind));
},
enqueueUnload: static _ => { },
drainCompletions: max => Drain(outbox, max),
applyTerrain: (_, _) => publications++,
state,
nearRadius: 2,
farRadius: 4);
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x30, 0x30, isSealedDungeon: true));
Assert.True(recenter.IsPending);
controller.Tick(0x30, 0x30, insideDungeon: true);
Assert.Empty(enqueued);
failDungeonEnqueue = false;
Assert.True(recenter.Advance());
Assert.Equal(2, enqueueAttempts);
Assert.Equal([(dungeonId, LandblockStreamJobKind.LoadNear)], enqueued);
outbox.Enqueue(new LandblockStreamResult.Loaded(
dungeonId,
LandblockStreamTier.Near,
new LoadedLandblock(
dungeonId,
new LandBlock(),
Array.Empty<WorldEntity>()),
EmptyMesh(),
generation: 1));
controller.Tick(0x30, 0x30, insideDungeon: true);
Assert.Equal(1, publications);
Assert.True(state.IsNearTier(dungeonId));
}
[Fact]
@ -715,9 +1297,10 @@ public sealed class LandblockPresentationPipelineTests
envCells);
}
private static WorldEntity Entity(uint id) => new()
private static WorldEntity Entity(uint id, uint serverGuid = 0) => new()
{
Id = id,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x01000000u + id,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,