diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md
index 00790c44..d10905cf 100644
--- a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md
+++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md
@@ -18,7 +18,7 @@ never hidden behind a retry, delay, suppression flag, or reordered callback.
- [x] B — extract the pre-network live-object presentation phase and the
non-advancing post-network spatial reconciler; remove callbacks into
`GameWindow` from `RetailLiveFrameCoordinator`.
-- [ ] C — extract streaming-origin convergence, observer selection, dungeon
+- [x] C — extract streaming-origin convergence, observer selection, dungeon
collapse, streaming tick, and rescued-entity rebucketing.
- [ ] D — extract dispatcher/raw-mouse/combat input sampling without changing
movement-command or UI-capture semantics.
@@ -50,6 +50,17 @@ and removed animation/runtime/projectile back-references to `GameWindow`.
Release tests, the 2,732-test App suite, and the full 7,090-test Release suite
(5 fixture/environment skips); all three independent reviews are clean.
+Checkpoint C moved retained origin convergence, readiness, observer selection,
+sealed-dungeon collapse, streaming publication, and persistent live-projection
+rescue into `StreamingFrameController`. Production keeps streaming publication
+ahead of input and the live object/network barrier, so a same-frame
+CreateObject can enter the newly resident bucket. The focused 146-test Release
+selection, 2,754-test App suite, and complete 7,112-test Release suite (5
+fixture/environment skips) pass. Login-before-position, portal hold, outdoor
+and sealed/SeenOutside EnvCells, pending recenter, exact rescue rebucketing,
+GUID deletion, and session reset are pinned; all three corrected-diff reviews
+are clean. `GameWindow.cs` is now 8,569 raw lines.
+
## 1. Outcome and non-goals
At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one
diff --git a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
index 02a1b2ad..57c4236c 100644
--- a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
+++ b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
@@ -28,3 +28,25 @@ internal sealed class LocalPlayerControllerSlot : ILocalPlayerControllerSource
{
public PlayerMovementController? Controller { get; set; }
}
+
+internal interface ILocalPlayerModeSource
+{
+ bool IsPlayerMode { get; }
+ bool ChaseModeEverEntered { get; }
+}
+
+///
+/// The one mutable local presentation-mode slot. Checkpoint F transfers write
+/// ownership to PlayerModeController; streaming and camera owners only read it.
+///
+internal sealed class LocalPlayerModeState : ILocalPlayerModeSource
+{
+ public bool IsPlayerMode { get; set; }
+ public bool ChaseModeEverEntered { get; set; }
+
+ public void ResetSession()
+ {
+ IsPlayerMode = false;
+ ChaseModeEverEntered = false;
+ }
+}
diff --git a/src/AcDream.App/Net/ILiveInWorldSource.cs b/src/AcDream.App/Net/ILiveInWorldSource.cs
new file mode 100644
index 00000000..48121eb7
--- /dev/null
+++ b/src/AcDream.App/Net/ILiveInWorldSource.cs
@@ -0,0 +1,6 @@
+namespace AcDream.App.Net;
+
+internal interface ILiveInWorldSource
+{
+ bool IsInWorld { get; }
+}
diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs
index 02664eaa..1f956f21 100644
--- a/src/AcDream.App/Net/LiveSessionController.cs
+++ b/src/AcDream.App/Net/LiveSessionController.cs
@@ -158,7 +158,10 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
/// publication, exact-generation Tick, graceful replacement, and convergent
/// teardown. Retail wire behavior remains inside WorldSession.
///
-internal sealed class LiveSessionController : IDisposable, ILiveSessionFramePhase
+internal sealed class LiveSessionController
+ : IDisposable,
+ ILiveSessionFramePhase,
+ ILiveInWorldSource
{
private sealed class SessionScope(
WorldSession session,
diff --git a/src/AcDream.App/Physics/ILocalPlayerLandblockSource.cs b/src/AcDream.App/Physics/ILocalPlayerLandblockSource.cs
new file mode 100644
index 00000000..36026f3f
--- /dev/null
+++ b/src/AcDream.App/Physics/ILocalPlayerLandblockSource.cs
@@ -0,0 +1,6 @@
+namespace AcDream.App.Physics;
+
+internal interface ILocalPlayerLandblockSource
+{
+ uint? LastKnownLandblockId { get; }
+}
diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
index aaebdac4..96f9c50b 100644
--- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
+++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
@@ -25,7 +25,8 @@ namespace AcDream.App.Physics;
///
internal sealed class LiveEntityNetworkUpdateController
: ILiveEntityNetworkUpdateSink,
- ILiveEntitySameGenerationUpdateSink
+ ILiveEntitySameGenerationUpdateSink,
+ ILocalPlayerLandblockSource
{
private readonly LiveEntityRuntime _liveEntities;
private readonly ClientObjectTable _objects;
@@ -72,6 +73,9 @@ internal sealed class LiveEntityNetworkUpdateController
internal uint? LastLivePlayerLandblockId =>
_authorityGate.LastLivePlayerLandblockId;
+ uint? ILocalPlayerLandblockSource.LastKnownLandblockId =>
+ LastLivePlayerLandblockId;
+
public LiveEntityNetworkUpdateController(
LiveEntityRuntime liveEntities,
ClientObjectTable objects,
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 2f7b502f..b02100d8 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -123,6 +123,7 @@ public sealed class GameWindow : IDisposable
_landblockPresentationPipeline;
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
private AcDream.App.Streaming.StreamingController? _streamingController;
+ private AcDream.App.Update.IStreamingFramePhase _streamingFrame = null!;
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback)
private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9)
@@ -531,7 +532,12 @@ public sealed class GameWindow : IDisposable
}
private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
- private bool _playerMode;
+ private readonly AcDream.App.Input.LocalPlayerModeState _localPlayerMode = new();
+ private bool _playerMode
+ {
+ get => _localPlayerMode.IsPlayerMode;
+ set => _localPlayerMode.IsPlayerMode = value;
+ }
private readonly AcDream.App.Input.LocalPlayerIdentityState _localPlayerIdentity = new();
private uint _playerServerGuid
{
@@ -661,18 +667,6 @@ public sealed class GameWindow : IDisposable
private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new();
private int _liveCenterX => _liveWorldOrigin.CenterX;
private int _liveCenterY => _liveWorldOrigin.CenterY;
- // #192 (2026-07-09): true once the player's first canonical Position has
- // been accepted — i.e. _liveCenterX/Y reflects a real, server-confirmed
- // position, not the Holtburg startup placeholder. Renderability is not a
- // prerequisite: a partial CreateObject may carry Position before Setup.
- // Gates
- // streaming via StreamingReadinessGate.ShouldStream so no landblock is ever
- // built around a still-guessed center in live mode. See
- // AcDream.Core.World.StreamingReadinessGate for the full mechanism this
- // closes (stale-centered geometry built during the InWorld-but-not-yet-
- // located window, applied late once its build finished).
- private bool _liveCenterKnown => _liveWorldOrigin.IsKnown;
-
// K-fix1 (2026-04-26): cached at startup so per-frame branches are
// single-flag reads instead of env-var lookups. True iff
// ACDREAM_LIVE=1 was set when the window came up.
@@ -699,7 +693,11 @@ public sealed class GameWindow : IDisposable
// Latches true the first time chase mode becomes active. Used by
// IsLiveModeWaitingForLogin to suppress the pre-login render gate
// for subsequent fly-mode toggles.
- private bool _chaseModeEverEntered;
+ private bool _chaseModeEverEntered
+ {
+ get => _localPlayerMode.ChaseModeEverEntered;
+ set => _localPlayerMode.ChaseModeEverEntered = value;
+ }
///
/// Phase 6.6/6.7: server-guid → local WorldEntity lookup so
@@ -2654,6 +2652,22 @@ public sealed class GameWindow : IDisposable
// gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
_liveWorldOrigin.SetPlaceholder(centerX, centerY);
_liveSessionController = new AcDream.App.Net.LiveSessionController();
+ _streamingFrame = new AcDream.App.Streaming.StreamingFrameController(
+ _options.LiveMode,
+ _localPlayerMode,
+ _playerControllerSlot,
+ _liveSessionController,
+ _liveWorldOrigin,
+ _liveEntityNetworkUpdates!,
+ new AcDream.App.Streaming.FlyCameraStreamingObserverSource(
+ _cameraController!),
+ new AcDream.App.Streaming.PhysicsStreamingDungeonCellSource(
+ _physicsEngine),
+ _streamingOriginRecenter!,
+ _streamingController!,
+ new AcDream.App.Streaming.LiveProjectionRescueRebucketter(
+ _worldState,
+ _liveEntities));
var liveEffectFrame = new AcDream.App.Update.LiveEffectFrameController(
_translucencyFades,
_animationHookFrames!,
@@ -2803,7 +2817,7 @@ public sealed class GameWindow : IDisposable
}
finally
{
- _playerMode = false;
+ _localPlayerMode.ResetSession();
_playerController = null;
_playerHost = null;
_chaseCamera = null;
@@ -2811,7 +2825,6 @@ public sealed class GameWindow : IDisposable
_playerMouseDeltaX = 0f;
_rmbHeld = false;
_autoRunActive = false;
- _chaseModeEverEntered = false;
_lastMovementTruthOutbound = null;
_localPlayerShadow.Clear();
_spawnClaimRangeMemo = null;
@@ -3836,169 +3849,9 @@ public sealed class GameWindow : IDisposable
// reuse the same timestamp for animation hooks and the later drain.
_scriptRunner?.PublishTime(frameTiming.ScriptTime);
- // 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,
- // streaming tick second) caused the initial CreateObject flood from
- // login to land before any landblock was loaded; live projection placement
- // is a no-op for unloaded landblocks, so all 40+ NPCs/weenies were
- // silently dropped on the first frame and never rendered.
- //
- // K-fix1 (2026-04-26): skip streaming entirely while live mode is
- // configured but the chase camera hasn't engaged yet — otherwise
- // the orbit camera at startup centers on the hardcoded
- // 0xA9B4 (Holtburg) and Holtburg landblocks render briefly
- // until the player spawn arrives + auto-entry switches to chase.
- //
- // #106 gate-3 (2026-06-09): narrowed to PRE-LOGIN only. The original
- // form also blocked the in-world window between EnterWorld and
- // auto-entry, which deadlocked the spawn-ground entry hold:
- // auto-entry waits for terrain under the spawn, terrain streaming
- // waited for chase mode. Once the session is InWorld the player's
- // landblock is known (the fly-camera else-if below was written for
- // exactly this window) — stream it. Pre-login, streaming stays
- // blocked, which is the hardcoded-Holtburg flash K-fix1 targeted.
- // 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
- // CreateObject (and hence the real center) has arrived. Streaming that
- // ran in that gap baked landblock offsets from the still-placeholder
- // _liveCenterX/Y (Holtburg), producing stale-positioned geometry applied
- // late once its build finished. Require _liveCenterKnown too — see
- // AcDream.Core.World.StreamingReadinessGate for the full mechanism.
- if (_streamingController is not null && _cameraController is not null
- && AcDream.Core.World.StreamingReadinessGate.ShouldStream(
- LiveModeEnabled, _chaseModeEverEntered, liveInWorld, _liveCenterKnown))
- {
- int observerCx = _liveCenterX;
- int observerCy = _liveCenterY;
-
- if (_playerMode && _playerController is not null
- && _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. 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
- // outdoor teleport too: pre-load the destination during the hold.
- //
- // NOTE: these assignments equal the observerCx/Cy defaults initialized
- // above — the LOAD-BEARING effect of this branch is INHIBITING the
- // position-derived offset in the else-if below while the player position
- // is frozen, not the (redundant) assignment. Kept explicit for clarity.
- observerCx = _liveCenterX;
- observerCy = _liveCenterY;
- }
- else if (_playerMode && _playerController is not null)
- {
- // Player mode: follow the physics-resolved player position.
- // The player walks via the local physics engine; the server
- // doesn't echo back our autonomous moves, so _lastLivePlayer*
- // stays at the login position. Compute the landblock from the
- // controller's current world-space position instead.
- var pp = _playerController.Position;
- observerCx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
- observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
- }
- else if (liveInWorld)
- {
- // Live, not yet in player mode: the login auto-entry hold, or a live
- // fly-camera spectator. Follow the PLAYER's server-known landblock; if it
- // hasn't arrived yet, KEEP the _liveCenterX/_liveCenterY default — which is
- // the spawn/teleport recenter (the dungeon landblock at a dungeon login).
- //
- // #135 regression fix (2026-06-14): this MUST NOT fall through to the
- // fly-camera projection below. During a dungeon-login hold the streaming is
- // pre-collapsed onto the spawn landblock; a camera-derived observer far from
- // it trips ExitDungeonExpand and unloads the dungeon before it can hydrate —
- // the player is never placed and login hangs with no dungeon. Previously
- // The last-player-landblock hint was once set by ANY entity, so a dungeon-local NPC
- // kept this branch on the dungeon; once it was filtered to the player guid
- // (line ~4507), a not-yet-arrived player UP dropped to the camera branch.
- // The fly camera is the OFFLINE observer only.
- if (_liveEntityNetworkUpdates?.LastLivePlayerLandblockId is { } lid)
- {
- observerCx = (int)((lid >> 24) & 0xFFu);
- observerCy = (int)((lid >> 16) & 0xFFu);
- }
- // else: keep the _liveCenterX/_liveCenterY default (the spawn recenter).
- }
- else
- {
- // Offline: project the fly camera's world-space position back into
- // landblock coordinates. OrbitCamera doesn't expose Position via
- // ICamera, but FlyCamera.Position is always updated (even when the
- // orbit camera is Active), so this is safe in both modes.
- // Each landblock is 192 world units wide.
- var camPos = _cameraController.Fly.Position;
- observerCx = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f);
- observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
- }
-
- // Dungeon gate (#133 FPS): when the player stands in a SEALED EnvCell
- // (indoor cell that doesn't see outside — the same predicate that kills
- // the sun/sky, playerInsideCell below), collapse streaming to the single
- // dungeon landblock. AC dungeons have no adjacent landblocks; the 25×25
- // window otherwise pulls in ~129 unrelated ocean-grid dungeons. Building
- // interiors (cottage/inn) have SeenOutside cells, so they are NOT gated
- // and keep their surrounding terrain.
- // True only for a sealed indoor cell. Read the physics CurrCell's own
- // SeenOutside (ObjCell.SeenOutside, set from the EnvCell dat flags) rather
- // than the render registry: the registry lookup only succeeds AFTER the
- // landblock FINALIZES (~tens of seconds for a 205-cell dungeon), which
- // delayed the collapse and let the full 25×25 neighbor window churn in
- // first (the "~30s to stabilize" report). CurrCell.SeenOutside is set the
- // moment the player is placed, so the collapse now engages at the snap.
- // AP-36 / #145: during a teleport HOLD the player is unplaced and CurrCell is
- // the frozen SOURCE cell. Suppress the source-cell gate so a teleport OUT of a
- // dungeon follows the destination (the PortalSpace observer pin above) instead
- // of staying pinned to the source dungeon — see DungeonStreamingGate.
- bool isTeleportHold =
- _playerController is { State: AcDream.App.Input.PlayerState.PortalSpace };
- var currCell = _physicsEngine.DataCache?.CellGraph.CurrCell;
- bool currSealedDungeon =
- currCell is AcDream.Core.World.Cells.EnvCell pcEnv && !pcEnv.SeenOutside;
- var gate = AcDream.App.Streaming.DungeonStreamingGate.Compute(
- isTeleportHold, currSealedDungeon, currCell?.Id ?? 0u);
- bool insideDungeon = gate.InsideDungeon;
- if (gate.ObserverLandblockKey is { } cellLb)
- {
- // Pin the collapse to the cell's OWN landblock (cell id high 16 bits),
- // NOT the position-derived observer landblock. A dungeon's EnvCells sit
- // at arbitrary world coords (the "ocean" placement) with negative local
- // offsets, so floor(pp.Y/192) lands one landblock off — which collapses
- // onto the WRONG landblock and unloads the real dungeon, nulling CurrCell
- // and breaking the render (the Bug-A coordinate class). The cell id is the
- // authoritative landblock.
- observerCx = (int)((cellLb >> 8) & 0xFFu);
- observerCy = (int)(cellLb & 0xFFu);
- }
- _streamingController.Tick(observerCx, observerCy, insideDungeon);
-
- // Re-inject persistent entities rescued from unloaded landblocks
- // into the current center landblock (the one the observer is in).
- var rescued = _worldState.DrainRescued();
- if (rescued.Count > 0)
- {
- uint centerLb = (uint)((observerCx << 24) | (observerCy << 16) | 0xFFFF);
- foreach (var entity in rescued)
- _liveEntities?.RebucketLiveEntity(entity.ServerGuid, centerLb);
- }
- }
+ // Streaming publishes before inbound CreateObject dispatch so newly
+ // accepted live projections can enter a resident bucket this frame.
+ _streamingFrame.Tick();
// Input callbacks feed the current object tick. Packets already read
// by Core.Net remain queued until the retail object/physics phase has
diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs
index 0581647d..c746f644 100644
--- a/src/AcDream.App/Streaming/StreamingController.cs
+++ b/src/AcDream.App/Streaming/StreamingController.cs
@@ -16,7 +16,7 @@ namespace AcDream.App.Streaming;
/// Threading: not thread-safe. All calls must happen on the render thread.
///
///
-public sealed class StreamingController
+public sealed class StreamingController : IStreamingFrameBackend
{
private sealed class OriginRecenterRetirement
{
diff --git a/src/AcDream.App/Streaming/StreamingFrameController.cs b/src/AcDream.App/Streaming/StreamingFrameController.cs
new file mode 100644
index 00000000..d32525b2
--- /dev/null
+++ b/src/AcDream.App/Streaming/StreamingFrameController.cs
@@ -0,0 +1,227 @@
+using System.Numerics;
+using AcDream.App.Input;
+using AcDream.App.Net;
+using AcDream.App.Physics;
+using AcDream.App.Rendering;
+using AcDream.App.Update;
+using AcDream.App.World;
+using AcDream.Core.Physics;
+using AcDream.Core.World;
+using AcDream.Core.World.Cells;
+
+namespace AcDream.App.Streaming;
+
+internal interface IStreamingOriginConvergence
+{
+ bool Advance();
+}
+
+internal interface IStreamingFrameBackend
+{
+ void Tick(int observerCx, int observerCy, bool insideDungeon = false);
+}
+
+internal interface IOfflineStreamingObserverSource
+{
+ Vector3 Position { get; }
+}
+
+internal sealed class FlyCameraStreamingObserverSource
+ : IOfflineStreamingObserverSource
+{
+ private readonly CameraController _camera;
+
+ public FlyCameraStreamingObserverSource(CameraController camera) =>
+ _camera = camera ?? throw new ArgumentNullException(nameof(camera));
+
+ public Vector3 Position => _camera.Fly.Position;
+}
+
+internal readonly record struct StreamingDungeonCellSnapshot(
+ uint CellId,
+ bool IsSealedDungeon);
+
+internal interface IStreamingDungeonCellSource
+{
+ StreamingDungeonCellSnapshot Capture();
+}
+
+internal sealed class PhysicsStreamingDungeonCellSource
+ : IStreamingDungeonCellSource
+{
+ private readonly PhysicsEngine _physics;
+
+ public PhysicsStreamingDungeonCellSource(PhysicsEngine physics) =>
+ _physics = physics ?? throw new ArgumentNullException(nameof(physics));
+
+ public StreamingDungeonCellSnapshot Capture()
+ {
+ ObjCell? cell = _physics.DataCache?.CellGraph.CurrCell;
+ return new StreamingDungeonCellSnapshot(
+ cell?.Id ?? 0u,
+ cell is EnvCell envCell && !envCell.SeenOutside);
+ }
+}
+
+internal interface ILiveProjectionRescueRebucketter
+{
+ void RebucketAll(int observerCx, int observerCy);
+}
+
+internal sealed class LiveProjectionRescueRebucketter
+ : ILiveProjectionRescueRebucketter
+{
+ private readonly GpuWorldState _worldState;
+ private readonly LiveEntityRuntime _liveEntities;
+
+ public LiveProjectionRescueRebucketter(
+ GpuWorldState worldState,
+ LiveEntityRuntime liveEntities)
+ {
+ _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
+ _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
+ }
+
+ public void RebucketAll(int observerCx, int observerCy)
+ {
+ List rescued = _worldState.DrainRescued();
+ if (rescued.Count == 0)
+ return;
+
+ uint centerLandblock =
+ ((uint)observerCx << 24) | ((uint)observerCy << 16) | 0xFFFFu;
+ foreach (WorldEntity entity in rescued)
+ _liveEntities.RebucketLiveEntity(entity.ServerGuid, centerLandblock);
+ }
+}
+
+///
+/// Owns the complete pre-input streaming phase of one host update: retained
+/// origin convergence, readiness, observer selection, sealed-dungeon collapse,
+/// completion publication, and persistent-projection rescue.
+///
+internal sealed class StreamingFrameController : IStreamingFramePhase
+{
+ private const float LandblockLength = 192f;
+
+ private readonly bool _liveModeEnabled;
+ private readonly ILocalPlayerModeSource _playerMode;
+ private readonly ILocalPlayerControllerSource _playerController;
+ private readonly ILiveInWorldSource _session;
+ private readonly LiveWorldOriginState _origin;
+ private readonly ILocalPlayerLandblockSource _playerLandblock;
+ private readonly IOfflineStreamingObserverSource _offlineObserver;
+ private readonly IStreamingDungeonCellSource _dungeonCell;
+ private readonly IStreamingOriginConvergence _originConvergence;
+ private readonly IStreamingFrameBackend _streaming;
+ private readonly ILiveProjectionRescueRebucketter _rescues;
+
+ public StreamingFrameController(
+ bool liveModeEnabled,
+ ILocalPlayerModeSource playerMode,
+ ILocalPlayerControllerSource playerController,
+ ILiveInWorldSource session,
+ LiveWorldOriginState origin,
+ ILocalPlayerLandblockSource playerLandblock,
+ IOfflineStreamingObserverSource offlineObserver,
+ IStreamingDungeonCellSource dungeonCell,
+ IStreamingOriginConvergence originConvergence,
+ IStreamingFrameBackend streaming,
+ ILiveProjectionRescueRebucketter rescues)
+ {
+ _liveModeEnabled = liveModeEnabled;
+ _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
+ _playerController = playerController
+ ?? throw new ArgumentNullException(nameof(playerController));
+ _session = session ?? throw new ArgumentNullException(nameof(session));
+ _origin = origin ?? throw new ArgumentNullException(nameof(origin));
+ _playerLandblock = playerLandblock
+ ?? throw new ArgumentNullException(nameof(playerLandblock));
+ _offlineObserver = offlineObserver
+ ?? throw new ArgumentNullException(nameof(offlineObserver));
+ _dungeonCell = dungeonCell ?? throw new ArgumentNullException(nameof(dungeonCell));
+ _originConvergence = originConvergence
+ ?? throw new ArgumentNullException(nameof(originConvergence));
+ _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming));
+ _rescues = rescues ?? throw new ArgumentNullException(nameof(rescues));
+ }
+
+ public void Tick()
+ {
+ // A pending teleport/session origin retirement must converge even while
+ // ordinary streaming is gated. This remains the first streaming edge.
+ _originConvergence.Advance();
+
+ bool liveInWorld = _session.IsInWorld;
+ // #192: InWorld precedes the player's first authoritative Position.
+ // Starting an async build against the placeholder origin bakes stale
+ // offsets that can publish after the real origin arrives, so live
+ // startup requires both edges. A prior chase lifetime remains open for
+ // intentional later fly-mode observation, exactly as before extraction.
+ if (!StreamingReadinessGate.ShouldStream(
+ _liveModeEnabled,
+ _playerMode.ChaseModeEverEntered,
+ liveInWorld,
+ _origin.IsKnown))
+ {
+ return;
+ }
+
+ (int observerCx, int observerCy) = SelectObserver(liveInWorld);
+ PlayerMovementController? controller = _playerController.Controller;
+ bool isTeleportHold = controller is { State: PlayerState.PortalSpace };
+ StreamingDungeonCellSnapshot currentCell = _dungeonCell.Capture();
+ DungeonGateResult gate = DungeonStreamingGate.Compute(
+ isTeleportHold,
+ currentCell.IsSealedDungeon,
+ currentCell.CellId);
+ if (gate.ObserverLandblockKey is { } cellLandblock)
+ {
+ observerCx = (int)((cellLandblock >> 8) & 0xFFu);
+ observerCy = (int)(cellLandblock & 0xFFu);
+ }
+
+ _streaming.Tick(observerCx, observerCy, gate.InsideDungeon);
+ _rescues.RebucketAll(observerCx, observerCy);
+ }
+
+ private (int X, int Y) SelectObserver(bool liveInWorld)
+ {
+ int observerCx = _origin.CenterX;
+ int observerCy = _origin.CenterY;
+ PlayerMovementController? controller = _playerController.Controller;
+
+ if (_playerMode.IsPlayerMode
+ && controller is { State: PlayerState.PortalSpace })
+ {
+ // The player root remains frozen in the source coordinate frame.
+ // The accepted shared origin already names the destination.
+ return (observerCx, observerCy);
+ }
+
+ if (_playerMode.IsPlayerMode && controller is not null)
+ {
+ Vector3 position = controller.Position;
+ return (
+ observerCx + (int)Math.Floor(position.X / LandblockLength),
+ observerCy + (int)Math.Floor(position.Y / LandblockLength));
+ }
+
+ if (liveInWorld)
+ {
+ if (_playerLandblock.LastKnownLandblockId is { } landblockId)
+ {
+ return (
+ (int)((landblockId >> 24) & 0xFFu),
+ (int)((landblockId >> 16) & 0xFFu));
+ }
+
+ return (observerCx, observerCy);
+ }
+
+ Vector3 cameraPosition = _offlineObserver.Position;
+ return (
+ observerCx + (int)Math.Floor(cameraPosition.X / LandblockLength),
+ observerCy + (int)Math.Floor(cameraPosition.Y / LandblockLength));
+ }
+}
diff --git a/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs b/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs
index 73fd4c40..c43fa647 100644
--- a/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs
+++ b/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs
@@ -9,7 +9,7 @@ namespace AcDream.App.Streaming;
/// 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
+internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConvergence
{
private readonly record struct Request(
int DestinationX,
diff --git a/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs
new file mode 100644
index 00000000..a56c88f8
--- /dev/null
+++ b/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs
@@ -0,0 +1,556 @@
+using System.Numerics;
+using AcDream.App.Input;
+using AcDream.App.Net;
+using AcDream.App.Physics;
+using AcDream.App.Streaming;
+using AcDream.App.World;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Core.Terrain;
+using AcDream.Core.World;
+using AcDream.Core.World.Cells;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.App.Tests.Streaming;
+
+public sealed class StreamingFrameControllerTests
+{
+ [Fact]
+ public void LivePreLogin_AdvancesOriginConvergenceButSuppressesStreaming()
+ {
+ var fixture = new Fixture(liveMode: true);
+
+ fixture.Controller.Tick();
+
+ Assert.Equal(1, fixture.Convergence.Advances);
+ Assert.Empty(fixture.Streaming.Calls);
+ Assert.Equal(0, fixture.Dungeon.Captures);
+ Assert.Empty(fixture.Rescues.Calls);
+ }
+
+ [Fact]
+ public void LiveInWorldWithoutAuthoritativeOrigin_RemainsGated()
+ {
+ var fixture = new Fixture(liveMode: true);
+ fixture.Session.IsInWorld = true;
+
+ fixture.Controller.Tick();
+
+ Assert.Equal(1, fixture.Convergence.Advances);
+ Assert.Empty(fixture.Streaming.Calls);
+ }
+
+ [Fact]
+ public void ChaseLifetimeAfterEntry_AllowsConvergenceWithNoCurrentSession()
+ {
+ var fixture = new Fixture(liveMode: true);
+ fixture.Mode.ChaseModeEverEntered = true;
+
+ fixture.Controller.Tick();
+
+ Assert.Equal([(10, 20, false)], fixture.Streaming.Calls);
+ }
+
+ [Fact]
+ public void ConvergenceCompletesBeforeObserverSelection()
+ {
+ var fixture = new Fixture();
+ fixture.Convergence.OnAdvance = () => fixture.Origin.Recenter(30, 40);
+
+ fixture.Controller.Tick();
+
+ Assert.Equal([(30, 40, false)], fixture.Streaming.Calls);
+ }
+
+ [Fact]
+ public void PendingConvergence_StillTicksBackendBeforeRescueSoBackendOwnsItsGate()
+ {
+ var calls = new List();
+ var fixture = new Fixture();
+ fixture.Convergence.AdvanceResult = false;
+ fixture.Streaming.OnTick = (_, _, _) => calls.Add("backend");
+ fixture.Rescues.OnRebucket = (_, _) => calls.Add("rescues");
+
+ fixture.Controller.Tick();
+
+ Assert.Equal(["backend", "rescues"], calls);
+ Assert.Equal([(10, 20, false)], fixture.Streaming.Calls);
+ Assert.Equal([(10, 20)], fixture.Rescues.Calls);
+ }
+
+ [Fact]
+ public void LivePrePlayerMode_UsesAuthoritativePlayerLandblock()
+ {
+ var fixture = new Fixture(liveMode: true, initializeOrigin: true);
+ fixture.Session.IsInWorld = true;
+ fixture.PlayerLandblock.LastKnownLandblockId = 0xA1B20001u;
+ fixture.Offline.Position = new Vector3(9000f, 9000f, 0f);
+
+ fixture.Controller.Tick();
+
+ Assert.Equal([(0xA1, 0xB2, false)], fixture.Streaming.Calls);
+ Assert.Equal(0, fixture.Offline.Reads);
+ }
+
+ [Fact]
+ public void LivePrePlayerModeWithoutPositionHint_StaysOnAcceptedOrigin()
+ {
+ var fixture = new Fixture(liveMode: true, initializeOrigin: true);
+ fixture.Session.IsInWorld = true;
+ fixture.Offline.Position = new Vector3(9000f, 9000f, 0f);
+
+ fixture.Controller.Tick();
+
+ Assert.Equal([(10, 20, false)], fixture.Streaming.Calls);
+ Assert.Equal(0, fixture.Offline.Reads);
+ }
+
+ [Fact]
+ public void PlayerModeWithoutController_InWorldStillUsesAuthoritativePlayerHint()
+ {
+ var fixture = new Fixture(liveMode: true, initializeOrigin: true);
+ fixture.Mode.IsPlayerMode = true;
+ fixture.Session.IsInWorld = true;
+ fixture.PlayerLandblock.LastKnownLandblockId = 0xC95B0001u;
+
+ fixture.Controller.Tick();
+
+ Assert.Equal([(0xC9, 0x5B, false)], fixture.Streaming.Calls);
+ Assert.Equal(0, fixture.Offline.Reads);
+ }
+
+ [Fact]
+ public void PlayerModeWithoutController_OfflineFallsBackToFlyObserver()
+ {
+ var fixture = new Fixture();
+ fixture.Mode.IsPlayerMode = true;
+ fixture.Offline.Position = new Vector3(192f, 384f, 0f);
+
+ fixture.Controller.Tick();
+
+ Assert.Equal([(11, 22, false)], fixture.Streaming.Calls);
+ Assert.Equal(1, fixture.Offline.Reads);
+ }
+
+ [Fact]
+ public void PlayerMode_UsesPhysicsResolvedPositionIncludingNegativeFloor()
+ {
+ var fixture = new Fixture(initializeOrigin: true);
+ fixture.Mode.IsPlayerMode = true;
+ var player = new PlayerMovementController(new PhysicsEngine());
+ player.SetPosition(
+ new Vector3(383f, -0.01f, 0f),
+ 0x0A140001u,
+ new Vector3(1f, 1f, 0f));
+ fixture.Player.Controller = player;
+
+ fixture.Controller.Tick();
+
+ Assert.Equal([(11, 19, false)], fixture.Streaming.Calls);
+ }
+
+ [Fact]
+ public void PortalSpace_PinsDestinationOriginAndSuppressesFrozenSourceDungeon()
+ {
+ var fixture = new Fixture(initializeOrigin: true);
+ fixture.Mode.IsPlayerMode = true;
+ var player = new PlayerMovementController(new PhysicsEngine())
+ {
+ State = PlayerState.PortalSpace,
+ };
+ player.SetPosition(
+ new Vector3(1000f, -1000f, 0f),
+ 0x0A140001u,
+ new Vector3(1f, 1f, 0f));
+ fixture.Player.Controller = player;
+ fixture.Dungeon.Snapshot = new StreamingDungeonCellSnapshot(
+ 0xAABB0001u,
+ IsSealedDungeon: true);
+
+ fixture.Controller.Tick();
+
+ Assert.Equal([(10, 20, false)], fixture.Streaming.Calls);
+ }
+
+ [Fact]
+ public void SealedDungeon_PinsObserverToCurrentCellsOwnLandblock()
+ {
+ var fixture = new Fixture(initializeOrigin: true);
+ fixture.Mode.IsPlayerMode = true;
+ var player = new PlayerMovementController(new PhysicsEngine());
+ player.SetPosition(
+ new Vector3(1000f, -1000f, 0f),
+ 0x0A140001u,
+ new Vector3(1f, 1f, 0f));
+ fixture.Player.Controller = player;
+ fixture.Dungeon.Snapshot = new StreamingDungeonCellSnapshot(
+ 0xAABB0001u,
+ IsSealedDungeon: true);
+
+ fixture.Controller.Tick();
+
+ Assert.Equal([(0xAA, 0xBB, true)], fixture.Streaming.Calls);
+ }
+
+ [Fact]
+ public void OfflineMode_UsesFlyCameraRelativeToPlaceholderOrigin()
+ {
+ var fixture = new Fixture();
+ fixture.Offline.Position = new Vector3(384f, -0.01f, 0f);
+
+ fixture.Controller.Tick();
+
+ Assert.Equal([(12, 19, false)], fixture.Streaming.Calls);
+ Assert.Equal(1, fixture.Offline.Reads);
+ }
+
+ [Fact]
+ public void StreamingBackendTickPrecedesRescueRebucketAtSameObserver()
+ {
+ var calls = new List();
+ var fixture = new Fixture();
+ fixture.Streaming.OnTick = (_, _, _) => calls.Add("streaming");
+ fixture.Rescues.OnRebucket = (_, _) => calls.Add("rescues");
+
+ fixture.Controller.Tick();
+
+ Assert.Equal(["streaming", "rescues"], calls);
+ Assert.Equal([(10, 20)], fixture.Rescues.Calls);
+ }
+
+ [Fact]
+ public void PhysicsDungeonCellSource_MapsMissingOutdoorAndSealedCells()
+ {
+ var physics = new PhysicsEngine
+ {
+ DataCache = new PhysicsDataCache(),
+ };
+ var source = new PhysicsStreamingDungeonCellSource(physics);
+
+ Assert.Equal(default, source.Capture());
+
+ var heights = new byte[81];
+ var heightTable = new float[256];
+ physics.DataCache.CellGraph.RegisterTerrain(
+ 0xAABB0000u,
+ new TerrainSurface(heights, heightTable),
+ Vector3.Zero);
+ physics.UpdatePlayerCurrCell(0xAABB0001u);
+
+ Assert.Equal(
+ new StreamingDungeonCellSnapshot(0xAABB0001u, IsSealedDungeon: false),
+ source.Capture());
+
+ var outsideVisibleCell = new AcDream.Core.World.Cells.EnvCell(
+ 0xCCDD0100u,
+ Matrix4x4.Identity,
+ Matrix4x4.Identity,
+ Vector3.Zero,
+ Vector3.One,
+ Array.Empty(),
+ Array.Empty(),
+ seenOutside: true,
+ containmentBsp: null);
+ physics.DataCache.CellGraph.Add(outsideVisibleCell);
+ physics.UpdatePlayerCurrCell(outsideVisibleCell.Id);
+
+ Assert.Equal(
+ new StreamingDungeonCellSnapshot(
+ outsideVisibleCell.Id,
+ IsSealedDungeon: false),
+ source.Capture());
+
+ var sealedCell = new AcDream.Core.World.Cells.EnvCell(
+ 0xCCDD0101u,
+ Matrix4x4.Identity,
+ Matrix4x4.Identity,
+ Vector3.Zero,
+ Vector3.One,
+ Array.Empty(),
+ Array.Empty(),
+ seenOutside: false,
+ containmentBsp: null);
+ physics.DataCache.CellGraph.Add(sealedCell);
+ physics.UpdatePlayerCurrCell(sealedCell.Id);
+
+ Assert.Equal(
+ new StreamingDungeonCellSnapshot(sealedCell.Id, IsSealedDungeon: true),
+ source.Capture());
+ }
+
+ [Fact]
+ public void LiveProjectionRescueRebucketter_RebucketsIntoExactObserverLandblock()
+ {
+ const uint guid = 0x50000031u;
+ const uint sourceLandblock = 0x0101FFFFu;
+ const uint sourceCell = 0x01010001u;
+ var state = new GpuWorldState();
+ state.AddLandblock(EmptyLandblock(sourceLandblock));
+ var runtime = Runtime(state);
+ runtime.RegisterLiveEntity(Spawn(guid, sourceCell));
+ WorldEntity entity = runtime.MaterializeLiveEntity(
+ guid,
+ sourceCell,
+ id => Entity(id, guid))!;
+ state.MarkPersistent(guid);
+ state.RemoveLandblock(sourceLandblock);
+ Assert.Equal(1, state.PendingRescueCount);
+
+ new LiveProjectionRescueRebucketter(state, runtime).RebucketAll(0xAA, 0xBB);
+
+ Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
+ Assert.Same(entity, record.WorldEntity);
+ Assert.Equal(0xAABBFFFFu, record.CanonicalLandblockId);
+ Assert.Equal(0, state.PendingRescueCount);
+ Assert.Equal(1, state.PendingLiveEntityCount);
+ }
+
+ [Fact]
+ public void LiveProjectionRescueRebucketter_IgnoresEmptyAndDeletedRescues()
+ {
+ const uint guid = 0x50000032u;
+ const uint sourceLandblock = 0x0101FFFFu;
+ const uint sourceCell = 0x01010001u;
+ var state = new GpuWorldState();
+ var runtime = Runtime(state);
+ var adapter = new LiveProjectionRescueRebucketter(state, runtime);
+
+ adapter.RebucketAll(0xAA, 0xBB);
+ Assert.Equal(0, state.PendingLiveEntityCount);
+
+ state.AddLandblock(EmptyLandblock(sourceLandblock));
+ runtime.RegisterLiveEntity(Spawn(guid, sourceCell));
+ runtime.MaterializeLiveEntity(guid, sourceCell, id => Entity(id, guid));
+ state.MarkPersistent(guid);
+ state.RemoveLandblock(sourceLandblock);
+ Assert.Equal(1, state.PendingRescueCount);
+ runtime.UnregisterLiveEntity(
+ new DeleteObject.Parsed(guid, InstanceSequence: 1),
+ isLocalPlayer: false);
+
+ adapter.RebucketAll(0xAA, 0xBB);
+
+ Assert.Equal(0, state.PendingRescueCount);
+ Assert.Equal(0, state.PendingLiveEntityCount);
+ Assert.Empty(state.Entities);
+ }
+
+ [Fact]
+ public void StreamingPublicationMakesSameFrameInboundProjectionResident()
+ {
+ const uint landblock = 0x0A14FFFFu;
+ const uint cell = 0x0A140001u;
+ const uint guid = 0x70000041u;
+ var state = new GpuWorldState();
+ var outbox = new Queue();
+ outbox.Enqueue(new LandblockStreamResult.Loaded(
+ landblock,
+ LandblockStreamTier.Near,
+ EmptyLandblock(landblock),
+ new LandblockMeshData(Array.Empty(), Array.Empty())));
+ var streaming = new StreamingController(
+ enqueueLoad: static (_, _) => { },
+ enqueueUnload: static _ => { },
+ drainCompletions: max => Drain(outbox, max),
+ applyTerrain: static (_, _) => { },
+ state,
+ nearRadius: 0,
+ farRadius: 0);
+ var fixture = new Fixture(streamingBackend: streaming);
+
+ fixture.Controller.Tick();
+ Assert.True(state.IsLoaded(landblock));
+
+ var runtime = Runtime(state);
+ runtime.RegisterLiveEntity(Spawn(guid, cell));
+ WorldEntity entity = runtime.MaterializeLiveEntity(
+ guid,
+ cell,
+ id => Entity(id, guid))!;
+
+ Assert.Same(entity, Assert.Single(state.Entities));
+ Assert.Equal(0, state.PendingLiveEntityCount);
+ }
+
+ [Fact]
+ public void SessionResetClosesChaseLifetimeOriginAndPlayerHintBeforeNextTick()
+ {
+ var fixture = new Fixture(liveMode: true, initializeOrigin: true);
+ fixture.Mode.IsPlayerMode = true;
+ fixture.Mode.ChaseModeEverEntered = true;
+ fixture.Session.IsInWorld = true;
+ fixture.PlayerLandblock.LastKnownLandblockId = 0xAABB0001u;
+
+ fixture.Mode.ResetSession();
+ fixture.Origin.Reset();
+ fixture.Session.IsInWorld = false;
+ fixture.PlayerLandblock.LastKnownLandblockId = null;
+ fixture.Controller.Tick();
+
+ Assert.Equal(1, fixture.Convergence.Advances);
+ Assert.Empty(fixture.Streaming.Calls);
+ Assert.Empty(fixture.Rescues.Calls);
+ Assert.False(fixture.Mode.IsPlayerMode);
+ Assert.False(fixture.Mode.ChaseModeEverEntered);
+ Assert.False(fixture.Origin.IsKnown);
+ }
+
+ private sealed class Fixture
+ {
+ internal Fixture(
+ bool liveMode = false,
+ bool initializeOrigin = false,
+ IStreamingFrameBackend? streamingBackend = null)
+ {
+ Origin.SetPlaceholder(10, 20);
+ if (initializeOrigin)
+ Assert.True(Origin.TryInitialize(10, 20));
+ Controller = new StreamingFrameController(
+ liveMode,
+ Mode,
+ Player,
+ Session,
+ Origin,
+ PlayerLandblock,
+ Offline,
+ Dungeon,
+ Convergence,
+ streamingBackend ?? Streaming,
+ Rescues);
+ }
+
+ internal LocalPlayerModeState Mode { get; } = new();
+ internal LocalPlayerControllerSlot Player { get; } = new();
+ internal SessionSource Session { get; } = new();
+ internal LiveWorldOriginState Origin { get; } = new();
+ internal PlayerLandblockSource PlayerLandblock { get; } = new();
+ internal OfflineObserverSource Offline { get; } = new();
+ internal DungeonCellSource Dungeon { get; } = new();
+ internal OriginConvergence Convergence { get; } = new();
+ internal StreamingBackend Streaming { get; } = new();
+ internal RescueRebucketter Rescues { get; } = new();
+ internal StreamingFrameController Controller { get; }
+ }
+
+ private sealed class SessionSource : ILiveInWorldSource
+ {
+ public bool IsInWorld { get; set; }
+ }
+
+ private sealed class PlayerLandblockSource : ILocalPlayerLandblockSource
+ {
+ public uint? LastKnownLandblockId { get; set; }
+ }
+
+ private sealed class OfflineObserverSource : IOfflineStreamingObserverSource
+ {
+ private Vector3 _position;
+ internal int Reads { get; private set; }
+ public Vector3 Position
+ {
+ get
+ {
+ Reads++;
+ return _position;
+ }
+ set => _position = value;
+ }
+ }
+
+ private sealed class DungeonCellSource : IStreamingDungeonCellSource
+ {
+ internal int Captures { get; private set; }
+ internal StreamingDungeonCellSnapshot Snapshot { get; set; }
+ public StreamingDungeonCellSnapshot Capture()
+ {
+ Captures++;
+ return Snapshot;
+ }
+ }
+
+ private sealed class OriginConvergence : IStreamingOriginConvergence
+ {
+ internal int Advances { get; private set; }
+ internal Action? OnAdvance { get; set; }
+ internal bool AdvanceResult { get; set; } = true;
+ public bool Advance()
+ {
+ Advances++;
+ OnAdvance?.Invoke();
+ return AdvanceResult;
+ }
+ }
+
+ private sealed class StreamingBackend : IStreamingFrameBackend
+ {
+ internal List<(int X, int Y, bool Dungeon)> Calls { get; } = [];
+ internal Action? OnTick { get; set; }
+ public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
+ {
+ Calls.Add((observerCx, observerCy, insideDungeon));
+ OnTick?.Invoke(observerCx, observerCy, insideDungeon);
+ }
+ }
+
+ private sealed class RescueRebucketter : ILiveProjectionRescueRebucketter
+ {
+ internal List<(int X, int Y)> Calls { get; } = [];
+ internal Action? OnRebucket { get; set; }
+ public void RebucketAll(int observerCx, int observerCy)
+ {
+ Calls.Add((observerCx, observerCy));
+ OnRebucket?.Invoke(observerCx, observerCy);
+ }
+ }
+
+ private static LiveEntityRuntime Runtime(GpuWorldState state) =>
+ new(state, new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
+
+ private static LoadedLandblock EmptyLandblock(uint landblock) =>
+ new(landblock, new LandBlock(), Array.Empty());
+
+ private static WorldEntity Entity(uint id, uint guid) => new()
+ {
+ Id = id,
+ ServerGuid = guid,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = Array.Empty(),
+ };
+
+ private static WorldSession.EntitySpawn Spawn(uint guid, uint cell)
+ {
+ var position = new CreateObject.ServerPosition(
+ cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
+ return new WorldSession.EntitySpawn(
+ guid,
+ position,
+ 0x02000001u,
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ BasePaletteId: null,
+ ObjScale: null,
+ Name: "streaming frame fixture",
+ ItemType: null,
+ MotionState: null,
+ MotionTableId: null,
+ PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
+ InstanceSequence: 1,
+ MovementSequence: 1,
+ ServerControlSequence: 1,
+ PositionSequence: 1);
+ }
+
+ private static IReadOnlyList Drain(
+ Queue queue,
+ int max)
+ {
+ var drained = new List(max);
+ while (drained.Count < max && queue.TryDequeue(out LandblockStreamResult? result))
+ drained.Add(result);
+ return drained;
+ }
+}
diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
index 52460ba3..498d6694 100644
--- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
+++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
@@ -80,6 +80,7 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData(typeof(AcDream.App.Update.LiveObjectFrameController))]
[InlineData(typeof(AcDream.App.Update.LiveEffectFrameController))]
[InlineData(typeof(AcDream.App.Update.LiveSpatialPresentationReconciler))]
+ [InlineData(typeof(AcDream.App.Streaming.StreamingFrameController))]
[InlineData(typeof(LiveEntityAnimationPresenter))]
[InlineData(typeof(LiveAnimationPresentationContext))]
[InlineData(typeof(StaticLiveRootCommitter))]
@@ -106,6 +107,7 @@ public sealed class GameWindowLiveEntityCompositionTests
typeof(AcDream.App.Update.LiveObjectFrameController),
typeof(AcDream.App.Update.LiveEffectFrameController),
typeof(AcDream.App.Update.LiveSpatialPresentationReconciler),
+ typeof(AcDream.App.Streaming.StreamingFrameController),
typeof(LiveEntityAnimationScheduler),
typeof(LiveEntityAnimationPresenter),
typeof(LiveAnimationPresentationContext),
@@ -167,6 +169,20 @@ public sealed class GameWindowLiveEntityCompositionTests
Assert.Contains("new AcDream.App.Physics.DatProjectileSetupResolver", source);
}
+ [Fact]
+ public void SessionReset_ClosesEveryStreamingReadinessOwner()
+ {
+ string source = File.ReadAllText(Path.Combine(
+ FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs"));
+
+ Assert.Contains("_localPlayerMode.ResetSession();", source, StringComparison.Ordinal);
+ Assert.Contains(
+ "_liveEntityNetworkUpdates?.ResetSessionState();",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains("_liveWorldOrigin.Reset();", source, StringComparison.Ordinal);
+ }
+
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
index 64e35436..770f334d 100644
--- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
+++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
@@ -349,6 +349,30 @@ public sealed class UpdateFrameOrchestratorTests
StringComparison.Ordinal);
}
+ [Fact]
+ public void GameWindow_DelegatesTheCompleteStreamingFrameBody()
+ {
+ string source = File.ReadAllText(Path.Combine(
+ FindRepoRoot(),
+ "src",
+ "AcDream.App",
+ "Rendering",
+ "GameWindow.cs"));
+
+ Assert.Contains("new AcDream.App.Streaming.StreamingFrameController(", source);
+ Assert.Equal(1, CountOccurrences(source, "_streamingFrame.Tick();"));
+ AssertAppearsInOrder(
+ source,
+ "_streamingFrame.Tick();",
+ "_inputDispatcher?.Tick();",
+ "_liveFrameCoordinator.Tick(frameDelta);");
+ Assert.DoesNotContain("_streamingController.Tick(observerCx", source,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("DungeonStreamingGate.Compute", source,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("DrainRescued()", source, StringComparison.Ordinal);
+ }
+
private static UpdateFrameOrchestrator Create(
List calls,
RecordingTeardown? teardown = null,