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