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>
|
||||
/// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a
|
||||
/// landblock prefix. Used by <c>GameWindow.ApplyLoadedTerrainLocked</c> when
|
||||
/// building the per-landblock <c>BuildingRegistry</c> — the per-frame
|
||||
/// landblock prefix. Used by <c>LandblockRenderPublisher</c> when building
|
||||
/// the per-landblock <c>BuildingRegistry</c> — the per-frame
|
||||
/// <c>drainedCells</c> dict misses cells loaded on prior frames, so the
|
||||
/// stamping loop in <see cref="Wb.BuildingLoader.Build"/> needs access to
|
||||
/// every cell currently in the landblock to ensure <c>BuildingId</c> is set.
|
||||
|
|
|
|||
|
|
@ -93,46 +93,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
private int _terrainCpuSampleCursor;
|
||||
private long _terrainLastDiagTick;
|
||||
|
||||
// [FRAME-DIAG] (ACDREAM_WB_DIAG=1): per-frame cost split for the FPS deep-dive.
|
||||
// The crux this measures: terrain APPLY runs in OnUpdate (under _datLock —
|
||||
// dat reads + physics/ShadowObjects/BSP registration + the terrain GPU
|
||||
// upload), while the DRAW + the title-bar ms run in OnRender, so a heavy
|
||||
// apply stalls the loop turn yet is INVISIBLE to the render-only ms. We
|
||||
// accumulate apply cost across each OnUpdate and flush it next to the
|
||||
// [TERRAIN-DIAG] draw cost. Samples are µs×100 over a rolling 256-ring,
|
||||
// reusing the [TERRAIN-DIAG] median/p95 helpers. Removable (mirrors the
|
||||
// probes stripped in 92e95be). Gated once at construction — zero cost off.
|
||||
// [FRAME-DIAG]: retain only the render-thread entity upload distribution.
|
||||
// Landblock publication timing/counts live with the focused publishers and
|
||||
// are read from their typed snapshots when diagnostics flush.
|
||||
private readonly bool _frameDiag = string.Equals(
|
||||
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", System.StringComparison.Ordinal);
|
||||
private long _applyAccumTicks; // apply CPU accumulated across this OnUpdate
|
||||
private long _applyUploadAccumTicks; // terrain glBufferSubData sub-span this OnUpdate
|
||||
private int _appliesThisUpdate; // # landblock LOADS applied this OnUpdate
|
||||
private int _frameDiagMaxAppliesPerUpdate; // burst size over the 5s window
|
||||
private readonly long[] _applyCpuSamples = new long[256]; // apply µs×100 / Update
|
||||
private int _applyCpuSampleCursor;
|
||||
private readonly long[] _applyUploadSamples = new long[256]; // terrain-upload µs×100 / Update
|
||||
private int _applyUploadSampleCursor;
|
||||
private readonly long[] _entityUploadSamples = new long[256]; // _wbMeshAdapter.Tick µs×100 / Render
|
||||
private int _entityUploadSampleCursor;
|
||||
// Apply CPU split into three sub-spans (to name WHICH part of the 40-110ms
|
||||
// apply dominates): cell-build (EnvCell dat reads + physics surfaces +
|
||||
// buildings), gfxobj-BSP (per-entity physics BSP cache), ShadowObjects+lights
|
||||
// registration. Accumulated per OnUpdate via checkpoints in ApplyLoadedTerrainLocked.
|
||||
private long _applyCellAccumTicks;
|
||||
private long _applyBspAccumTicks;
|
||||
private long _applyShadowAccumTicks;
|
||||
private readonly long[] _applyCellSamples = new long[256];
|
||||
private int _applyCellSampleCursor;
|
||||
private readonly long[] _applyBspSamples = new long[256];
|
||||
private int _applyBspSampleCursor;
|
||||
private readonly long[] _applyShadowSamples = new long[256];
|
||||
private int _applyShadowSampleCursor;
|
||||
// Direct measure of the _datLock acquisition wait (the streaming worker holds
|
||||
// the lock for its full per-landblock build, stalling this apply). Confirms the
|
||||
// apply≫sub-span gap is lock contention, not in-lock work.
|
||||
private long _applyLockWaitAccumTicks;
|
||||
private readonly long[] _applyLockWaitSamples = new long[256];
|
||||
private int _applyLockWaitSampleCursor;
|
||||
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"),
|
||||
"1",
|
||||
System.StringComparison.Ordinal);
|
||||
private readonly long[] _entityUploadSamples = new long[256];
|
||||
private int _entityUploadSampleCursor;
|
||||
|
||||
// MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call
|
||||
// per OnRender + three stage scopes. All logic lives in
|
||||
|
|
@ -150,10 +119,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
// Phase A.1: streaming fields replacing the one-shot _entities list.
|
||||
private AcDream.App.Streaming.LandblockStreamer? _streamer;
|
||||
private AcDream.App.Streaming.GpuWorldState _worldState = new();
|
||||
private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher;
|
||||
private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher;
|
||||
private AcDream.App.Streaming.LandblockStaticPresentationPublisher?
|
||||
_landblockStaticPresentationPublisher;
|
||||
private AcDream.App.Streaming.LandblockPresentationPipeline?
|
||||
_landblockPresentationPipeline;
|
||||
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
|
||||
private AcDream.App.Streaming.StreamingController? _streamingController;
|
||||
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
|
||||
|
|
@ -1630,9 +1597,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
|
||||
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
|
||||
|
||||
// Build blending context from the terrain atlas. Stored as fields so
|
||||
// ApplyLoadedTerrain (render-thread callback invoked per streamed lb)
|
||||
// can call LandblockMesh.Build without re-deriving these every time.
|
||||
// Build blending context from the terrain atlas. The worker-side
|
||||
// LandblockBuildFactory captures this immutable table for each build.
|
||||
var terrainTypeToLayerBytes = new Dictionary<uint, byte>(terrainAtlas.TerrainTypeToLayer.Count);
|
||||
foreach (var kvp in terrainAtlas.TerrainTypeToLayer)
|
||||
terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value;
|
||||
|
|
@ -2410,7 +2376,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
_envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer(
|
||||
_gl, _wbMeshAdapter!.MeshManager!, _envCellFrustum);
|
||||
_envCellRenderer.Initialize(_meshShader!);
|
||||
_landblockRenderPublisher = new AcDream.App.Streaming.LandblockRenderPublisher(
|
||||
var landblockRenderPublisher = new AcDream.App.Streaming.LandblockRenderPublisher(
|
||||
publishTerrain: (landblockId, meshData, origin) =>
|
||||
_terrain!.AddLandblockWithMesh(landblockId, meshData, origin),
|
||||
removeTerrain: landblockId => _terrain!.RemoveLandblock(landblockId),
|
||||
|
|
@ -2422,16 +2388,34 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
build,
|
||||
_wbMeshAdapter.MeshManager!),
|
||||
removeEnvCells: _envCellRenderer.RemoveLandblock);
|
||||
_landblockPhysicsPublisher =
|
||||
var landblockPhysicsPublisher =
|
||||
new AcDream.App.Streaming.LandblockPhysicsPublisher(
|
||||
_physicsEngine,
|
||||
_heightTable!);
|
||||
_landblockStaticPresentationPublisher =
|
||||
var landblockStaticPresentationPublisher =
|
||||
new AcDream.App.Streaming.LandblockStaticPresentationPublisher(
|
||||
_lightingSink!,
|
||||
_translucencyFades,
|
||||
_worldGameState,
|
||||
_worldEvents);
|
||||
var landblockRetirementOwner =
|
||||
new AcDream.App.Streaming.LandblockPresentationRetirementOwner(
|
||||
landblockRenderPublisher,
|
||||
landblockPhysicsPublisher,
|
||||
landblockStaticPresentationPublisher,
|
||||
_lightingSink!,
|
||||
_translucencyFades);
|
||||
_landblockPresentationPipeline =
|
||||
new AcDream.App.Streaming.LandblockPresentationPipeline(
|
||||
landblockRenderPublisher,
|
||||
landblockPhysicsPublisher,
|
||||
landblockStaticPresentationPublisher,
|
||||
_worldState,
|
||||
landblockRetirementOwner,
|
||||
onLandblockLoaded: loadedLandblockId =>
|
||||
_liveEntityHydration!.OnLandblockLoaded(loadedLandblockId),
|
||||
ensureEnvCellMeshes:
|
||||
landblockRenderPublisher.PrepareAfterRenderPins);
|
||||
|
||||
_clipFrame ??= ClipFrame.NoClip();
|
||||
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(
|
||||
|
|
@ -2523,24 +2507,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
});
|
||||
_streamer.Start();
|
||||
|
||||
var landblockRetirementOwner =
|
||||
new AcDream.App.Streaming.LandblockPresentationRetirementOwner(
|
||||
_landblockRenderPublisher!,
|
||||
_landblockPhysicsPublisher!,
|
||||
_landblockStaticPresentationPublisher!,
|
||||
_lightingSink!,
|
||||
_translucencyFades);
|
||||
var landblockPresentationPipeline =
|
||||
new AcDream.App.Streaming.LandblockPresentationPipeline(
|
||||
_landblockRenderPublisher!,
|
||||
_landblockPhysicsPublisher!,
|
||||
_landblockStaticPresentationPublisher!,
|
||||
_worldState,
|
||||
landblockRetirementOwner,
|
||||
onLandblockLoaded: loadedLandblockId =>
|
||||
_liveEntityHydration!.OnLandblockLoaded(loadedLandblockId),
|
||||
ensureEnvCellMeshes:
|
||||
_landblockRenderPublisher!.PrepareAfterRenderPins);
|
||||
_streamingController = new AcDream.App.Streaming.StreamingController(
|
||||
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(
|
||||
new AcDream.App.Streaming.LandblockBuildRequest(
|
||||
|
|
@ -2559,7 +2525,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
// Retained-object recovery runs after each
|
||||
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the
|
||||
// objects it still considers known.
|
||||
presentationPipeline: landblockPresentationPipeline);
|
||||
presentationPipeline: _landblockPresentationPipeline!);
|
||||
_streamingOriginRecenter =
|
||||
new AcDream.App.Streaming.StreamingOriginRecenterCoordinator(
|
||||
_streamingController,
|
||||
_liveWorldOrigin);
|
||||
// A.5 T22.5: apply max-completions from resolved quality.
|
||||
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
|
||||
_worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator(
|
||||
|
|
@ -3297,6 +3267,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
private readonly TeleportViewPlaneController _teleportViewPlane = new();
|
||||
private readonly AcDream.App.Streaming.TeleportTransitCoordinator<
|
||||
AcDream.Core.Net.WorldSession.EntityPositionUpdate> _teleportTransit = new();
|
||||
private AcDream.App.Streaming.StreamingOriginRecenterCoordinator?
|
||||
_streamingOriginRecenter;
|
||||
private System.Numerics.Vector3 _pendingTeleportPos;
|
||||
private uint _pendingTeleportCell;
|
||||
private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout)
|
||||
|
|
@ -3365,22 +3337,21 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
System.Numerics.Vector3 newWorldPos;
|
||||
if (streamingCenterChanges)
|
||||
{
|
||||
// #145: drop the stale STREAMING center landblock from the physics engine
|
||||
// BEFORE recentering. The destination loads at world-offset (0,0) (the new
|
||||
// center), but the prior center was ALSO loaded at offset (0,0) and its
|
||||
// offset is never re-based — so the two overlap, and the Z-agnostic outdoor
|
||||
// cell-snap resolves the player into the stale center. On a replacement this
|
||||
// can be the abandoned first destination, not the unplaced player's source.
|
||||
_landblockPhysicsPublisher!.RemoveLandblock(streamingOriginLandblockId);
|
||||
|
||||
_liveWorldOrigin.Recenter(lbX, lbY);
|
||||
// #145: retire the complete old streaming window while its shared
|
||||
// origin is still current. Destination streaming remains blocked
|
||||
// until every retained presentation ticket converges. This prevents
|
||||
// old terrain and collision, which were baked in the prior frame,
|
||||
// from overlapping the destination after the origin changes.
|
||||
var recenter = _streamingOriginRecenter
|
||||
?? throw new InvalidOperationException(
|
||||
"A teleport changed streaming center before the recenter coordinator was wired.");
|
||||
recenter.Begin(
|
||||
lbX,
|
||||
lbY,
|
||||
IsSealedDungeonCell(p.LandblockId));
|
||||
newWorldPos = new System.Numerics.Vector3(
|
||||
p.PositionX, p.PositionY, p.PositionZ);
|
||||
|
||||
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
|
||||
_streamingController.PreCollapseToDungeon(lbX, lbY);
|
||||
else
|
||||
_streamingController?.ForceReloadWindow();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -3418,6 +3389,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
/// </summary>
|
||||
private void ResetTeleportTransitState(bool clearSession = false)
|
||||
{
|
||||
bool originRecenterConverged =
|
||||
_streamingOriginRecenter?.Reset(sessionEnding: clearSession) ?? true;
|
||||
if (clearSession)
|
||||
{
|
||||
_teleportTransit.ClearSession();
|
||||
|
|
@ -3440,6 +3413,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
_streamingController.PriorityLandblockId = 0u;
|
||||
_streamingController.PriorityRadius = 0;
|
||||
}
|
||||
|
||||
if (clearSession && !originRecenterConverged)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The ending session's streaming-origin retirement has not converged.");
|
||||
}
|
||||
}
|
||||
|
||||
// #145: the LANDBLOCK-relative (cell-local) position used to SEED the player
|
||||
|
|
@ -3801,183 +3780,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
$"live: AdminEnvirons sound cue = {name} " +
|
||||
$"(0x{environChangeType:X2}) — audio binding pending");
|
||||
}
|
||||
/// <summary>
|
||||
/// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick
|
||||
/// whenever a new landblock's terrain + entities are ready for GPU upload.
|
||||
/// Phase A.5 T12: the worker pre-builds <paramref name="meshData"/> off the
|
||||
/// render thread via <see cref="AcDream.Core.Terrain.LandblockMesh.Build"/>;
|
||||
/// this callback no longer pays that CPU cost.
|
||||
/// Must only be called from the render thread.
|
||||
/// </summary>
|
||||
private void ApplyLoadedTerrain(AcDream.App.Streaming.LandblockBuild build,
|
||||
AcDream.Core.Terrain.LandblockMeshData meshData)
|
||||
{
|
||||
var lb = build.Landblock;
|
||||
if (_terrain is null || _dats is null) return;
|
||||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("APPLY", lb.LandblockId);
|
||||
|
||||
// datLock fix (2026-06-23): ApplyLoadedTerrainLocked now makes ZERO
|
||||
// DatCollection calls — every dat it needs was pre-read by the streaming
|
||||
// worker into lb.PhysicsDats. Its remaining mutations are update-thread-
|
||||
// only (physics engine, ShadowObjects, renderer, _worldState) or already
|
||||
// concurrent-safe (PhysicsDataCache is ConcurrentDictionary). _datLock's
|
||||
// only cross-thread job was serializing DatCollection, so with no Get call
|
||||
// here the lock is unnecessary — removing it eliminates the measured
|
||||
// 24ms-median / 88ms-p95 lockwait stall (the FPS 30↔200 swing). The method
|
||||
// keeps its historical "Locked" name; the worker still serializes its OWN
|
||||
// dat reads on _datLock — only the apply stops contending for it.
|
||||
// [FRAME-DIAG]: lockwait now measures ~0 — the before/after proof.
|
||||
long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
if (_frameDiag)
|
||||
_applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
||||
try
|
||||
{
|
||||
ApplyLoadedTerrainLocked(build, meshData);
|
||||
}
|
||||
catch (AcDream.App.Streaming.StreamingMutationException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// This legacy body spans multiple concrete owners and cannot yet
|
||||
// identify an exact failed suffix. Conservatively report that a
|
||||
// prefix may have committed so the transaction coordinator never
|
||||
// replays terrain/cells/collision/lights/plugin events. Slice-5
|
||||
// checkpoints C-E replace this wrapper with per-owner receipts.
|
||||
throw new AcDream.App.Streaming.StreamingMutationException(
|
||||
$"Landblock 0x{lb.LandblockId:X8} presentation failed after an unknown commit point.",
|
||||
mutationCommitted: true,
|
||||
error);
|
||||
}
|
||||
if (_frameDiag)
|
||||
{
|
||||
_applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
||||
_appliesThisUpdate++;
|
||||
}
|
||||
}
|
||||
|
||||
private void PublishLandblockStaticLightingBeforeCollision(
|
||||
AcDream.Core.World.WorldEntity entity,
|
||||
AcDream.Core.World.PhysicsDatBundle datBundle)
|
||||
{
|
||||
if (_lightingSink is null || _dats is null)
|
||||
return;
|
||||
|
||||
uint sourceId = entity.SourceGfxObjOrSetupId;
|
||||
if ((sourceId & 0xFF000000u) != 0x02000000u)
|
||||
return;
|
||||
if (!datBundle.Setups.TryGetValue(sourceId, out var setup)
|
||||
|| setup.Lights.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// A landblock can reapply without an unload (#168 ForceReloadWindow
|
||||
// and Far-to-Near promotion). Static IDs are deterministic, so replace
|
||||
// the prior owner state before registering the Setup lights.
|
||||
uint effectOwnerId = entity.Id;
|
||||
_lightingSink.UnregisterOwner(effectOwnerId);
|
||||
_translucencyFades.ClearEntity(effectOwnerId);
|
||||
var lights = AcDream.Core.Lighting.LightInfoLoader.Load(
|
||||
setup,
|
||||
ownerId: effectOwnerId,
|
||||
entityPosition: entity.Position,
|
||||
entityRotation: entity.Rotation,
|
||||
cellId: entity.ParentCellId ?? 0u);
|
||||
foreach (var light in lights)
|
||||
_lightingSink.RegisterOwnedLight(light);
|
||||
}
|
||||
|
||||
private void ApplyLoadedTerrainLocked(
|
||||
AcDream.App.Streaming.LandblockBuild build,
|
||||
AcDream.Core.Terrain.LandblockMeshData meshData)
|
||||
{
|
||||
AcDream.Core.World.LoadedLandblock landblock = build.Landblock;
|
||||
if (_landblockRenderPublisher is null || _landblockPhysicsPublisher is null)
|
||||
return;
|
||||
|
||||
AcDream.Core.World.PhysicsDatBundle datBundle =
|
||||
landblock.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty;
|
||||
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderBefore =
|
||||
_landblockRenderPublisher.Diagnostics;
|
||||
AcDream.App.Streaming.LandblockRenderPublication renderPublication =
|
||||
_landblockRenderPublisher.BeginPublication(build, meshData);
|
||||
System.Numerics.Vector3 origin = renderPublication.Origin;
|
||||
|
||||
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderPrefix =
|
||||
_landblockRenderPublisher.Diagnostics;
|
||||
if (_frameDiag)
|
||||
{
|
||||
long terrainTicks =
|
||||
renderPrefix.TerrainPublishTicks - renderBefore.TerrainPublishTicks;
|
||||
_applyUploadAccumTicks += terrainTicks;
|
||||
_applyCellAccumTicks +=
|
||||
renderPrefix.BeginPublishTicks
|
||||
- renderBefore.BeginPublishTicks
|
||||
- terrainTicks;
|
||||
}
|
||||
|
||||
AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physicsBefore =
|
||||
_landblockPhysicsPublisher.Diagnostics;
|
||||
AcDream.App.Streaming.LandblockPhysicsPublication physicsPublication =
|
||||
_landblockPhysicsPublisher.BeginPublication(renderPublication);
|
||||
|
||||
// Retail initializes buildings/visible cells before ordinary static
|
||||
// collision and scripts. The two receipts retain one captured origin
|
||||
// while each focused owner commits its exact stage.
|
||||
_landblockRenderPublisher.CompletePublication(renderPublication);
|
||||
_landblockPhysicsPublisher.CompletePublication(
|
||||
physicsPublication,
|
||||
entity => PublishLandblockStaticLightingBeforeCollision(
|
||||
entity,
|
||||
datBundle));
|
||||
|
||||
if (_frameDiag)
|
||||
{
|
||||
AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physicsAfter =
|
||||
_landblockPhysicsPublisher.Diagnostics;
|
||||
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderAfter =
|
||||
_landblockRenderPublisher.Diagnostics;
|
||||
long gfxCacheTicks =
|
||||
physicsAfter.GfxCacheTicks - physicsBefore.GfxCacheTicks;
|
||||
_applyCellAccumTicks +=
|
||||
physicsAfter.BasePublishTicks - physicsBefore.BasePublishTicks;
|
||||
_applyCellAccumTicks +=
|
||||
renderAfter.CompletePublishTicks
|
||||
- renderPrefix.CompletePublishTicks;
|
||||
_applyBspAccumTicks += gfxCacheTicks;
|
||||
_applyShadowAccumTicks +=
|
||||
physicsAfter.CompletePublishTicks
|
||||
- physicsBefore.CompletePublishTicks
|
||||
- gfxCacheTicks;
|
||||
}
|
||||
|
||||
long pluginStarted =
|
||||
_frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
foreach (AcDream.Core.World.WorldEntity entity in landblock.Entities)
|
||||
{
|
||||
// Live objects publish through LiveEntityRuntime exactly once per
|
||||
// accepted incarnation. This callback owns DAT statics only.
|
||||
if (entity.ServerGuid != 0)
|
||||
continue;
|
||||
|
||||
var snapshot =
|
||||
new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
||||
Id: entity.Id,
|
||||
SourceId: entity.SourceGfxObjOrSetupId,
|
||||
Position: entity.Position,
|
||||
Rotation: entity.Rotation);
|
||||
_worldGameState.Add(snapshot);
|
||||
_worldEvents.FireEntitySpawned(snapshot);
|
||||
}
|
||||
if (_frameDiag)
|
||||
{
|
||||
_applyShadowAccumTicks +=
|
||||
System.Diagnostics.Stopwatch.GetTimestamp() - pluginStarted;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnUpdate(double dt)
|
||||
{
|
||||
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update);
|
||||
|
|
@ -4009,31 +3811,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
_physicsScriptGameTime += frameSeconds;
|
||||
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
||||
|
||||
// [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick;
|
||||
// flush the PREVIOUS OnUpdate's accumulated apply cost here (a clean per-frame
|
||||
// boundary, independent of where in OnUpdate the applies landed) and reset.
|
||||
if (_frameDiag)
|
||||
{
|
||||
if (_appliesThisUpdate > 0)
|
||||
{
|
||||
FrameDiagPush(_applyCpuSamples, ref _applyCpuSampleCursor, _applyAccumTicks);
|
||||
FrameDiagPush(_applyUploadSamples, ref _applyUploadSampleCursor, _applyUploadAccumTicks);
|
||||
FrameDiagPush(_applyCellSamples, ref _applyCellSampleCursor, _applyCellAccumTicks);
|
||||
FrameDiagPush(_applyBspSamples, ref _applyBspSampleCursor, _applyBspAccumTicks);
|
||||
FrameDiagPush(_applyShadowSamples, ref _applyShadowSampleCursor, _applyShadowAccumTicks);
|
||||
FrameDiagPush(_applyLockWaitSamples, ref _applyLockWaitSampleCursor, _applyLockWaitAccumTicks);
|
||||
if (_appliesThisUpdate > _frameDiagMaxAppliesPerUpdate)
|
||||
_frameDiagMaxAppliesPerUpdate = _appliesThisUpdate;
|
||||
}
|
||||
_applyAccumTicks = 0;
|
||||
_applyUploadAccumTicks = 0;
|
||||
_applyCellAccumTicks = 0;
|
||||
_applyBspAccumTicks = 0;
|
||||
_applyShadowAccumTicks = 0;
|
||||
_applyLockWaitAccumTicks = 0;
|
||||
_appliesThisUpdate = 0;
|
||||
}
|
||||
|
||||
// Phase A.1: advance the streaming controller FIRST so the initial
|
||||
// landblocks are loaded into GpuWorldState before live-session
|
||||
// CreateObject events drain. The earlier order (live tick first,
|
||||
|
|
@ -4059,6 +3836,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
// The world-geometry RENDER gate (IsLiveModeWaitingForLogin at the
|
||||
// draw site) is unchanged — the pre-entry screen still shows sky
|
||||
// only.
|
||||
// Recenter cancellation/session-reset requests must keep converging
|
||||
// even when ordinary streaming is temporarily gated off. This call is
|
||||
// a no-op outside a pending teleport or session-boundary origin
|
||||
// retirement transaction.
|
||||
_streamingOriginRecenter?.Advance();
|
||||
|
||||
bool liveInWorld = _liveSessionController?.IsInWorld == true;
|
||||
// #192: liveInWorld alone used to be sufficient here — but InWorld fires
|
||||
// right after the login handshake, before the player's own spawn
|
||||
|
|
@ -4078,10 +3861,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace)
|
||||
{
|
||||
// Teleport hold (#135): the local player position is frozen at the
|
||||
// PRE-teleport spot, expressed in the OLD center frame, but
|
||||
// _liveCenterX/_liveCenterY were already recentered onto the
|
||||
// destination landblock (OnLivePositionUpdated). Follow the
|
||||
// destination directly — the stale position-derived offset
|
||||
// PRE-teleport spot, expressed in the OLD center frame. The
|
||||
// origin coordinator commits _liveCenterX/Y to the destination
|
||||
// only after old-window presentation retirement converges; the
|
||||
// streaming controller remains gated until that edge. Follow
|
||||
// the active shared origin directly — the stale position-derived offset
|
||||
// (_liveCenterX + floor(frozenPos/192)) could land ≥2 landblocks off
|
||||
// the dungeon and trip ExitDungeonExpand, re-streaming the very
|
||||
// neighbor window the pre-collapse just suppressed. Correct for an
|
||||
|
|
@ -4266,8 +4050,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
if (_teleportTransit.IsActive)
|
||||
{
|
||||
bool haveDest = _pendingTeleportCell != 0u;
|
||||
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
|
||||
if (haveDest && !ready)
|
||||
bool originReady = _streamingOriginRecenter?.IsPending != true;
|
||||
bool ready = haveDest
|
||||
&& originReady
|
||||
&& TeleportWorldReady(_pendingTeleportCell);
|
||||
if (haveDest && originReady && !ready)
|
||||
{
|
||||
_teleportHoldSeconds += frameDelta;
|
||||
if (_teleportHoldSeconds >= TeleportMaxHoldSeconds)
|
||||
|
|
@ -5059,7 +4846,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
// routes interior-root look-ins to its landscape-stage sub-pass
|
||||
// (DrawBuildingLookIns); the root's own building self-excludes
|
||||
// via the seed eye-side test.
|
||||
foreach (var registry in _landblockRenderPublisher?.BuildingRegistries
|
||||
foreach (var registry in _landblockPresentationPipeline?.BuildingRegistries
|
||||
?? Array.Empty<AcDream.App.Rendering.Wb.BuildingRegistry>())
|
||||
{
|
||||
foreach (var b in registry.All())
|
||||
|
|
@ -8712,43 +8499,38 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
$"loaded={_terrain?.LoadedSlots ?? 0} " +
|
||||
$"capacity={_terrain?.CapacitySlots ?? 0}");
|
||||
|
||||
// [FRAME-DIAG]: the FPS-deep-dive per-frame cost split + leak/churn counters.
|
||||
// apply_us = terrain-apply CPU in OnUpdate (dat reads + physics/ShadowObjects
|
||||
// registration + terrain upload) — INVISIBLE to the title-bar ms (H1).
|
||||
// upl_us = the terrain glBufferSubData sub-span of that apply (expected tiny).
|
||||
// entUpl_us= the OnRender _wbMeshAdapter.Tick entity-mesh GPU drain (H2).
|
||||
// For entity-DRAW CPU/GPU see the [WB-DIAG] line; walked = resident-N proxy (H3).
|
||||
double applyMedUs = TerrainDiagMedianMicros(_applyCpuSamples) / 100.0;
|
||||
double applyP95Us = TerrainDiagPercentile95Micros(_applyCpuSamples) / 100.0;
|
||||
double uplMedUs = TerrainDiagMedianMicros(_applyUploadSamples) / 100.0;
|
||||
double uplP95Us = TerrainDiagPercentile95Micros(_applyUploadSamples) / 100.0;
|
||||
// Publication metrics are cumulative typed snapshots from the focused
|
||||
// owners. The only GameWindow-local distribution left is the render-
|
||||
// thread entity upload drain.
|
||||
AcDream.App.Streaming.LandblockPresentationDiagnostics presentation =
|
||||
_landblockPresentationPipeline?.Diagnostics ?? default;
|
||||
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics render =
|
||||
presentation.Render;
|
||||
AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physics =
|
||||
presentation.Physics;
|
||||
AcDream.App.Streaming.LandblockStaticPresentationDiagnostics statics =
|
||||
presentation.Statics;
|
||||
double ticksToMicros =
|
||||
1_000_000.0 / System.Diagnostics.Stopwatch.Frequency;
|
||||
double entUplMedUs = TerrainDiagMedianMicros(_entityUploadSamples) / 100.0;
|
||||
double entUplP95Us = TerrainDiagPercentile95Micros(_entityUploadSamples) / 100.0;
|
||||
// apply CPU split: cell-build / gfxobj-BSP / ShadowObjects+lights — names
|
||||
// WHICH part of the apply dominates (decides the fix shape).
|
||||
double cellMedUs = TerrainDiagMedianMicros(_applyCellSamples) / 100.0;
|
||||
double cellP95Us = TerrainDiagPercentile95Micros(_applyCellSamples) / 100.0;
|
||||
double bspMedUs = TerrainDiagMedianMicros(_applyBspSamples) / 100.0;
|
||||
double bspP95Us = TerrainDiagPercentile95Micros(_applyBspSamples) / 100.0;
|
||||
double shadMedUs = TerrainDiagMedianMicros(_applyShadowSamples) / 100.0;
|
||||
double shadP95Us = TerrainDiagPercentile95Micros(_applyShadowSamples) / 100.0;
|
||||
double lockMedUs = TerrainDiagMedianMicros(_applyLockWaitSamples) / 100.0;
|
||||
double lockP95Us = TerrainDiagPercentile95Micros(_applyLockWaitSamples) / 100.0;
|
||||
int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0;
|
||||
Console.WriteLine(
|
||||
$"[FRAME-DIAG] apply_us={applyMedUs:F1}m/{applyP95Us:F1}p95 " +
|
||||
$"lockwait={lockMedUs:F1}m/{lockP95Us:F1}p95 " +
|
||||
$"[cell={cellMedUs:F1}m/{cellP95Us:F1}p95 bsp={bspMedUs:F1}m/{bspP95Us:F1}p95 " +
|
||||
$"shadow={shadMedUs:F1}m/{shadP95Us:F1}p95] " +
|
||||
$"(upl={uplMedUs:F1}m/{uplP95Us:F1}p95) " +
|
||||
$"[FRAME-DIAG] publish={render.BeginCount}/{render.CompleteCount} " +
|
||||
$"render_total_us=[terrain={render.TerrainPublishTicks * ticksToMicros:F1} " +
|
||||
$"begin={render.BeginPublishTicks * ticksToMicros:F1} " +
|
||||
$"complete={render.CompletePublishTicks * ticksToMicros:F1}] " +
|
||||
$"physics_total_us=[base={physics.BasePublishTicks * ticksToMicros:F1} " +
|
||||
$"gfx={physics.GfxCacheTicks * ticksToMicros:F1} " +
|
||||
$"complete={physics.CompletePublishTicks * ticksToMicros:F1}] " +
|
||||
$"static={statics.BeginCount}/{statics.CompleteCount}" +
|
||||
$"(active={statics.ActiveEntityCount}) " +
|
||||
$"entUpl_us={entUplMedUs:F1}m/{entUplP95Us:F1}p95 " +
|
||||
$"applies_max/upd={_frameDiagMaxAppliesPerUpdate} " +
|
||||
$"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " +
|
||||
$"forceReload={_streamingController?.ForceReloadCount ?? 0}" +
|
||||
$"(drop={_streamingController?.LastForceReloadDropCount ?? 0}) " +
|
||||
$"fullRetire={_streamingController?.FullWindowRetirementCount ?? 0}" +
|
||||
$"(landblocks={_streamingController?.LastFullWindowRetirementLandblockCount ?? 0}) " +
|
||||
$"esg={_entitiesByServerGuid.Count} spawn={LastSpawns.Count} " +
|
||||
$"resident={_worldState.Entities.Count} walked={walked}");
|
||||
_frameDiagMaxAppliesPerUpdate = 0; // reset the per-window burst tracker
|
||||
|
||||
_terrainLastDiagTick = now;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,8 +129,8 @@ public sealed class LandblockBuildFactory
|
|||
// Hydrate the stabs: same logic as the old OnLoad preload. Each stab
|
||||
// entity from LandblockLoader carries a SourceGfxObjOrSetupId that we
|
||||
// expand into per-part MeshRefs via SetupMesh.Flatten / GfxObjMesh.Build.
|
||||
// GPU upload (EnsureUploaded) happens on the render thread in
|
||||
// ApplyLoadedTerrain — NOT here.
|
||||
// GPU upload happens later through the render-thread presentation
|
||||
// pipeline, never in this worker-side build.
|
||||
var hydrated = new List<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count);
|
||||
foreach (var e in baseLoaded.Entities)
|
||||
{
|
||||
|
|
@ -364,7 +364,8 @@ public sealed class LandblockBuildFactory
|
|||
if (gfx is not null)
|
||||
{
|
||||
_physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx);
|
||||
// Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain.
|
||||
// Sub-meshes are CPU-built here; the presentation pipeline
|
||||
// defers upload to the render thread.
|
||||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value);
|
||||
|
|
@ -417,9 +418,8 @@ public sealed class LandblockBuildFactory
|
|||
// _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at
|
||||
// build time this landblock is NOT registered in physics yet, so that query
|
||||
// could only return null (→ this same own-heightmap) or a STALE neighbor's
|
||||
// height — the previous location's terrain, still registered after a teleport
|
||||
// recenter (which drops only the single stale CENTER landblock, GameWindow
|
||||
// :5444) until streaming unloads it — planting scenery at the old location's
|
||||
// height — the previous location's terrain before the full old-window
|
||||
// recenter retirement converges — planting scenery at the old location's
|
||||
// altitude (trees-in-sky, deltaZ up to +500m; confirmed via the
|
||||
// [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every
|
||||
// case, so the global query is removed (also drops its per-spawn cost).
|
||||
|
|
@ -526,7 +526,7 @@ public sealed class LandblockBuildFactory
|
|||
/// Portal cells and drawable shell placements are accumulated in the
|
||||
/// transaction-local <paramref name="envCellBuild"/>. The render thread
|
||||
/// cannot observe this landblock until the streaming completion carries the
|
||||
/// finished transaction to ApplyLoadedTerrain.
|
||||
/// finished transaction to <c>LandblockPresentationPipeline</c>.
|
||||
///
|
||||
/// Ported from pre-streaming preload lines 407-565.
|
||||
/// </summary>
|
||||
|
|
@ -683,8 +683,8 @@ public sealed class LandblockBuildFactory
|
|||
// Setup carries real Lights — a "light attach point" fixture (e.g. the Town
|
||||
// Network fountain room's ceiling light, Setup 0x02000365). Track the dat
|
||||
// Setup's Lights.Count here so the meshRefs==0 gate below doesn't also drop
|
||||
// the entity that would otherwise carry those lights to the registration
|
||||
// pass (GameWindow.cs ~7900).
|
||||
// the entity that otherwise carries those lights to the static
|
||||
// presentation publisher.
|
||||
int stabLightCount = 0;
|
||||
if ((stab.Id & 0xFF000000u) == 0x01000000u)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -326,9 +326,9 @@ public sealed class LandblockPhysicsPublisher
|
|||
|
||||
/// <summary>
|
||||
/// Publishes ordinary static collision and refloods after the caller has
|
||||
/// completed the render-owned building/EnvCell suffix. The callback keeps
|
||||
/// the shipped per-entity light-before-collision order until checkpoint E
|
||||
/// moves static presentation into the transaction coordinator.
|
||||
/// completed the render-owned building/EnvCell suffix. The concrete
|
||||
/// presentation pipeline supplies the static-presentation owner callback
|
||||
/// that preserves per-entity light-before-collision order.
|
||||
/// </summary>
|
||||
public void CompletePublication(
|
||||
LandblockPhysicsPublication publication,
|
||||
|
|
|
|||
|
|
@ -4,14 +4,23 @@ using AcDream.Core.World;
|
|||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only presentation state needed by render traversal and diagnostics.
|
||||
/// Keeping this projection on the pipeline prevents the composition root from
|
||||
/// retaining each concrete publication owner independently.
|
||||
/// </summary>
|
||||
public readonly record struct LandblockPresentationDiagnostics(
|
||||
LandblockRenderPublisherDiagnostics Render,
|
||||
LandblockPhysicsPublisherDiagnostics Physics,
|
||||
LandblockStaticPresentationDiagnostics Statics);
|
||||
|
||||
/// <summary>
|
||||
/// Render-thread transaction coordinator for one accepted landblock streaming
|
||||
/// result. Streaming policy (desired tier, generation, priority, and apply
|
||||
/// budget) remains in <see cref="StreamingController"/>; spatial buckets remain
|
||||
/// in <see cref="GpuWorldState"/>. This owner fixes the presentation ordering
|
||||
/// independently of those policies so the concrete render, physics, and static
|
||||
/// publishers can be extracted behind it without returning callbacks to the
|
||||
/// game window.
|
||||
/// independently of those policies. The concrete render, physics, and static
|
||||
/// publishers remain private implementation participants behind this boundary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail establishes cells/buildings before static objects in
|
||||
|
|
@ -19,9 +28,9 @@ namespace AcDream.App.Streaming;
|
|||
/// <c>CLandBlock::release_all @ 0x0052FCF0</c> (objects and visible cells) from
|
||||
/// later destruction in <c>CLandBlock::Destroy @ 0x0052FAA0</c> (static objects
|
||||
/// and buildings). Acdream's detach-first retry ledger is the existing
|
||||
/// asynchronous lifetime adaptation. The injected publication operation
|
||||
/// currently contains the already-shipped production transaction; later
|
||||
/// Slice-5 checkpoints replace it with focused publishers.
|
||||
/// asynchronous lifetime adaptation. Production always uses the focused owner
|
||||
/// graph; the internal delegate constructor exists only for hermetic policy and
|
||||
/// retry characterization tests.
|
||||
/// </remarks>
|
||||
public sealed class LandblockPresentationPipeline
|
||||
{
|
||||
|
|
@ -63,7 +72,7 @@ public sealed class LandblockPresentationPipeline
|
|||
private readonly Dictionary<LandblockStreamResult, PublicationTransaction> _publications =
|
||||
new(ReferenceEqualityComparer.Instance);
|
||||
|
||||
public LandblockPresentationPipeline(
|
||||
internal LandblockPresentationPipeline(
|
||||
Action<LandblockBuild, LandblockMeshData> publishBeforeSpatialCommit,
|
||||
GpuWorldState state,
|
||||
Action<uint>? onLandblockLoaded = null,
|
||||
|
|
@ -137,6 +146,21 @@ public sealed class LandblockPresentationPipeline
|
|||
retirementOwner.Advance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Building visibility registries published by the render owner. Legacy
|
||||
/// test pipelines have no render owner and therefore expose an empty view.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<BuildingRegistry> BuildingRegistries =>
|
||||
_renderPublisher?.BuildingRegistries ?? Array.Empty<BuildingRegistry>();
|
||||
|
||||
/// <summary>
|
||||
/// Cumulative diagnostics from the three concrete presentation owners.
|
||||
/// </summary>
|
||||
public LandblockPresentationDiagnostics Diagnostics => new(
|
||||
_renderPublisher?.Diagnostics ?? default,
|
||||
_physicsPublisher?.Diagnostics ?? default,
|
||||
_staticPublisher?.Diagnostics ?? default);
|
||||
|
||||
public int PendingRetirementCount => _retirements.PendingCount;
|
||||
public int PendingPublicationCount => _publications.Count;
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,21 @@ namespace AcDream.App.Streaming;
|
|||
/// </summary>
|
||||
public sealed class StreamingController
|
||||
{
|
||||
private sealed class OriginRecenterRetirement
|
||||
{
|
||||
public bool RadiiConverged;
|
||||
public bool GenerationAdvanced;
|
||||
public bool PendingLoadsCleared;
|
||||
public bool DeferredApplyCleared;
|
||||
public bool RegionCleared;
|
||||
public List<uint>? ResidentIds;
|
||||
public int RetirementCursor;
|
||||
public bool PreparationCommitted;
|
||||
public (int X, int Y, bool IsSealedDungeon)? Destination;
|
||||
public bool DestinationConfigured;
|
||||
public bool DestinationLoadEnqueued;
|
||||
}
|
||||
|
||||
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
|
||||
private readonly Action<uint, ulong> _enqueueUnload;
|
||||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||||
|
|
@ -29,6 +44,8 @@ public sealed class StreamingController
|
|||
private RadiiReconfiguration? _pendingRadiiReconfiguration;
|
||||
private bool _advancingRadiiReconfiguration;
|
||||
private (int NearRadius, int FarRadius)? _deferredRadiiRequest;
|
||||
private OriginRecenterRetirement? _originRecenterRetirement;
|
||||
private bool _advancingOriginRecenter;
|
||||
// Hard streaming boundaries advance this token. Worker completions carry
|
||||
// the generation captured at enqueue time, so an old overlapping load or
|
||||
// unload cannot mutate the replacement window after a portal recenter.
|
||||
|
|
@ -116,6 +133,7 @@ public sealed class StreamingController
|
|||
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
|
||||
public int DeferredApplyBacklog => _deferredApply.Count;
|
||||
public int PendingRetirementCount => _presentation.PendingRetirementCount;
|
||||
internal bool IsCollapsedToDungeon => _collapsed;
|
||||
|
||||
/// <summary>
|
||||
/// True once every in-bounds landblock in the requested Chebyshev ring has
|
||||
|
|
@ -154,15 +172,21 @@ public sealed class StreamingController
|
|||
return true;
|
||||
}
|
||||
|
||||
// [FRAME-DIAG]: how many times ForceReloadWindow has fired (each one drops +
|
||||
// re-uploads the WHOLE window) and the landblock count it dropped last time.
|
||||
public int ForceReloadCount { get; private set; }
|
||||
public int LastForceReloadDropCount { get; private set; }
|
||||
// [FRAME-DIAG]: full-window presentation retirements include explicit
|
||||
// reloads and shared-origin recenter barriers. Both retire and later
|
||||
// re-upload the complete resident window.
|
||||
public int FullWindowRetirementCount { get; private set; }
|
||||
public int LastFullWindowRetirementLandblockCount { get; private set; }
|
||||
|
||||
// Completions that were drained past a priority item get buffered here
|
||||
// so they still apply over subsequent frames without loss.
|
||||
private readonly List<LandblockStreamResult> _deferredApply = new();
|
||||
public StreamingController(
|
||||
/// <summary>
|
||||
/// Internal compatibility seam for hermetic controller policy tests. Live
|
||||
/// composition cannot supply presentation callbacks; it must use the
|
||||
/// concrete pipeline constructor below.
|
||||
/// </summary>
|
||||
internal StreamingController(
|
||||
Action<uint, LandblockStreamJobKind, ulong> enqueueLoad,
|
||||
Action<uint, ulong> enqueueUnload,
|
||||
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
||||
|
|
@ -245,6 +269,15 @@ public sealed class StreamingController
|
|||
nameof(farRadius),
|
||||
"Far radius must be greater than or equal to near radius.");
|
||||
|
||||
// A radius transaction may not admit work after an origin-recenter
|
||||
// snapshot has been captured. Retain only the latest requested radii;
|
||||
// the first Tick after origin commit applies them to the new frame.
|
||||
if (_originRecenterRetirement is not null)
|
||||
{
|
||||
_deferredRadiiRequest = (nearRadius, farRadius);
|
||||
return;
|
||||
}
|
||||
|
||||
// Queue callbacks are injected seams and can synchronously reenter the
|
||||
// controller. Last-request-wins deferral keeps the active mutation
|
||||
// cursor stable; the request is applied after the current transaction
|
||||
|
|
@ -427,6 +460,12 @@ public sealed class StreamingController
|
|||
/// </summary>
|
||||
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
|
||||
{
|
||||
if (_originRecenterRetirement is not null)
|
||||
{
|
||||
_presentation.AdvanceRetirements();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pendingRadiiReconfiguration is not null)
|
||||
AdvanceRadiiReconfiguration();
|
||||
else if (_deferredRadiiRequest is { } deferred)
|
||||
|
|
@ -762,18 +801,262 @@ public sealed class StreamingController
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-06-22: an OUTDOOR teleport moved the render origin (<c>_liveCenter</c>). Every
|
||||
/// resident terrain block was baked relative to the OLD origin, so any block that survives
|
||||
/// an INCREMENTAL recenter — which happens on a NEARBY jump where the old and new windows
|
||||
/// overlap, e.g. (170,168)→(169,180) — renders shifted by the jump distance: the confirmed
|
||||
/// "terrain in the sky" arcs (the stale slots were all offset by exactly deltaLB×192).
|
||||
/// SYNCHRONOUSLY drop every resident landblock (render slot + physics + state) so none
|
||||
/// survives into the next frame stale, and no async unload can race a re-bake, then null
|
||||
/// the region so the next <see cref="NormalTick"/> re-bootstraps the WHOLE window fresh at
|
||||
/// the new origin (the near ring is priority-applied during portal travel; the rest streams in).
|
||||
/// A sealed-dungeon destination uses <see cref="PreCollapseToDungeon"/> instead.
|
||||
/// Starts a reload of the current streaming window. Logical residency is
|
||||
/// detached immediately; render, physics, and static-lighting owners then
|
||||
/// converge through the retained presentation-retirement ledger before
|
||||
/// <see cref="NormalTick"/> bootstraps the window again.
|
||||
/// Shared-origin teleports use <see cref="BeginOriginRecenter"/> instead so
|
||||
/// every old-window owner retires while the old coordinate frame is active.
|
||||
/// </summary>
|
||||
public void ForceReloadWindow()
|
||||
{
|
||||
BeginFullWindowRetirement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the old-window half of a shared-origin recenter. All accepted
|
||||
/// worker work is invalidated and every resident landblock is detached
|
||||
/// through the retryable presentation ledger while the old coordinate
|
||||
/// frame is still active. <see cref="Tick"/> remains blocked until
|
||||
/// <see cref="TryCommitOriginRecenter"/> succeeds.
|
||||
/// </summary>
|
||||
internal void BeginOriginRecenter()
|
||||
{
|
||||
_originRecenterRetirement ??= new OriginRecenterRetirement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances every retained old-window teardown and reports whether the
|
||||
/// composition root may safely change the shared world origin.
|
||||
/// </summary>
|
||||
internal bool IsOriginRecenterRetirementComplete()
|
||||
{
|
||||
if (_originRecenterRetirement is null)
|
||||
throw new InvalidOperationException(
|
||||
"No streaming-origin recenter transaction is pending.");
|
||||
|
||||
if (!TryAdvanceOriginRecenterPreparation())
|
||||
return false;
|
||||
_presentation.AdvanceRetirements();
|
||||
return _presentation.PendingRetirementCount == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the streaming bootstrap gate after the composition root has
|
||||
/// committed the new shared origin. No old-window presentation owner may
|
||||
/// still be pending at this edge.
|
||||
/// </summary>
|
||||
internal bool TryCommitOriginRecenter(
|
||||
int destinationX,
|
||||
int destinationY,
|
||||
bool isSealedDungeon)
|
||||
{
|
||||
if (_advancingOriginRecenter)
|
||||
return false;
|
||||
|
||||
_advancingOriginRecenter = true;
|
||||
try
|
||||
{
|
||||
return TryCommitOriginRecenterCore(
|
||||
destinationX,
|
||||
destinationY,
|
||||
isSealedDungeon);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_advancingOriginRecenter = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryCommitOriginRecenterCore(
|
||||
int destinationX,
|
||||
int destinationY,
|
||||
bool isSealedDungeon)
|
||||
{
|
||||
OriginRecenterRetirement transaction = _originRecenterRetirement
|
||||
?? throw new InvalidOperationException(
|
||||
"No streaming-origin recenter transaction is pending.");
|
||||
if (!transaction.PreparationCommitted)
|
||||
throw new InvalidOperationException(
|
||||
"Streaming-origin retirement preparation has not completed.");
|
||||
if (_presentation.PendingRetirementCount != 0)
|
||||
throw new InvalidOperationException(
|
||||
"The streaming origin cannot change while old-window presentation retirement is pending.");
|
||||
|
||||
var destination = (destinationX, destinationY, isSealedDungeon);
|
||||
if (transaction.Destination is { } retained && retained != destination)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A recenter transaction cannot commit two different destinations.");
|
||||
}
|
||||
transaction.Destination ??= destination;
|
||||
|
||||
if (!transaction.DestinationConfigured)
|
||||
{
|
||||
_collapsed = isSealedDungeon;
|
||||
_collapsedCenter = isSealedDungeon
|
||||
? StreamingRegion.EncodeLandblockId(destinationX, destinationY)
|
||||
: 0u;
|
||||
if (isSealedDungeon)
|
||||
{
|
||||
_region = new StreamingRegion(
|
||||
destinationX,
|
||||
destinationY,
|
||||
nearRadius: 0,
|
||||
farRadius: 0);
|
||||
_region.MarkResidentFromBootstrap();
|
||||
}
|
||||
else
|
||||
{
|
||||
_region = null;
|
||||
}
|
||||
transaction.DestinationConfigured = true;
|
||||
}
|
||||
|
||||
if (isSealedDungeon && !transaction.DestinationLoadEnqueued)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnqueueLoad(
|
||||
StreamingRegion.EncodeLandblockId(destinationX, destinationY),
|
||||
LandblockStreamJobKind.LoadNear);
|
||||
transaction.DestinationLoadEnqueued = true;
|
||||
}
|
||||
catch (StreamingMutationException error) when (error.MutationCommitted)
|
||||
{
|
||||
transaction.DestinationLoadEnqueued = true;
|
||||
Console.WriteLine(
|
||||
$"streaming: committed dungeon recenter enqueue reported failure: {error}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"streaming: dungeon recenter enqueue will resume: {error}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_originRecenterRetirement = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases a fully retired origin transaction at a session boundary
|
||||
/// without bootstrapping a destination from the ending session.
|
||||
/// </summary>
|
||||
internal bool TryCancelOriginRecenter()
|
||||
{
|
||||
if (_originRecenterRetirement is not { PreparationCommitted: true })
|
||||
return false;
|
||||
if (_presentation.PendingRetirementCount != 0)
|
||||
return false;
|
||||
|
||||
_collapsed = false;
|
||||
_collapsedCenter = 0u;
|
||||
_region = null;
|
||||
_originRecenterRetirement = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryAdvanceOriginRecenterPreparation()
|
||||
{
|
||||
OriginRecenterRetirement transaction = _originRecenterRetirement
|
||||
?? throw new InvalidOperationException(
|
||||
"No streaming-origin recenter transaction is pending.");
|
||||
if (transaction.PreparationCommitted)
|
||||
return true;
|
||||
if (_advancingOriginRecenter)
|
||||
return false;
|
||||
|
||||
_advancingOriginRecenter = true;
|
||||
try
|
||||
{
|
||||
if (!transaction.RadiiConverged)
|
||||
{
|
||||
if (_pendingRadiiReconfiguration is not null)
|
||||
AdvanceRadiiReconfiguration();
|
||||
if (_pendingRadiiReconfiguration is not null)
|
||||
return false;
|
||||
transaction.RadiiConverged = true;
|
||||
}
|
||||
if (!transaction.GenerationAdvanced)
|
||||
{
|
||||
AdvanceGeneration();
|
||||
transaction.GenerationAdvanced = true;
|
||||
}
|
||||
if (!transaction.PendingLoadsCleared)
|
||||
{
|
||||
try
|
||||
{
|
||||
_clearPendingLoads?.Invoke();
|
||||
transaction.PendingLoadsCleared = true;
|
||||
}
|
||||
catch (StreamingMutationException error) when (error.MutationCommitted)
|
||||
{
|
||||
transaction.PendingLoadsCleared = true;
|
||||
Console.WriteLine(
|
||||
$"streaming: committed pending-load clear reported failure: {error}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!transaction.DeferredApplyCleared)
|
||||
{
|
||||
_deferredApply.Clear();
|
||||
transaction.DeferredApplyCleared = true;
|
||||
}
|
||||
if (!transaction.RegionCleared)
|
||||
{
|
||||
_collapsed = false;
|
||||
_region = null;
|
||||
transaction.RegionCleared = true;
|
||||
}
|
||||
if (transaction.ResidentIds is null)
|
||||
{
|
||||
transaction.ResidentIds = new List<uint>(_state.LoadedLandblockIds);
|
||||
FullWindowRetirementCount++;
|
||||
LastFullWindowRetirementLandblockCount = transaction.ResidentIds.Count;
|
||||
}
|
||||
|
||||
while (transaction.RetirementCursor < transaction.ResidentIds.Count)
|
||||
{
|
||||
uint id = transaction.ResidentIds[transaction.RetirementCursor];
|
||||
try
|
||||
{
|
||||
_presentation.BeginFullRetirement(id);
|
||||
transaction.RetirementCursor++;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// A delivered visibility-observer failure may surface after
|
||||
// detachment has committed. Advance that exact cursor only
|
||||
// when world state proves the old resident is unreachable;
|
||||
// otherwise retain it for the next frame.
|
||||
if (!_state.IsLoaded(id))
|
||||
transaction.RetirementCursor++;
|
||||
Console.WriteLine(
|
||||
$"streaming: origin-recenter retirement for 0x{id:X8} " +
|
||||
$"will resume: {error}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
transaction.PreparationCommitted = true;
|
||||
return true;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"streaming: origin-recenter preparation will resume: {error}");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_advancingOriginRecenter = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void BeginFullWindowRetirement()
|
||||
{
|
||||
AdvanceGeneration();
|
||||
_collapsed = false;
|
||||
|
|
@ -785,8 +1068,8 @@ public sealed class StreamingController
|
|||
_region = null;
|
||||
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
|
||||
var ids = new List<uint>(_state.LoadedLandblockIds);
|
||||
ForceReloadCount++; // [FRAME-DIAG] churn counter
|
||||
LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume
|
||||
FullWindowRetirementCount++; // [FRAME-DIAG] churn counter
|
||||
LastFullWindowRetirementLandblockCount = ids.Count; // upcoming re-upload volume
|
||||
foreach (var id in ids)
|
||||
_presentation.BeginFullRetirement(id);
|
||||
}
|
||||
|
|
@ -838,14 +1121,13 @@ public sealed class StreamingController
|
|||
}
|
||||
catch
|
||||
{
|
||||
// The presentation pipeline retains its exact committed
|
||||
// stage after the coarse presentation callback commits.
|
||||
// Preserve it and every untouched completion from this
|
||||
// already-drained chunk in original FIFO order. A
|
||||
// presentation-stage failure is deliberately fail-fast
|
||||
// until the focused publishers replace that callback;
|
||||
// its result is not retryable, but the untouched suffix
|
||||
// must still survive.
|
||||
// The concrete presentation pipeline retains the exact
|
||||
// unfinished stage. Preserve it and every untouched
|
||||
// completion from this already-drained chunk in
|
||||
// original FIFO order. Internal compatibility tests may
|
||||
// use a callback pipeline that cannot retain a receipt;
|
||||
// its failed result is consumed, but the untouched
|
||||
// suffix must still survive.
|
||||
if (_presentation.HasPendingPublication(result))
|
||||
_deferredApply.Add(result);
|
||||
for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++)
|
||||
|
|
@ -890,12 +1172,12 @@ public sealed class StreamingController
|
|||
}
|
||||
catch
|
||||
{
|
||||
// A suffix-stage failure keeps its pipeline receipt and
|
||||
// must remain at this exact FIFO position. The still-
|
||||
// monolithic presentation callback deliberately removes
|
||||
// its receipt on failure because its internal committed
|
||||
// prefix is unknown; consume only that failed result so a
|
||||
// later frame cannot replay it automatically.
|
||||
// A concrete suffix-stage failure keeps its pipeline
|
||||
// receipt and must remain at this exact FIFO position. The
|
||||
// internal compatibility callback cannot report its exact
|
||||
// committed prefix and therefore removes its receipt;
|
||||
// consume only that failed result so a later frame cannot
|
||||
// replay it automatically.
|
||||
if (!_presentation.HasPendingPublication(result))
|
||||
read++;
|
||||
throw;
|
||||
|
|
|
|||
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);
|
||||
|
||||
// #145: if the player's current cell belonged to the landblock being removed (a teleport
|
||||
// drops the stale source center via OnLivePositionUpdated), clear it. Otherwise CurrCell
|
||||
// retires the stale source through StreamingOriginRecenterCoordinator and the presentation
|
||||
// pipeline), clear it. Otherwise CurrCell
|
||||
// dangles on an orphaned cell and the dungeon-streaming gate — keyed on CurrCell — keeps
|
||||
// streaming collapsed onto the gone landblock, so the destination never streams in and
|
||||
// only the skybox renders. Clearing it lets the gate read "not in a dungeon" → the
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ using DatEnvironment = DatReaderWriter.DBObjs.Environment;
|
|||
namespace AcDream.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// The parsed dat objects <c>ApplyLoadedTerrainLocked</c> needs, pre-read by the
|
||||
/// streaming worker under <c>_datLock</c> so the apply makes ZERO DatCollection
|
||||
/// calls and the update thread never blocks on the worker's lock (the FPS
|
||||
/// 30↔200 swing was that lock-wait). Keyed by the same dat id the apply would
|
||||
/// have passed to <c>_dats.Get<T></c>.
|
||||
/// Parsed DAT closure produced by <c>LandblockBuildFactory</c> under the shared
|
||||
/// reader gate and consumed by the render-thread presentation publishers.
|
||||
/// Publication makes zero DatCollection calls, so the update thread never
|
||||
/// blocks on worker DAT access. Entries retain their original DAT IDs.
|
||||
/// </summary>
|
||||
public sealed record PhysicsDatBundle(
|
||||
LandBlockInfo? Info,
|
||||
|
|
|
|||
|
|
@ -101,6 +101,66 @@ public sealed class LiveSessionResetPlanTests
|
|||
Assert.Equal(2, secondCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_BlocksNextSessionUntilOriginRetirementConverges()
|
||||
{
|
||||
const uint sourceId = 0x5353FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
sourceId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x53, 0x53));
|
||||
bool failRetirement = true;
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: static (_, _) => { },
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
removeTerrain: _ =>
|
||||
{
|
||||
if (failRetirement)
|
||||
throw new InvalidOperationException("injected ending-session failure");
|
||||
});
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
int identityResets = 0;
|
||||
var plan = new LiveSessionResetPlan(
|
||||
[
|
||||
new("teleport transit", () =>
|
||||
{
|
||||
if (!recenter.Reset(sessionEnding: true))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"streaming-origin retirement remains pending");
|
||||
}
|
||||
}),
|
||||
new("session identity", () =>
|
||||
{
|
||||
identityResets++;
|
||||
origin.Reset();
|
||||
}),
|
||||
]);
|
||||
|
||||
AggregateException blocked = Assert.Throws<AggregateException>(plan.Execute);
|
||||
LiveSessionResetStageException failure = Assert.IsType<LiveSessionResetStageException>(
|
||||
Assert.Single(blocked.InnerExceptions));
|
||||
Assert.Equal("teleport transit", failure.StageName);
|
||||
Assert.Equal(1, identityResets);
|
||||
|
||||
failRetirement = false;
|
||||
plan.Execute();
|
||||
|
||||
Assert.Equal(2, identityResets);
|
||||
Assert.False(recenter.IsPending);
|
||||
Assert.False(origin.IsKnown);
|
||||
Assert.True(origin.TryInitialize(0x61, 0x62));
|
||||
Assert.Equal((0x61, 0x62), (origin.CenterX, origin.CenterY));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_ReentrantAttemptIsReportedButLaterStagesStillRun()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ public sealed class LandblockBuildOriginTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFactoryAndGameWindowPublication_UseCapturedOriginInsteadOfLiveCenter()
|
||||
public void BuildFactoryAndRenderPublisher_UseCapturedOriginWithoutGameWindowFacade()
|
||||
{
|
||||
string root = FindRepoRoot();
|
||||
string gameWindowSource = File.ReadAllText(Path.Combine(
|
||||
|
|
@ -253,11 +253,12 @@ public sealed class LandblockBuildOriginTests
|
|||
"AcDream.App",
|
||||
"Streaming",
|
||||
"LandblockRenderPublisher.cs"));
|
||||
string publicationSection = Slice(
|
||||
gameWindowSource,
|
||||
"private void ApplyLoadedTerrainLocked(",
|
||||
"private void OnUpdate(double dt)");
|
||||
|
||||
string recenterSource = File.ReadAllText(Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Streaming",
|
||||
"StreamingOriginRecenterCoordinator.cs"));
|
||||
Assert.Contains("request.Origin", buildSource, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterX", buildSource, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterY", buildSource, StringComparison.Ordinal);
|
||||
|
|
@ -277,12 +278,68 @@ public sealed class LandblockBuildOriginTests
|
|||
"BuildPhysicsDatBundle",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"ApplyLoadedTerrain",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"PublishLandblockStaticLightingBeforeCollision",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"_landblockPhysicsPublisher!.RemoveLandblock",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"_landblockRenderPublisher",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"_landblockPhysicsPublisher",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"_landblockStaticPresentationPublisher",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"_landblockPresentationPipeline",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"new AcDream.App.Streaming.LandblockRenderPublisher(",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"new AcDream.App.Streaming.LandblockPhysicsPublisher(",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"new AcDream.App.Streaming.LandblockStaticPresentationPublisher(",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("applyTerrain:", gameWindowSource, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("demoteNearLayer:", gameWindowSource, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("retirementCoordinator:", gameWindowSource, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"_liveWorldOrigin.Recenter(lbX, lbY)",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
int retirementBarrier = recenterSource.IndexOf(
|
||||
"IsOriginRecenterRetirementComplete()",
|
||||
StringComparison.Ordinal);
|
||||
int originCommit = recenterSource.IndexOf(
|
||||
"_origin.Recenter(",
|
||||
StringComparison.Ordinal);
|
||||
int destinationCommit = recenterSource.IndexOf(
|
||||
"_streaming.TryCommitOriginRecenter(",
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(retirementBarrier >= 0);
|
||||
Assert.True(originCommit > retirementBarrier);
|
||||
Assert.True(destinationCommit > originCommit);
|
||||
Assert.Contains("ComputeOrigin(landblockId, build.Origin)", renderPublisherSource, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterX", renderPublisherSource, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterY", renderPublisherSource, StringComparison.Ordinal);
|
||||
Assert.Contains("renderPublication.Origin", publicationSection, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterX", publicationSection, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterY", publicationSection, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static LandblockBuild EmptyBuild(uint landblockId, LandblockBuildOrigin origin) =>
|
||||
|
|
@ -316,15 +373,6 @@ public sealed class LandblockBuildOriginTests
|
|||
return results;
|
||||
}
|
||||
|
||||
private static string Slice(string source, string startMarker, string endMarker)
|
||||
{
|
||||
int start = source.IndexOf(startMarker, StringComparison.Ordinal);
|
||||
Assert.True(start >= 0, $"Missing source marker: {startMarker}");
|
||||
int end = source.IndexOf(endMarker, start, StringComparison.Ordinal);
|
||||
Assert.True(end > start, $"Missing source marker: {endMarker}");
|
||||
return source[start..end];
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
string? dir = AppContext.BaseDirectory;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Numerics;
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
|
@ -47,7 +48,7 @@ public sealed class LandblockPresentationPipelineTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ControllerConcreteConstructorHasNoLegacyPresentationParameters()
|
||||
public void PublicPresentationConstructorsExposeOnlyConcreteOwnerGraph()
|
||||
{
|
||||
string[] legacyNames =
|
||||
[
|
||||
|
|
@ -59,14 +60,595 @@ public sealed class LandblockPresentationPipelineTests
|
|||
"retirementCoordinator",
|
||||
];
|
||||
|
||||
System.Reflection.ConstructorInfo concrete = Assert.Single(
|
||||
typeof(StreamingController).GetConstructors(),
|
||||
constructor => constructor.GetParameters().Any(
|
||||
parameter => parameter.Name == "presentationPipeline"));
|
||||
string[] names = concrete.GetParameters()
|
||||
System.Reflection.ConstructorInfo controller = Assert.Single(
|
||||
typeof(StreamingController).GetConstructors());
|
||||
Assert.Contains(
|
||||
controller.GetParameters(),
|
||||
parameter => parameter.Name == "presentationPipeline");
|
||||
string[] names = controller.GetParameters()
|
||||
.Select(parameter => parameter.Name!)
|
||||
.ToArray();
|
||||
Assert.DoesNotContain(names, legacyNames.Contains);
|
||||
|
||||
System.Reflection.ConstructorInfo pipeline = Assert.Single(
|
||||
typeof(LandblockPresentationPipeline).GetConstructors());
|
||||
Type[] pipelineParameterTypes = pipeline.GetParameters()
|
||||
.Select(parameter => parameter.ParameterType)
|
||||
.ToArray();
|
||||
Assert.Contains(typeof(LandblockRenderPublisher), pipelineParameterTypes);
|
||||
Assert.Contains(typeof(LandblockPhysicsPublisher), pipelineParameterTypes);
|
||||
Assert.Contains(typeof(LandblockStaticPresentationPublisher), pipelineParameterTypes);
|
||||
Assert.DoesNotContain(
|
||||
pipeline.GetParameters(),
|
||||
parameter => parameter.Name == "publishBeforeSpatialCommit");
|
||||
|
||||
string[] coordinatorOnlyMethods =
|
||||
[
|
||||
"BeginOriginRecenter",
|
||||
"IsOriginRecenterRetirementComplete",
|
||||
"TryCommitOriginRecenter",
|
||||
"TryCancelOriginRecenter",
|
||||
];
|
||||
foreach (string methodName in coordinatorOnlyMethods)
|
||||
{
|
||||
Assert.Null(typeof(StreamingController).GetMethod(methodName));
|
||||
Assert.NotNull(typeof(StreamingController).GetMethod(
|
||||
methodName,
|
||||
System.Reflection.BindingFlags.Instance |
|
||||
System.Reflection.BindingFlags.NonPublic));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_BlocksOriginAndBootstrapUntilEveryOldOwnerConverges()
|
||||
{
|
||||
const uint centerId = 0x1919FFFFu;
|
||||
const uint neighborId = 0x191AFFFFu;
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
centerId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
neighborId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x19, 0x19));
|
||||
var enqueued = new List<uint>();
|
||||
bool failNeighbor = true;
|
||||
int neighborAttempts = 0;
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: (id, _) => enqueued.Add(id),
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
removeTerrain: id =>
|
||||
{
|
||||
if (id != neighborId)
|
||||
return;
|
||||
neighborAttempts++;
|
||||
if (failNeighbor)
|
||||
throw new InvalidOperationException("injected non-center failure");
|
||||
});
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
|
||||
Assert.False(recenter.Begin(0x22, 0x23, isSealedDungeon: false));
|
||||
|
||||
Assert.Equal((0x19, 0x19), (origin.CenterX, origin.CenterY));
|
||||
Assert.False(state.IsLoaded(centerId));
|
||||
Assert.False(state.IsLoaded(neighborId));
|
||||
Assert.Equal(1, controller.PendingRetirementCount);
|
||||
Assert.Equal(2, controller.LastFullWindowRetirementLandblockCount);
|
||||
|
||||
controller.Tick(0x19, 0x19);
|
||||
Assert.Empty(enqueued);
|
||||
Assert.True(neighborAttempts >= 2);
|
||||
|
||||
failNeighbor = false;
|
||||
Assert.True(recenter.Advance());
|
||||
|
||||
Assert.Equal((0x22, 0x23), (origin.CenterX, origin.CenterY));
|
||||
Assert.Equal(0, controller.PendingRetirementCount);
|
||||
controller.Tick(0x22, 0x23);
|
||||
Assert.Equal([0x2223FFFFu], enqueued);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_PreparationFailureBeforeDetachRetainsOldOriginAndResidents()
|
||||
{
|
||||
const uint landblockId = 0x3030FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
landblockId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x30, 0x30));
|
||||
bool failClear = true;
|
||||
int clearAttempts = 0;
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: static (_, _) => { },
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
clearPendingLoads: () =>
|
||||
{
|
||||
clearAttempts++;
|
||||
if (failClear)
|
||||
throw new InvalidOperationException("injected clear failure");
|
||||
});
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
|
||||
Assert.False(recenter.Begin(0x40, 0x41, isSealedDungeon: false));
|
||||
Assert.Equal((0x30, 0x30), (origin.CenterX, origin.CenterY));
|
||||
Assert.True(state.IsLoaded(landblockId));
|
||||
Assert.Equal(0, controller.PendingRetirementCount);
|
||||
|
||||
failClear = false;
|
||||
Assert.True(recenter.Advance());
|
||||
|
||||
Assert.Equal(2, clearAttempts);
|
||||
Assert.False(state.IsLoaded(landblockId));
|
||||
Assert.Equal((0x40, 0x41), (origin.CenterX, origin.CenterY));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_ObserverFailureHalfwayRetainsBarrierUntilSnapshotFinishes()
|
||||
{
|
||||
const uint firstId = 0x4242FFFFu;
|
||||
const uint secondId = 0x4243FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
firstId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
secondId,
|
||||
new LandBlock(),
|
||||
[Entity(2, serverGuid: 0x70000042u)]));
|
||||
int observerCalls = 0;
|
||||
state.LiveProjectionVisibilityChanged += (_, _) =>
|
||||
{
|
||||
observerCalls++;
|
||||
throw new InvalidOperationException("injected observer failure");
|
||||
};
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x42, 0x42));
|
||||
var removed = new List<uint>();
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: static (_, _) => { },
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
removeTerrain: id => removed.Add(id));
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
|
||||
Assert.False(recenter.Begin(0x43, 0x44, isSealedDungeon: false));
|
||||
|
||||
Assert.Equal((0x42, 0x42), (origin.CenterX, origin.CenterY));
|
||||
Assert.False(state.IsLoaded(firstId));
|
||||
Assert.False(state.IsLoaded(secondId));
|
||||
Assert.Equal(1, observerCalls);
|
||||
|
||||
Assert.True(recenter.Advance());
|
||||
Assert.Equal((0x43, 0x44), (origin.CenterX, origin.CenterY));
|
||||
Assert.Equal([firstId, secondId], removed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_RetryPreservesLiveIdentityAndDoesNotRescueReusedGuid()
|
||||
{
|
||||
const uint oldId = 0x4545FFFFu;
|
||||
const uint destinationId = 0x4646FFFFu;
|
||||
const uint playerGuid = 0x50000045u;
|
||||
const uint remoteGuid = 0x70000045u;
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
oldId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
WorldEntity player = Entity(10, playerGuid);
|
||||
WorldEntity remote = Entity(11, remoteGuid);
|
||||
state.MarkPersistent(playerGuid);
|
||||
state.PlaceLiveEntityProjection(oldId, player);
|
||||
state.PlaceLiveEntityProjection(oldId, remote);
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x45, 0x45));
|
||||
bool failRetirement = true;
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: static (_, _) => { },
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
removeTerrain: _ =>
|
||||
{
|
||||
if (failRetirement)
|
||||
throw new InvalidOperationException("injected live-owner retry");
|
||||
});
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
|
||||
Assert.False(recenter.Begin(0x46, 0x46, isSealedDungeon: false));
|
||||
|
||||
Assert.Same(player, Assert.Single(state.DrainRescued()));
|
||||
Assert.Equal(1, state.PendingLiveEntityCount);
|
||||
Assert.Empty(state.Entities);
|
||||
|
||||
failRetirement = false;
|
||||
Assert.True(recenter.Advance());
|
||||
Assert.Empty(state.DrainRescued());
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
destinationId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
state.RebucketLiveEntity(player, destinationId);
|
||||
Assert.Same(player, Assert.Single(state.Entities));
|
||||
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
oldId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
Assert.Contains(state.Entities, entity => ReferenceEquals(entity, remote));
|
||||
|
||||
state.ForgetLiveEntity(playerGuid);
|
||||
WorldEntity replacement = Entity(12, playerGuid);
|
||||
state.MarkPersistent(playerGuid);
|
||||
state.PlaceLiveEntityProjection(destinationId, replacement);
|
||||
state.RemoveLandblock(destinationId);
|
||||
|
||||
Assert.Same(replacement, Assert.Single(state.DrainRescued()));
|
||||
Assert.DoesNotContain(state.Entities, entity => ReferenceEquals(entity, player));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_ResetWithoutReplacementConvergesAtCurrentOrigin()
|
||||
{
|
||||
const uint landblockId = 0x5050FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
landblockId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x50, 0x50));
|
||||
bool failRetirement = true;
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: static (_, _) => { },
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
removeTerrain: _ =>
|
||||
{
|
||||
if (failRetirement)
|
||||
throw new InvalidOperationException("injected retirement failure");
|
||||
});
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
|
||||
Assert.False(recenter.Begin(0x60, 0x60, isSealedDungeon: false));
|
||||
recenter.Reset();
|
||||
Assert.True(recenter.IsPending);
|
||||
|
||||
failRetirement = false;
|
||||
Assert.True(recenter.Advance());
|
||||
|
||||
Assert.False(recenter.IsPending);
|
||||
Assert.Equal((0x50, 0x50), (origin.CenterX, origin.CenterY));
|
||||
controller.Tick(0x50, 0x50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_ResetBackToSealedSourceKeepsRadiusZeroMode()
|
||||
{
|
||||
const uint sourceId = 0x5252FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
sourceId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x52, 0x52));
|
||||
var enqueued = new List<(uint Id, LandblockStreamJobKind Kind)>();
|
||||
bool failRetirement = true;
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: (id, kind) => enqueued.Add((id, kind)),
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 2,
|
||||
farRadius: 4,
|
||||
removeTerrain: _ =>
|
||||
{
|
||||
if (failRetirement)
|
||||
throw new InvalidOperationException("injected retirement failure");
|
||||
});
|
||||
controller.PreCollapseToDungeon(0x52, 0x52);
|
||||
enqueued.Clear();
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
|
||||
Assert.False(recenter.Begin(0x60, 0x60, isSealedDungeon: false));
|
||||
recenter.Reset();
|
||||
failRetirement = false;
|
||||
Assert.True(recenter.Advance());
|
||||
|
||||
Assert.Equal((0x52, 0x52), (origin.CenterX, origin.CenterY));
|
||||
Assert.Equal([(sourceId, LandblockStreamJobKind.LoadNear)], enqueued);
|
||||
controller.Tick(0x52, 0x52, insideDungeon: true);
|
||||
Assert.Single(enqueued);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_SessionResetConvergesWithoutBootstrappingEndingWorld()
|
||||
{
|
||||
const uint sourceId = 0x5353FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
sourceId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x53, 0x53));
|
||||
var enqueued = new List<uint>();
|
||||
bool failRetirement = true;
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: (id, _) => enqueued.Add(id),
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
removeTerrain: _ =>
|
||||
{
|
||||
if (failRetirement)
|
||||
throw new InvalidOperationException("injected session reset failure");
|
||||
});
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
|
||||
Assert.False(recenter.Begin(0x54, 0x54, isSealedDungeon: true));
|
||||
recenter.Reset(sessionEnding: true);
|
||||
failRetirement = false;
|
||||
Assert.True(recenter.Advance());
|
||||
|
||||
Assert.Empty(enqueued);
|
||||
Assert.Equal((0x53, 0x53), (origin.CenterX, origin.CenterY));
|
||||
controller.Tick(0x53, 0x53);
|
||||
Assert.Equal([sourceId], enqueued);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_SessionResetCannotOverwriteNextSessionOrigin()
|
||||
{
|
||||
const uint sourceId = 0x5353FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
sourceId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x53, 0x53));
|
||||
bool failRetirement = true;
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: static (_, _) => { },
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
removeTerrain: _ =>
|
||||
{
|
||||
if (failRetirement)
|
||||
throw new InvalidOperationException("injected session reset failure");
|
||||
});
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
|
||||
Assert.False(recenter.Begin(0x54, 0x54, isSealedDungeon: true));
|
||||
recenter.Reset(sessionEnding: true);
|
||||
origin.Reset();
|
||||
Assert.True(origin.TryInitialize(0x61, 0x62));
|
||||
|
||||
failRetirement = false;
|
||||
Assert.True(recenter.Advance());
|
||||
|
||||
Assert.Equal((0x61, 0x62), (origin.CenterX, origin.CenterY));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_CommittedPendingClearFailureDoesNotReplayClear()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x10, 0x10));
|
||||
int clearAttempts = 0;
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: static (_, _) => { },
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
clearPendingLoads: () =>
|
||||
{
|
||||
clearAttempts++;
|
||||
throw new StreamingMutationException(
|
||||
"injected committed clear failure",
|
||||
mutationCommitted: true);
|
||||
});
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
|
||||
Assert.False(recenter.Begin(0x11, 0x11, isSealedDungeon: false));
|
||||
Assert.True(recenter.Advance());
|
||||
|
||||
Assert.Equal(1, clearAttempts);
|
||||
Assert.Equal((0x11, 0x11), (origin.CenterX, origin.CenterY));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_ReentrantClearAndRetirementDoNotRecurseOrSkipResidents()
|
||||
{
|
||||
const uint firstId = 0x7070FFFFu;
|
||||
const uint secondId = 0x7071FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
firstId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
secondId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x70, 0x70));
|
||||
StreamingController? controller = null;
|
||||
int clearCalls = 0;
|
||||
var removed = new List<uint>();
|
||||
controller = new StreamingController(
|
||||
enqueueLoad: static (_, _) => { },
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
clearPendingLoads: () =>
|
||||
{
|
||||
clearCalls++;
|
||||
Assert.False(controller!.IsOriginRecenterRetirementComplete());
|
||||
},
|
||||
removeTerrain: id =>
|
||||
{
|
||||
removed.Add(id);
|
||||
if (id == firstId)
|
||||
Assert.False(controller!.IsOriginRecenterRetirementComplete());
|
||||
});
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
|
||||
Assert.True(recenter.Begin(0x71, 0x71, isSealedDungeon: false));
|
||||
|
||||
Assert.Equal(1, clearCalls);
|
||||
Assert.Equal([firstId, secondId], removed);
|
||||
Assert.False(state.IsLoaded(firstId));
|
||||
Assert.False(state.IsLoaded(secondId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_DefersRadiusChangeUntilDestinationCommit()
|
||||
{
|
||||
const uint oldId = 0x7272FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
oldId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x72, 0x72));
|
||||
var enqueued = new List<uint>();
|
||||
bool failRetirement = true;
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: (id, _) => enqueued.Add(id),
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
removeTerrain: _ =>
|
||||
{
|
||||
if (failRetirement)
|
||||
throw new InvalidOperationException("injected radius barrier failure");
|
||||
});
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
|
||||
Assert.False(recenter.Begin(0x73, 0x73, isSealedDungeon: false));
|
||||
controller.ReconfigureRadii(nearRadius: 1, farRadius: 2);
|
||||
controller.Tick(0x72, 0x72);
|
||||
|
||||
Assert.Equal(0, controller.NearRadius);
|
||||
Assert.Equal(0, controller.FarRadius);
|
||||
Assert.Empty(enqueued);
|
||||
|
||||
failRetirement = false;
|
||||
Assert.True(recenter.Advance());
|
||||
controller.Tick(0x73, 0x73);
|
||||
|
||||
Assert.Equal(1, controller.NearRadius);
|
||||
Assert.Equal(2, controller.FarRadius);
|
||||
Assert.Equal(25, enqueued.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_SealedDungeonPinsRadiusZeroBeforeCenterCompletion()
|
||||
{
|
||||
const uint oldId = 0x2020FFFFu;
|
||||
const uint dungeonId = 0x3030FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
oldId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x20, 0x20));
|
||||
var outbox = new Queue<LandblockStreamResult>();
|
||||
var enqueued = new List<(uint Id, LandblockStreamJobKind Kind)>();
|
||||
bool failDungeonEnqueue = true;
|
||||
int enqueueAttempts = 0;
|
||||
int publications = 0;
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: (id, kind) =>
|
||||
{
|
||||
enqueueAttempts++;
|
||||
if (failDungeonEnqueue)
|
||||
throw new InvalidOperationException("injected dungeon enqueue failure");
|
||||
enqueued.Add((id, kind));
|
||||
},
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: max => Drain(outbox, max),
|
||||
applyTerrain: (_, _) => publications++,
|
||||
state,
|
||||
nearRadius: 2,
|
||||
farRadius: 4);
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
|
||||
Assert.False(recenter.Begin(0x30, 0x30, isSealedDungeon: true));
|
||||
Assert.True(recenter.IsPending);
|
||||
controller.Tick(0x30, 0x30, insideDungeon: true);
|
||||
Assert.Empty(enqueued);
|
||||
|
||||
failDungeonEnqueue = false;
|
||||
Assert.True(recenter.Advance());
|
||||
Assert.Equal(2, enqueueAttempts);
|
||||
Assert.Equal([(dungeonId, LandblockStreamJobKind.LoadNear)], enqueued);
|
||||
|
||||
outbox.Enqueue(new LandblockStreamResult.Loaded(
|
||||
dungeonId,
|
||||
LandblockStreamTier.Near,
|
||||
new LoadedLandblock(
|
||||
dungeonId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()),
|
||||
EmptyMesh(),
|
||||
generation: 1));
|
||||
controller.Tick(0x30, 0x30, insideDungeon: true);
|
||||
|
||||
Assert.Equal(1, publications);
|
||||
Assert.True(state.IsNearTier(dungeonId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -715,9 +1297,10 @@ public sealed class LandblockPresentationPipelineTests
|
|||
envCells);
|
||||
}
|
||||
|
||||
private static WorldEntity Entity(uint id) => new()
|
||||
private static WorldEntity Entity(uint id, uint serverGuid = 0) => new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0x01000000u + id,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue