refactor(streaming): complete landblock presentation cutover

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

View file

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

View file

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