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,