diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs
index 035016b0..31b9ebca 100644
--- a/src/AcDream.App/Rendering/CellVisibility.cs
+++ b/src/AcDream.App/Rendering/CellVisibility.cs
@@ -291,8 +291,8 @@ public sealed class CellVisibility
///
/// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a
- /// landblock prefix. Used by GameWindow.ApplyLoadedTerrainLocked when
- /// building the per-landblock BuildingRegistry — the per-frame
+ /// landblock prefix. Used by LandblockRenderPublisher when building
+ /// the per-landblock BuildingRegistry — the per-frame
/// drainedCells dict misses cells loaded on prior frames, so the
/// stamping loop in needs access to
/// every cell currently in the landblock to ensure BuildingId is set.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 2ef8d4c9..e6a816be 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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(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
///
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");
}
- ///
- /// 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 off the
- /// render thread via ;
- /// this callback no longer pays that CPU cost.
- /// Must only be called from the render thread.
- ///
- 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())
{
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;
}
diff --git a/src/AcDream.App/Streaming/LandblockBuildFactory.cs b/src/AcDream.App/Streaming/LandblockBuildFactory.cs
index 1fc1903a..6b46beea 100644
--- a/src/AcDream.App/Streaming/LandblockBuildFactory.cs
+++ b/src/AcDream.App/Streaming/LandblockBuildFactory.cs
@@ -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(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 . The render thread
/// cannot observe this landblock until the streaming completion carries the
- /// finished transaction to ApplyLoadedTerrain.
+ /// finished transaction to LandblockPresentationPipeline.
///
/// Ported from pre-streaming preload lines 407-565.
///
@@ -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)
{
diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs
index c13e21ea..d107db85 100644
--- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs
+++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs
@@ -326,9 +326,9 @@ public sealed class LandblockPhysicsPublisher
///
/// 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.
///
public void CompletePublication(
LandblockPhysicsPublication publication,
diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
index 640ebb6a..9139b85d 100644
--- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
+++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
@@ -4,14 +4,23 @@ using AcDream.Core.World;
namespace AcDream.App.Streaming;
+///
+/// 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.
+///
+public readonly record struct LandblockPresentationDiagnostics(
+ LandblockRenderPublisherDiagnostics Render,
+ LandblockPhysicsPublisherDiagnostics Physics,
+ LandblockStaticPresentationDiagnostics Statics);
+
///
/// Render-thread transaction coordinator for one accepted landblock streaming
/// result. Streaming policy (desired tier, generation, priority, and apply
/// budget) remains in ; spatial buckets remain
/// in . 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.
///
///
/// Retail establishes cells/buildings before static objects in
@@ -19,9 +28,9 @@ namespace AcDream.App.Streaming;
/// CLandBlock::release_all @ 0x0052FCF0 (objects and visible cells) from
/// later destruction in CLandBlock::Destroy @ 0x0052FAA0 (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.
///
public sealed class LandblockPresentationPipeline
{
@@ -63,7 +72,7 @@ public sealed class LandblockPresentationPipeline
private readonly Dictionary _publications =
new(ReferenceEqualityComparer.Instance);
- public LandblockPresentationPipeline(
+ internal LandblockPresentationPipeline(
Action publishBeforeSpatialCommit,
GpuWorldState state,
Action? onLandblockLoaded = null,
@@ -137,6 +146,21 @@ public sealed class LandblockPresentationPipeline
retirementOwner.Advance);
}
+ ///
+ /// Building visibility registries published by the render owner. Legacy
+ /// test pipelines have no render owner and therefore expose an empty view.
+ ///
+ public IReadOnlyCollection BuildingRegistries =>
+ _renderPublisher?.BuildingRegistries ?? Array.Empty();
+
+ ///
+ /// Cumulative diagnostics from the three concrete presentation owners.
+ ///
+ public LandblockPresentationDiagnostics Diagnostics => new(
+ _renderPublisher?.Diagnostics ?? default,
+ _physicsPublisher?.Diagnostics ?? default,
+ _staticPublisher?.Diagnostics ?? default);
+
public int PendingRetirementCount => _retirements.PendingCount;
public int PendingPublicationCount => _publications.Count;
diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs
index 0d76a543..0581647d 100644
--- a/src/AcDream.App/Streaming/StreamingController.cs
+++ b/src/AcDream.App/Streaming/StreamingController.cs
@@ -18,6 +18,21 @@ namespace AcDream.App.Streaming;
///
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? ResidentIds;
+ public int RetirementCursor;
+ public bool PreparationCommitted;
+ public (int X, int Y, bool IsSealedDungeon)? Destination;
+ public bool DestinationConfigured;
+ public bool DestinationLoadEnqueued;
+ }
+
private readonly Action _enqueueLoad;
private readonly Action _enqueueUnload;
private readonly Func> _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;
///
/// 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 _deferredApply = new();
- public StreamingController(
+ ///
+ /// Internal compatibility seam for hermetic controller policy tests. Live
+ /// composition cannot supply presentation callbacks; it must use the
+ /// concrete pipeline constructor below.
+ ///
+ internal StreamingController(
Action enqueueLoad,
Action enqueueUnload,
Func> 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
///
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
}
///
- /// 2026-06-22: an OUTDOOR teleport moved the render origin (_liveCenter). Every
- /// resident terrain block was baked relative to the OLD origin, so any block that survives
- /// an INCREMENTAL recenter — which happens on a NEARBY jump where the old and new windows
- /// overlap, e.g. (170,168)→(169,180) — renders shifted by the jump distance: the confirmed
- /// "terrain in the sky" arcs (the stale slots were all offset by exactly deltaLB×192).
- /// SYNCHRONOUSLY drop every resident landblock (render slot + physics + state) so none
- /// survives into the next frame stale, and no async unload can race a re-bake, then null
- /// the region so the next re-bootstraps the WHOLE window fresh at
- /// the new origin (the near ring is priority-applied during portal travel; the rest streams in).
- /// A sealed-dungeon destination uses instead.
+ /// 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
+ /// bootstraps the window again.
+ /// Shared-origin teleports use instead so
+ /// every old-window owner retires while the old coordinate frame is active.
///
public void ForceReloadWindow()
+ {
+ BeginFullWindowRetirement();
+ }
+
+ ///
+ /// 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. remains blocked until
+ /// succeeds.
+ ///
+ internal void BeginOriginRecenter()
+ {
+ _originRecenterRetirement ??= new OriginRecenterRetirement();
+ }
+
+ ///
+ /// Advances every retained old-window teardown and reports whether the
+ /// composition root may safely change the shared world origin.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// Releases a fully retired origin transaction at a session boundary
+ /// without bootstrapping a destination from the ending session.
+ ///
+ 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(_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(_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;
diff --git a/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs b/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs
new file mode 100644
index 00000000..73fd4c40
--- /dev/null
+++ b/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs
@@ -0,0 +1,192 @@
+using AcDream.App.World;
+
+namespace AcDream.App.Streaming;
+
+///
+/// Coordinates streaming-origin lifetime boundaries. Presentation must retire
+/// the complete old window first; a teleport then changes
+/// 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.
+///
+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;
+
+ ///
+ /// Begins a new recenter transaction and performs its first retirement
+ /// attempt immediately. A teleport replacement must call
+ /// before supplying a different destination.
+ ///
+ 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();
+ }
+
+ ///
+ /// Advances retained presentation teardown. Returns true only after the
+ /// new origin is committed and destination streaming has been unblocked.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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();
+ }
+}
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index a2c24799..a11d30b4 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -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
diff --git a/src/AcDream.Core/World/PhysicsDatBundle.cs b/src/AcDream.Core/World/PhysicsDatBundle.cs
index 3c7b68ed..42606e2b 100644
--- a/src/AcDream.Core/World/PhysicsDatBundle.cs
+++ b/src/AcDream.Core/World/PhysicsDatBundle.cs
@@ -6,11 +6,10 @@ using DatEnvironment = DatReaderWriter.DBObjs.Environment;
namespace AcDream.Core.World;
///
-/// The parsed dat objects ApplyLoadedTerrainLocked needs, pre-read by the
-/// streaming worker under _datLock 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 _dats.Get<T>.
+/// Parsed DAT closure produced by LandblockBuildFactory 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.
///
public sealed record PhysicsDatBundle(
LandBlockInfo? Info,
diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
index cce541e2..9321e78a 100644
--- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
+++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
@@ -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()));
+ 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(),
+ 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(plan.Execute);
+ LiveSessionResetStageException failure = Assert.IsType(
+ 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()
{
diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs
index 9ec20493..9553ea8a 100644
--- a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs
@@ -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;
diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs
index df731675..a7871301 100644
--- a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs
@@ -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()));
+ state.AddLandblock(new LoadedLandblock(
+ neighborId,
+ new LandBlock(),
+ Array.Empty()));
+ var origin = new LiveWorldOriginState();
+ Assert.True(origin.TryInitialize(0x19, 0x19));
+ var enqueued = new List();
+ bool failNeighbor = true;
+ int neighborAttempts = 0;
+ var controller = new StreamingController(
+ enqueueLoad: (id, _) => enqueued.Add(id),
+ enqueueUnload: static _ => { },
+ drainCompletions: static _ => Array.Empty(),
+ 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()));
+ 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(),
+ 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()));
+ 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();
+ var controller = new StreamingController(
+ enqueueLoad: static (_, _) => { },
+ enqueueUnload: static _ => { },
+ drainCompletions: static _ => Array.Empty(),
+ 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 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(),
+ 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()));
+ state.RebucketLiveEntity(player, destinationId);
+ Assert.Same(player, Assert.Single(state.Entities));
+
+ state.AddLandblock(new LoadedLandblock(
+ oldId,
+ new LandBlock(),
+ Array.Empty()));
+ 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()));
+ 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(),
+ 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()));
+ 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(),
+ 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()));
+ var origin = new LiveWorldOriginState();
+ Assert.True(origin.TryInitialize(0x53, 0x53));
+ var enqueued = new List();
+ bool failRetirement = true;
+ var controller = new StreamingController(
+ enqueueLoad: (id, _) => enqueued.Add(id),
+ enqueueUnload: static _ => { },
+ drainCompletions: static _ => Array.Empty(),
+ 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()));
+ 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(),
+ 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(),
+ 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()));
+ state.AddLandblock(new LoadedLandblock(
+ secondId,
+ new LandBlock(),
+ Array.Empty()));
+ var origin = new LiveWorldOriginState();
+ Assert.True(origin.TryInitialize(0x70, 0x70));
+ StreamingController? controller = null;
+ int clearCalls = 0;
+ var removed = new List();
+ controller = new StreamingController(
+ enqueueLoad: static (_, _) => { },
+ enqueueUnload: static _ => { },
+ drainCompletions: static _ => Array.Empty(),
+ 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()));
+ var origin = new LiveWorldOriginState();
+ Assert.True(origin.TryInitialize(0x72, 0x72));
+ var enqueued = new List();
+ bool failRetirement = true;
+ var controller = new StreamingController(
+ enqueueLoad: (id, _) => enqueued.Add(id),
+ enqueueUnload: static _ => { },
+ drainCompletions: static _ => Array.Empty(),
+ 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()));
+ var origin = new LiveWorldOriginState();
+ Assert.True(origin.TryInitialize(0x20, 0x20));
+ var outbox = new Queue();
+ 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()),
+ 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,