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:
parent
801d8a189c
commit
c79d0a49da
12 changed files with 1373 additions and 402 deletions
|
|
@ -291,8 +291,8 @@ public sealed class CellVisibility
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a
|
/// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a
|
||||||
/// landblock prefix. Used by <c>GameWindow.ApplyLoadedTerrainLocked</c> when
|
/// landblock prefix. Used by <c>LandblockRenderPublisher</c> when building
|
||||||
/// building the per-landblock <c>BuildingRegistry</c> — the per-frame
|
/// the per-landblock <c>BuildingRegistry</c> — the per-frame
|
||||||
/// <c>drainedCells</c> dict misses cells loaded on prior frames, so the
|
/// <c>drainedCells</c> dict misses cells loaded on prior frames, so the
|
||||||
/// stamping loop in <see cref="Wb.BuildingLoader.Build"/> needs access to
|
/// stamping loop in <see cref="Wb.BuildingLoader.Build"/> needs access to
|
||||||
/// every cell currently in the landblock to ensure <c>BuildingId</c> is set.
|
/// every cell currently in the landblock to ensure <c>BuildingId</c> is set.
|
||||||
|
|
|
||||||
|
|
@ -93,46 +93,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
private int _terrainCpuSampleCursor;
|
private int _terrainCpuSampleCursor;
|
||||||
private long _terrainLastDiagTick;
|
private long _terrainLastDiagTick;
|
||||||
|
|
||||||
// [FRAME-DIAG] (ACDREAM_WB_DIAG=1): per-frame cost split for the FPS deep-dive.
|
// [FRAME-DIAG]: retain only the render-thread entity upload distribution.
|
||||||
// The crux this measures: terrain APPLY runs in OnUpdate (under _datLock —
|
// Landblock publication timing/counts live with the focused publishers and
|
||||||
// dat reads + physics/ShadowObjects/BSP registration + the terrain GPU
|
// are read from their typed snapshots when diagnostics flush.
|
||||||
// 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.
|
|
||||||
private readonly bool _frameDiag = string.Equals(
|
private readonly bool _frameDiag = string.Equals(
|
||||||
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", System.StringComparison.Ordinal);
|
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"),
|
||||||
private long _applyAccumTicks; // apply CPU accumulated across this OnUpdate
|
"1",
|
||||||
private long _applyUploadAccumTicks; // terrain glBufferSubData sub-span this OnUpdate
|
System.StringComparison.Ordinal);
|
||||||
private int _appliesThisUpdate; // # landblock LOADS applied this OnUpdate
|
private readonly long[] _entityUploadSamples = new long[256];
|
||||||
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;
|
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;
|
|
||||||
|
|
||||||
// MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call
|
// MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call
|
||||||
// per OnRender + three stage scopes. All logic lives in
|
// 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.
|
// Phase A.1: streaming fields replacing the one-shot _entities list.
|
||||||
private AcDream.App.Streaming.LandblockStreamer? _streamer;
|
private AcDream.App.Streaming.LandblockStreamer? _streamer;
|
||||||
private AcDream.App.Streaming.GpuWorldState _worldState = new();
|
private AcDream.App.Streaming.GpuWorldState _worldState = new();
|
||||||
private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher;
|
private AcDream.App.Streaming.LandblockPresentationPipeline?
|
||||||
private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher;
|
_landblockPresentationPipeline;
|
||||||
private AcDream.App.Streaming.LandblockStaticPresentationPublisher?
|
|
||||||
_landblockStaticPresentationPublisher;
|
|
||||||
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
|
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
|
||||||
private AcDream.App.Streaming.StreamingController? _streamingController;
|
private AcDream.App.Streaming.StreamingController? _streamingController;
|
||||||
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
|
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
|
||||||
|
|
@ -1630,9 +1597,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
|
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
|
||||||
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
|
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
|
||||||
|
|
||||||
// Build blending context from the terrain atlas. Stored as fields so
|
// Build blending context from the terrain atlas. The worker-side
|
||||||
// ApplyLoadedTerrain (render-thread callback invoked per streamed lb)
|
// LandblockBuildFactory captures this immutable table for each build.
|
||||||
// can call LandblockMesh.Build without re-deriving these every time.
|
|
||||||
var terrainTypeToLayerBytes = new Dictionary<uint, byte>(terrainAtlas.TerrainTypeToLayer.Count);
|
var terrainTypeToLayerBytes = new Dictionary<uint, byte>(terrainAtlas.TerrainTypeToLayer.Count);
|
||||||
foreach (var kvp in terrainAtlas.TerrainTypeToLayer)
|
foreach (var kvp in terrainAtlas.TerrainTypeToLayer)
|
||||||
terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value;
|
terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value;
|
||||||
|
|
@ -2410,7 +2376,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
_envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer(
|
_envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer(
|
||||||
_gl, _wbMeshAdapter!.MeshManager!, _envCellFrustum);
|
_gl, _wbMeshAdapter!.MeshManager!, _envCellFrustum);
|
||||||
_envCellRenderer.Initialize(_meshShader!);
|
_envCellRenderer.Initialize(_meshShader!);
|
||||||
_landblockRenderPublisher = new AcDream.App.Streaming.LandblockRenderPublisher(
|
var landblockRenderPublisher = new AcDream.App.Streaming.LandblockRenderPublisher(
|
||||||
publishTerrain: (landblockId, meshData, origin) =>
|
publishTerrain: (landblockId, meshData, origin) =>
|
||||||
_terrain!.AddLandblockWithMesh(landblockId, meshData, origin),
|
_terrain!.AddLandblockWithMesh(landblockId, meshData, origin),
|
||||||
removeTerrain: landblockId => _terrain!.RemoveLandblock(landblockId),
|
removeTerrain: landblockId => _terrain!.RemoveLandblock(landblockId),
|
||||||
|
|
@ -2422,16 +2388,34 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
build,
|
build,
|
||||||
_wbMeshAdapter.MeshManager!),
|
_wbMeshAdapter.MeshManager!),
|
||||||
removeEnvCells: _envCellRenderer.RemoveLandblock);
|
removeEnvCells: _envCellRenderer.RemoveLandblock);
|
||||||
_landblockPhysicsPublisher =
|
var landblockPhysicsPublisher =
|
||||||
new AcDream.App.Streaming.LandblockPhysicsPublisher(
|
new AcDream.App.Streaming.LandblockPhysicsPublisher(
|
||||||
_physicsEngine,
|
_physicsEngine,
|
||||||
_heightTable!);
|
_heightTable!);
|
||||||
_landblockStaticPresentationPublisher =
|
var landblockStaticPresentationPublisher =
|
||||||
new AcDream.App.Streaming.LandblockStaticPresentationPublisher(
|
new AcDream.App.Streaming.LandblockStaticPresentationPublisher(
|
||||||
_lightingSink!,
|
_lightingSink!,
|
||||||
_translucencyFades,
|
_translucencyFades,
|
||||||
_worldGameState,
|
_worldGameState,
|
||||||
_worldEvents);
|
_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();
|
_clipFrame ??= ClipFrame.NoClip();
|
||||||
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(
|
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(
|
||||||
|
|
@ -2523,24 +2507,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
});
|
});
|
||||||
_streamer.Start();
|
_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(
|
_streamingController = new AcDream.App.Streaming.StreamingController(
|
||||||
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(
|
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(
|
||||||
new AcDream.App.Streaming.LandblockBuildRequest(
|
new AcDream.App.Streaming.LandblockBuildRequest(
|
||||||
|
|
@ -2559,7 +2525,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
// Retained-object recovery runs after each
|
// Retained-object recovery runs after each
|
||||||
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the
|
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the
|
||||||
// objects it still considers known.
|
// 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.
|
// A.5 T22.5: apply max-completions from resolved quality.
|
||||||
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
|
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
|
||||||
_worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator(
|
_worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator(
|
||||||
|
|
@ -3297,6 +3267,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
private readonly TeleportViewPlaneController _teleportViewPlane = new();
|
private readonly TeleportViewPlaneController _teleportViewPlane = new();
|
||||||
private readonly AcDream.App.Streaming.TeleportTransitCoordinator<
|
private readonly AcDream.App.Streaming.TeleportTransitCoordinator<
|
||||||
AcDream.Core.Net.WorldSession.EntityPositionUpdate> _teleportTransit = new();
|
AcDream.Core.Net.WorldSession.EntityPositionUpdate> _teleportTransit = new();
|
||||||
|
private AcDream.App.Streaming.StreamingOriginRecenterCoordinator?
|
||||||
|
_streamingOriginRecenter;
|
||||||
private System.Numerics.Vector3 _pendingTeleportPos;
|
private System.Numerics.Vector3 _pendingTeleportPos;
|
||||||
private uint _pendingTeleportCell;
|
private uint _pendingTeleportCell;
|
||||||
private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout)
|
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;
|
System.Numerics.Vector3 newWorldPos;
|
||||||
if (streamingCenterChanges)
|
if (streamingCenterChanges)
|
||||||
{
|
{
|
||||||
// #145: drop the stale STREAMING center landblock from the physics engine
|
// #145: retire the complete old streaming window while its shared
|
||||||
// BEFORE recentering. The destination loads at world-offset (0,0) (the new
|
// origin is still current. Destination streaming remains blocked
|
||||||
// center), but the prior center was ALSO loaded at offset (0,0) and its
|
// until every retained presentation ticket converges. This prevents
|
||||||
// offset is never re-based — so the two overlap, and the Z-agnostic outdoor
|
// old terrain and collision, which were baked in the prior frame,
|
||||||
// cell-snap resolves the player into the stale center. On a replacement this
|
// from overlapping the destination after the origin changes.
|
||||||
// can be the abandoned first destination, not the unplaced player's source.
|
var recenter = _streamingOriginRecenter
|
||||||
_landblockPhysicsPublisher!.RemoveLandblock(streamingOriginLandblockId);
|
?? throw new InvalidOperationException(
|
||||||
|
"A teleport changed streaming center before the recenter coordinator was wired.");
|
||||||
_liveWorldOrigin.Recenter(lbX, lbY);
|
recenter.Begin(
|
||||||
|
lbX,
|
||||||
|
lbY,
|
||||||
|
IsSealedDungeonCell(p.LandblockId));
|
||||||
newWorldPos = new System.Numerics.Vector3(
|
newWorldPos = new System.Numerics.Vector3(
|
||||||
p.PositionX, p.PositionY, p.PositionZ);
|
p.PositionX, p.PositionY, p.PositionZ);
|
||||||
|
|
||||||
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
|
|
||||||
_streamingController.PreCollapseToDungeon(lbX, lbY);
|
|
||||||
else
|
|
||||||
_streamingController?.ForceReloadWindow();
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -3418,6 +3389,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void ResetTeleportTransitState(bool clearSession = false)
|
private void ResetTeleportTransitState(bool clearSession = false)
|
||||||
{
|
{
|
||||||
|
bool originRecenterConverged =
|
||||||
|
_streamingOriginRecenter?.Reset(sessionEnding: clearSession) ?? true;
|
||||||
if (clearSession)
|
if (clearSession)
|
||||||
{
|
{
|
||||||
_teleportTransit.ClearSession();
|
_teleportTransit.ClearSession();
|
||||||
|
|
@ -3440,6 +3413,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
_streamingController.PriorityLandblockId = 0u;
|
_streamingController.PriorityLandblockId = 0u;
|
||||||
_streamingController.PriorityRadius = 0;
|
_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
|
// #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} " +
|
$"live: AdminEnvirons sound cue = {name} " +
|
||||||
$"(0x{environChangeType:X2}) — audio binding pending");
|
$"(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)
|
private void OnUpdate(double dt)
|
||||||
{
|
{
|
||||||
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update);
|
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update);
|
||||||
|
|
@ -4009,31 +3811,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
_physicsScriptGameTime += frameSeconds;
|
_physicsScriptGameTime += frameSeconds;
|
||||||
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
_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
|
// Phase A.1: advance the streaming controller FIRST so the initial
|
||||||
// landblocks are loaded into GpuWorldState before live-session
|
// landblocks are loaded into GpuWorldState before live-session
|
||||||
// CreateObject events drain. The earlier order (live tick first,
|
// 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
|
// The world-geometry RENDER gate (IsLiveModeWaitingForLogin at the
|
||||||
// draw site) is unchanged — the pre-entry screen still shows sky
|
// draw site) is unchanged — the pre-entry screen still shows sky
|
||||||
// only.
|
// 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;
|
bool liveInWorld = _liveSessionController?.IsInWorld == true;
|
||||||
// #192: liveInWorld alone used to be sufficient here — but InWorld fires
|
// #192: liveInWorld alone used to be sufficient here — but InWorld fires
|
||||||
// right after the login handshake, before the player's own spawn
|
// 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)
|
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace)
|
||||||
{
|
{
|
||||||
// Teleport hold (#135): the local player position is frozen at the
|
// Teleport hold (#135): the local player position is frozen at the
|
||||||
// PRE-teleport spot, expressed in the OLD center frame, but
|
// PRE-teleport spot, expressed in the OLD center frame. The
|
||||||
// _liveCenterX/_liveCenterY were already recentered onto the
|
// origin coordinator commits _liveCenterX/Y to the destination
|
||||||
// destination landblock (OnLivePositionUpdated). Follow the
|
// only after old-window presentation retirement converges; the
|
||||||
// destination directly — the stale position-derived offset
|
// 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
|
// (_liveCenterX + floor(frozenPos/192)) could land ≥2 landblocks off
|
||||||
// the dungeon and trip ExitDungeonExpand, re-streaming the very
|
// the dungeon and trip ExitDungeonExpand, re-streaming the very
|
||||||
// neighbor window the pre-collapse just suppressed. Correct for an
|
// neighbor window the pre-collapse just suppressed. Correct for an
|
||||||
|
|
@ -4266,8 +4050,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
if (_teleportTransit.IsActive)
|
if (_teleportTransit.IsActive)
|
||||||
{
|
{
|
||||||
bool haveDest = _pendingTeleportCell != 0u;
|
bool haveDest = _pendingTeleportCell != 0u;
|
||||||
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
|
bool originReady = _streamingOriginRecenter?.IsPending != true;
|
||||||
if (haveDest && !ready)
|
bool ready = haveDest
|
||||||
|
&& originReady
|
||||||
|
&& TeleportWorldReady(_pendingTeleportCell);
|
||||||
|
if (haveDest && originReady && !ready)
|
||||||
{
|
{
|
||||||
_teleportHoldSeconds += frameDelta;
|
_teleportHoldSeconds += frameDelta;
|
||||||
if (_teleportHoldSeconds >= TeleportMaxHoldSeconds)
|
if (_teleportHoldSeconds >= TeleportMaxHoldSeconds)
|
||||||
|
|
@ -5059,7 +4846,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
// routes interior-root look-ins to its landscape-stage sub-pass
|
// routes interior-root look-ins to its landscape-stage sub-pass
|
||||||
// (DrawBuildingLookIns); the root's own building self-excludes
|
// (DrawBuildingLookIns); the root's own building self-excludes
|
||||||
// via the seed eye-side test.
|
// 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>())
|
?? Array.Empty<AcDream.App.Rendering.Wb.BuildingRegistry>())
|
||||||
{
|
{
|
||||||
foreach (var b in registry.All())
|
foreach (var b in registry.All())
|
||||||
|
|
@ -8712,43 +8499,38 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
$"loaded={_terrain?.LoadedSlots ?? 0} " +
|
$"loaded={_terrain?.LoadedSlots ?? 0} " +
|
||||||
$"capacity={_terrain?.CapacitySlots ?? 0}");
|
$"capacity={_terrain?.CapacitySlots ?? 0}");
|
||||||
|
|
||||||
// [FRAME-DIAG]: the FPS-deep-dive per-frame cost split + leak/churn counters.
|
// Publication metrics are cumulative typed snapshots from the focused
|
||||||
// apply_us = terrain-apply CPU in OnUpdate (dat reads + physics/ShadowObjects
|
// owners. The only GameWindow-local distribution left is the render-
|
||||||
// registration + terrain upload) — INVISIBLE to the title-bar ms (H1).
|
// thread entity upload drain.
|
||||||
// upl_us = the terrain glBufferSubData sub-span of that apply (expected tiny).
|
AcDream.App.Streaming.LandblockPresentationDiagnostics presentation =
|
||||||
// entUpl_us= the OnRender _wbMeshAdapter.Tick entity-mesh GPU drain (H2).
|
_landblockPresentationPipeline?.Diagnostics ?? default;
|
||||||
// For entity-DRAW CPU/GPU see the [WB-DIAG] line; walked = resident-N proxy (H3).
|
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics render =
|
||||||
double applyMedUs = TerrainDiagMedianMicros(_applyCpuSamples) / 100.0;
|
presentation.Render;
|
||||||
double applyP95Us = TerrainDiagPercentile95Micros(_applyCpuSamples) / 100.0;
|
AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physics =
|
||||||
double uplMedUs = TerrainDiagMedianMicros(_applyUploadSamples) / 100.0;
|
presentation.Physics;
|
||||||
double uplP95Us = TerrainDiagPercentile95Micros(_applyUploadSamples) / 100.0;
|
AcDream.App.Streaming.LandblockStaticPresentationDiagnostics statics =
|
||||||
|
presentation.Statics;
|
||||||
|
double ticksToMicros =
|
||||||
|
1_000_000.0 / System.Diagnostics.Stopwatch.Frequency;
|
||||||
double entUplMedUs = TerrainDiagMedianMicros(_entityUploadSamples) / 100.0;
|
double entUplMedUs = TerrainDiagMedianMicros(_entityUploadSamples) / 100.0;
|
||||||
double entUplP95Us = TerrainDiagPercentile95Micros(_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;
|
int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0;
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"[FRAME-DIAG] apply_us={applyMedUs:F1}m/{applyP95Us:F1}p95 " +
|
$"[FRAME-DIAG] publish={render.BeginCount}/{render.CompleteCount} " +
|
||||||
$"lockwait={lockMedUs:F1}m/{lockP95Us:F1}p95 " +
|
$"render_total_us=[terrain={render.TerrainPublishTicks * ticksToMicros:F1} " +
|
||||||
$"[cell={cellMedUs:F1}m/{cellP95Us:F1}p95 bsp={bspMedUs:F1}m/{bspP95Us:F1}p95 " +
|
$"begin={render.BeginPublishTicks * ticksToMicros:F1} " +
|
||||||
$"shadow={shadMedUs:F1}m/{shadP95Us:F1}p95] " +
|
$"complete={render.CompletePublishTicks * ticksToMicros:F1}] " +
|
||||||
$"(upl={uplMedUs:F1}m/{uplP95Us:F1}p95) " +
|
$"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 " +
|
$"entUpl_us={entUplMedUs:F1}m/{entUplP95Us:F1}p95 " +
|
||||||
$"applies_max/upd={_frameDiagMaxAppliesPerUpdate} " +
|
|
||||||
$"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " +
|
$"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " +
|
||||||
$"forceReload={_streamingController?.ForceReloadCount ?? 0}" +
|
$"fullRetire={_streamingController?.FullWindowRetirementCount ?? 0}" +
|
||||||
$"(drop={_streamingController?.LastForceReloadDropCount ?? 0}) " +
|
$"(landblocks={_streamingController?.LastFullWindowRetirementLandblockCount ?? 0}) " +
|
||||||
$"esg={_entitiesByServerGuid.Count} spawn={LastSpawns.Count} " +
|
$"esg={_entitiesByServerGuid.Count} spawn={LastSpawns.Count} " +
|
||||||
$"resident={_worldState.Entities.Count} walked={walked}");
|
$"resident={_worldState.Entities.Count} walked={walked}");
|
||||||
_frameDiagMaxAppliesPerUpdate = 0; // reset the per-window burst tracker
|
|
||||||
|
|
||||||
_terrainLastDiagTick = now;
|
_terrainLastDiagTick = now;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,8 +129,8 @@ public sealed class LandblockBuildFactory
|
||||||
// Hydrate the stabs: same logic as the old OnLoad preload. Each stab
|
// Hydrate the stabs: same logic as the old OnLoad preload. Each stab
|
||||||
// entity from LandblockLoader carries a SourceGfxObjOrSetupId that we
|
// entity from LandblockLoader carries a SourceGfxObjOrSetupId that we
|
||||||
// expand into per-part MeshRefs via SetupMesh.Flatten / GfxObjMesh.Build.
|
// expand into per-part MeshRefs via SetupMesh.Flatten / GfxObjMesh.Build.
|
||||||
// GPU upload (EnsureUploaded) happens on the render thread in
|
// GPU upload happens later through the render-thread presentation
|
||||||
// ApplyLoadedTerrain — NOT here.
|
// pipeline, never in this worker-side build.
|
||||||
var hydrated = new List<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count);
|
var hydrated = new List<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count);
|
||||||
foreach (var e in baseLoaded.Entities)
|
foreach (var e in baseLoaded.Entities)
|
||||||
{
|
{
|
||||||
|
|
@ -364,7 +364,8 @@ public sealed class LandblockBuildFactory
|
||||||
if (gfx is not null)
|
if (gfx is not null)
|
||||||
{
|
{
|
||||||
_physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx);
|
_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);
|
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||||
if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value);
|
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
|
// _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at
|
||||||
// build time this landblock is NOT registered in physics yet, so that query
|
// 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
|
// could only return null (→ this same own-heightmap) or a STALE neighbor's
|
||||||
// height — the previous location's terrain, still registered after a teleport
|
// height — the previous location's terrain before the full old-window
|
||||||
// recenter (which drops only the single stale CENTER landblock, GameWindow
|
// recenter retirement converges — planting scenery at the old location's
|
||||||
// :5444) until streaming unloads it — planting scenery at the old location's
|
|
||||||
// altitude (trees-in-sky, deltaZ up to +500m; confirmed via the
|
// altitude (trees-in-sky, deltaZ up to +500m; confirmed via the
|
||||||
// [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every
|
// [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).
|
// 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
|
/// Portal cells and drawable shell placements are accumulated in the
|
||||||
/// transaction-local <paramref name="envCellBuild"/>. The render thread
|
/// transaction-local <paramref name="envCellBuild"/>. The render thread
|
||||||
/// cannot observe this landblock until the streaming completion carries the
|
/// 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.
|
/// Ported from pre-streaming preload lines 407-565.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -683,8 +683,8 @@ public sealed class LandblockBuildFactory
|
||||||
// Setup carries real Lights — a "light attach point" fixture (e.g. the Town
|
// Setup carries real Lights — a "light attach point" fixture (e.g. the Town
|
||||||
// Network fountain room's ceiling light, Setup 0x02000365). Track the dat
|
// 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
|
// 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
|
// the entity that otherwise carries those lights to the static
|
||||||
// pass (GameWindow.cs ~7900).
|
// presentation publisher.
|
||||||
int stabLightCount = 0;
|
int stabLightCount = 0;
|
||||||
if ((stab.Id & 0xFF000000u) == 0x01000000u)
|
if ((stab.Id & 0xFF000000u) == 0x01000000u)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -326,9 +326,9 @@ public sealed class LandblockPhysicsPublisher
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Publishes ordinary static collision and refloods after the caller has
|
/// Publishes ordinary static collision and refloods after the caller has
|
||||||
/// completed the render-owned building/EnvCell suffix. The callback keeps
|
/// completed the render-owned building/EnvCell suffix. The concrete
|
||||||
/// the shipped per-entity light-before-collision order until checkpoint E
|
/// presentation pipeline supplies the static-presentation owner callback
|
||||||
/// moves static presentation into the transaction coordinator.
|
/// that preserves per-entity light-before-collision order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void CompletePublication(
|
public void CompletePublication(
|
||||||
LandblockPhysicsPublication publication,
|
LandblockPhysicsPublication publication,
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,23 @@ using AcDream.Core.World;
|
||||||
|
|
||||||
namespace AcDream.App.Streaming;
|
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>
|
/// <summary>
|
||||||
/// Render-thread transaction coordinator for one accepted landblock streaming
|
/// Render-thread transaction coordinator for one accepted landblock streaming
|
||||||
/// result. Streaming policy (desired tier, generation, priority, and apply
|
/// result. Streaming policy (desired tier, generation, priority, and apply
|
||||||
/// budget) remains in <see cref="StreamingController"/>; spatial buckets remain
|
/// budget) remains in <see cref="StreamingController"/>; spatial buckets remain
|
||||||
/// in <see cref="GpuWorldState"/>. This owner fixes the presentation ordering
|
/// in <see cref="GpuWorldState"/>. This owner fixes the presentation ordering
|
||||||
/// independently of those policies so the concrete render, physics, and static
|
/// independently of those policies. The concrete render, physics, and static
|
||||||
/// publishers can be extracted behind it without returning callbacks to the
|
/// publishers remain private implementation participants behind this boundary.
|
||||||
/// game window.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Retail establishes cells/buildings before static objects in
|
/// 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
|
/// <c>CLandBlock::release_all @ 0x0052FCF0</c> (objects and visible cells) from
|
||||||
/// later destruction in <c>CLandBlock::Destroy @ 0x0052FAA0</c> (static objects
|
/// later destruction in <c>CLandBlock::Destroy @ 0x0052FAA0</c> (static objects
|
||||||
/// and buildings). Acdream's detach-first retry ledger is the existing
|
/// and buildings). Acdream's detach-first retry ledger is the existing
|
||||||
/// asynchronous lifetime adaptation. The injected publication operation
|
/// asynchronous lifetime adaptation. Production always uses the focused owner
|
||||||
/// currently contains the already-shipped production transaction; later
|
/// graph; the internal delegate constructor exists only for hermetic policy and
|
||||||
/// Slice-5 checkpoints replace it with focused publishers.
|
/// retry characterization tests.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class LandblockPresentationPipeline
|
public sealed class LandblockPresentationPipeline
|
||||||
{
|
{
|
||||||
|
|
@ -63,7 +72,7 @@ public sealed class LandblockPresentationPipeline
|
||||||
private readonly Dictionary<LandblockStreamResult, PublicationTransaction> _publications =
|
private readonly Dictionary<LandblockStreamResult, PublicationTransaction> _publications =
|
||||||
new(ReferenceEqualityComparer.Instance);
|
new(ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
public LandblockPresentationPipeline(
|
internal LandblockPresentationPipeline(
|
||||||
Action<LandblockBuild, LandblockMeshData> publishBeforeSpatialCommit,
|
Action<LandblockBuild, LandblockMeshData> publishBeforeSpatialCommit,
|
||||||
GpuWorldState state,
|
GpuWorldState state,
|
||||||
Action<uint>? onLandblockLoaded = null,
|
Action<uint>? onLandblockLoaded = null,
|
||||||
|
|
@ -137,6 +146,21 @@ public sealed class LandblockPresentationPipeline
|
||||||
retirementOwner.Advance);
|
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 PendingRetirementCount => _retirements.PendingCount;
|
||||||
public int PendingPublicationCount => _publications.Count;
|
public int PendingPublicationCount => _publications.Count;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,21 @@ namespace AcDream.App.Streaming;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class StreamingController
|
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, LandblockStreamJobKind, ulong> _enqueueLoad;
|
||||||
private readonly Action<uint, ulong> _enqueueUnload;
|
private readonly Action<uint, ulong> _enqueueUnload;
|
||||||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||||||
|
|
@ -29,6 +44,8 @@ public sealed class StreamingController
|
||||||
private RadiiReconfiguration? _pendingRadiiReconfiguration;
|
private RadiiReconfiguration? _pendingRadiiReconfiguration;
|
||||||
private bool _advancingRadiiReconfiguration;
|
private bool _advancingRadiiReconfiguration;
|
||||||
private (int NearRadius, int FarRadius)? _deferredRadiiRequest;
|
private (int NearRadius, int FarRadius)? _deferredRadiiRequest;
|
||||||
|
private OriginRecenterRetirement? _originRecenterRetirement;
|
||||||
|
private bool _advancingOriginRecenter;
|
||||||
// Hard streaming boundaries advance this token. Worker completions carry
|
// Hard streaming boundaries advance this token. Worker completions carry
|
||||||
// the generation captured at enqueue time, so an old overlapping load or
|
// the generation captured at enqueue time, so an old overlapping load or
|
||||||
// unload cannot mutate the replacement window after a portal recenter.
|
// 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.
|
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
|
||||||
public int DeferredApplyBacklog => _deferredApply.Count;
|
public int DeferredApplyBacklog => _deferredApply.Count;
|
||||||
public int PendingRetirementCount => _presentation.PendingRetirementCount;
|
public int PendingRetirementCount => _presentation.PendingRetirementCount;
|
||||||
|
internal bool IsCollapsedToDungeon => _collapsed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True once every in-bounds landblock in the requested Chebyshev ring has
|
/// True once every in-bounds landblock in the requested Chebyshev ring has
|
||||||
|
|
@ -154,15 +172,21 @@ public sealed class StreamingController
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// [FRAME-DIAG]: how many times ForceReloadWindow has fired (each one drops +
|
// [FRAME-DIAG]: full-window presentation retirements include explicit
|
||||||
// re-uploads the WHOLE window) and the landblock count it dropped last time.
|
// reloads and shared-origin recenter barriers. Both retire and later
|
||||||
public int ForceReloadCount { get; private set; }
|
// re-upload the complete resident window.
|
||||||
public int LastForceReloadDropCount { get; private set; }
|
public int FullWindowRetirementCount { get; private set; }
|
||||||
|
public int LastFullWindowRetirementLandblockCount { get; private set; }
|
||||||
|
|
||||||
// Completions that were drained past a priority item get buffered here
|
// Completions that were drained past a priority item get buffered here
|
||||||
// so they still apply over subsequent frames without loss.
|
// so they still apply over subsequent frames without loss.
|
||||||
private readonly List<LandblockStreamResult> _deferredApply = new();
|
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, LandblockStreamJobKind, ulong> enqueueLoad,
|
||||||
Action<uint, ulong> enqueueUnload,
|
Action<uint, ulong> enqueueUnload,
|
||||||
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
||||||
|
|
@ -245,6 +269,15 @@ public sealed class StreamingController
|
||||||
nameof(farRadius),
|
nameof(farRadius),
|
||||||
"Far radius must be greater than or equal to near radius.");
|
"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
|
// Queue callbacks are injected seams and can synchronously reenter the
|
||||||
// controller. Last-request-wins deferral keeps the active mutation
|
// controller. Last-request-wins deferral keeps the active mutation
|
||||||
// cursor stable; the request is applied after the current transaction
|
// cursor stable; the request is applied after the current transaction
|
||||||
|
|
@ -427,6 +460,12 @@ public sealed class StreamingController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
|
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
|
||||||
{
|
{
|
||||||
|
if (_originRecenterRetirement is not null)
|
||||||
|
{
|
||||||
|
_presentation.AdvanceRetirements();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_pendingRadiiReconfiguration is not null)
|
if (_pendingRadiiReconfiguration is not null)
|
||||||
AdvanceRadiiReconfiguration();
|
AdvanceRadiiReconfiguration();
|
||||||
else if (_deferredRadiiRequest is { } deferred)
|
else if (_deferredRadiiRequest is { } deferred)
|
||||||
|
|
@ -762,18 +801,262 @@ public sealed class StreamingController
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 2026-06-22: an OUTDOOR teleport moved the render origin (<c>_liveCenter</c>). Every
|
/// Starts a reload of the current streaming window. Logical residency is
|
||||||
/// resident terrain block was baked relative to the OLD origin, so any block that survives
|
/// detached immediately; render, physics, and static-lighting owners then
|
||||||
/// an INCREMENTAL recenter — which happens on a NEARBY jump where the old and new windows
|
/// converge through the retained presentation-retirement ledger before
|
||||||
/// overlap, e.g. (170,168)→(169,180) — renders shifted by the jump distance: the confirmed
|
/// <see cref="NormalTick"/> bootstraps the window again.
|
||||||
/// "terrain in the sky" arcs (the stale slots were all offset by exactly deltaLB×192).
|
/// Shared-origin teleports use <see cref="BeginOriginRecenter"/> instead so
|
||||||
/// SYNCHRONOUSLY drop every resident landblock (render slot + physics + state) so none
|
/// every old-window owner retires while the old coordinate frame is active.
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void ForceReloadWindow()
|
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();
|
AdvanceGeneration();
|
||||||
_collapsed = false;
|
_collapsed = false;
|
||||||
|
|
@ -785,8 +1068,8 @@ public sealed class StreamingController
|
||||||
_region = null;
|
_region = null;
|
||||||
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
|
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
|
||||||
var ids = new List<uint>(_state.LoadedLandblockIds);
|
var ids = new List<uint>(_state.LoadedLandblockIds);
|
||||||
ForceReloadCount++; // [FRAME-DIAG] churn counter
|
FullWindowRetirementCount++; // [FRAME-DIAG] churn counter
|
||||||
LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume
|
LastFullWindowRetirementLandblockCount = ids.Count; // upcoming re-upload volume
|
||||||
foreach (var id in ids)
|
foreach (var id in ids)
|
||||||
_presentation.BeginFullRetirement(id);
|
_presentation.BeginFullRetirement(id);
|
||||||
}
|
}
|
||||||
|
|
@ -838,14 +1121,13 @@ public sealed class StreamingController
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// The presentation pipeline retains its exact committed
|
// The concrete presentation pipeline retains the exact
|
||||||
// stage after the coarse presentation callback commits.
|
// unfinished stage. Preserve it and every untouched
|
||||||
// Preserve it and every untouched completion from this
|
// completion from this already-drained chunk in
|
||||||
// already-drained chunk in original FIFO order. A
|
// original FIFO order. Internal compatibility tests may
|
||||||
// presentation-stage failure is deliberately fail-fast
|
// use a callback pipeline that cannot retain a receipt;
|
||||||
// until the focused publishers replace that callback;
|
// its failed result is consumed, but the untouched
|
||||||
// its result is not retryable, but the untouched suffix
|
// suffix must still survive.
|
||||||
// must still survive.
|
|
||||||
if (_presentation.HasPendingPublication(result))
|
if (_presentation.HasPendingPublication(result))
|
||||||
_deferredApply.Add(result);
|
_deferredApply.Add(result);
|
||||||
for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++)
|
for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++)
|
||||||
|
|
@ -890,12 +1172,12 @@ public sealed class StreamingController
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// A suffix-stage failure keeps its pipeline receipt and
|
// A concrete suffix-stage failure keeps its pipeline
|
||||||
// must remain at this exact FIFO position. The still-
|
// receipt and must remain at this exact FIFO position. The
|
||||||
// monolithic presentation callback deliberately removes
|
// internal compatibility callback cannot report its exact
|
||||||
// its receipt on failure because its internal committed
|
// committed prefix and therefore removes its receipt;
|
||||||
// prefix is unknown; consume only that failed result so a
|
// consume only that failed result so a later frame cannot
|
||||||
// later frame cannot replay it automatically.
|
// replay it automatically.
|
||||||
if (!_presentation.HasPendingPublication(result))
|
if (!_presentation.HasPendingPublication(result))
|
||||||
read++;
|
read++;
|
||||||
throw;
|
throw;
|
||||||
|
|
|
||||||
192
src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs
Normal file
192
src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -127,7 +127,8 @@ public sealed class PhysicsEngine
|
||||||
DataCache?.RemoveBuildingsForLandblock(landblockId);
|
DataCache?.RemoveBuildingsForLandblock(landblockId);
|
||||||
|
|
||||||
// #145: if the player's current cell belonged to the landblock being removed (a teleport
|
// #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
|
// 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
|
// 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
|
// only the skybox renders. Clearing it lets the gate read "not in a dungeon" → the
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,10 @@ using DatEnvironment = DatReaderWriter.DBObjs.Environment;
|
||||||
namespace AcDream.Core.World;
|
namespace AcDream.Core.World;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The parsed dat objects <c>ApplyLoadedTerrainLocked</c> needs, pre-read by the
|
/// Parsed DAT closure produced by <c>LandblockBuildFactory</c> under the shared
|
||||||
/// streaming worker under <c>_datLock</c> so the apply makes ZERO DatCollection
|
/// reader gate and consumed by the render-thread presentation publishers.
|
||||||
/// calls and the update thread never blocks on the worker's lock (the FPS
|
/// Publication makes zero DatCollection calls, so the update thread never
|
||||||
/// 30↔200 swing was that lock-wait). Keyed by the same dat id the apply would
|
/// blocks on worker DAT access. Entries retain their original DAT IDs.
|
||||||
/// have passed to <c>_dats.Get<T></c>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record PhysicsDatBundle(
|
public sealed record PhysicsDatBundle(
|
||||||
LandBlockInfo? Info,
|
LandBlockInfo? Info,
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,66 @@ public sealed class LiveSessionResetPlanTests
|
||||||
Assert.Equal(2, secondCalls);
|
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]
|
[Fact]
|
||||||
public void Execute_ReentrantAttemptIsReportedButLaterStagesStillRun()
|
public void Execute_ReentrantAttemptIsReportedButLaterStagesStillRun()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,7 @@ public sealed class LandblockBuildOriginTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildFactoryAndGameWindowPublication_UseCapturedOriginInsteadOfLiveCenter()
|
public void BuildFactoryAndRenderPublisher_UseCapturedOriginWithoutGameWindowFacade()
|
||||||
{
|
{
|
||||||
string root = FindRepoRoot();
|
string root = FindRepoRoot();
|
||||||
string gameWindowSource = File.ReadAllText(Path.Combine(
|
string gameWindowSource = File.ReadAllText(Path.Combine(
|
||||||
|
|
@ -253,11 +253,12 @@ public sealed class LandblockBuildOriginTests
|
||||||
"AcDream.App",
|
"AcDream.App",
|
||||||
"Streaming",
|
"Streaming",
|
||||||
"LandblockRenderPublisher.cs"));
|
"LandblockRenderPublisher.cs"));
|
||||||
string publicationSection = Slice(
|
string recenterSource = File.ReadAllText(Path.Combine(
|
||||||
gameWindowSource,
|
root,
|
||||||
"private void ApplyLoadedTerrainLocked(",
|
"src",
|
||||||
"private void OnUpdate(double dt)");
|
"AcDream.App",
|
||||||
|
"Streaming",
|
||||||
|
"StreamingOriginRecenterCoordinator.cs"));
|
||||||
Assert.Contains("request.Origin", buildSource, StringComparison.Ordinal);
|
Assert.Contains("request.Origin", buildSource, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("_liveCenterX", buildSource, StringComparison.Ordinal);
|
Assert.DoesNotContain("_liveCenterX", buildSource, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("_liveCenterY", buildSource, StringComparison.Ordinal);
|
Assert.DoesNotContain("_liveCenterY", buildSource, StringComparison.Ordinal);
|
||||||
|
|
@ -277,12 +278,68 @@ public sealed class LandblockBuildOriginTests
|
||||||
"BuildPhysicsDatBundle",
|
"BuildPhysicsDatBundle",
|
||||||
gameWindowSource,
|
gameWindowSource,
|
||||||
StringComparison.Ordinal);
|
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.Contains("ComputeOrigin(landblockId, build.Origin)", renderPublisherSource, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("_liveCenterX", renderPublisherSource, StringComparison.Ordinal);
|
Assert.DoesNotContain("_liveCenterX", renderPublisherSource, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("_liveCenterY", 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) =>
|
private static LandblockBuild EmptyBuild(uint landblockId, LandblockBuildOrigin origin) =>
|
||||||
|
|
@ -316,15 +373,6 @@ public sealed class LandblockBuildOriginTests
|
||||||
return results;
|
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()
|
private static string FindRepoRoot()
|
||||||
{
|
{
|
||||||
string? dir = AppContext.BaseDirectory;
|
string? dir = AppContext.BaseDirectory;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using System.Numerics;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using AcDream.App.Rendering.Wb;
|
using AcDream.App.Rendering.Wb;
|
||||||
using AcDream.App.Streaming;
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.App.World;
|
||||||
using AcDream.Core.Terrain;
|
using AcDream.Core.Terrain;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
|
|
@ -47,7 +48,7 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ControllerConcreteConstructorHasNoLegacyPresentationParameters()
|
public void PublicPresentationConstructorsExposeOnlyConcreteOwnerGraph()
|
||||||
{
|
{
|
||||||
string[] legacyNames =
|
string[] legacyNames =
|
||||||
[
|
[
|
||||||
|
|
@ -59,14 +60,595 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
"retirementCoordinator",
|
"retirementCoordinator",
|
||||||
];
|
];
|
||||||
|
|
||||||
System.Reflection.ConstructorInfo concrete = Assert.Single(
|
System.Reflection.ConstructorInfo controller = Assert.Single(
|
||||||
typeof(StreamingController).GetConstructors(),
|
typeof(StreamingController).GetConstructors());
|
||||||
constructor => constructor.GetParameters().Any(
|
Assert.Contains(
|
||||||
parameter => parameter.Name == "presentationPipeline"));
|
controller.GetParameters(),
|
||||||
string[] names = concrete.GetParameters()
|
parameter => parameter.Name == "presentationPipeline");
|
||||||
|
string[] names = controller.GetParameters()
|
||||||
.Select(parameter => parameter.Name!)
|
.Select(parameter => parameter.Name!)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
Assert.DoesNotContain(names, legacyNames.Contains);
|
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]
|
[Fact]
|
||||||
|
|
@ -715,9 +1297,10 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
envCells);
|
envCells);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WorldEntity Entity(uint id) => new()
|
private static WorldEntity Entity(uint id, uint serverGuid = 0) => new()
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
|
ServerGuid = serverGuid,
|
||||||
SourceGfxObjOrSetupId = 0x01000000u + id,
|
SourceGfxObjOrSetupId = 0x01000000u + id,
|
||||||
Position = Vector3.Zero,
|
Position = Vector3.Zero,
|
||||||
Rotation = Quaternion.Identity,
|
Rotation = Quaternion.Identity,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue