diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 07d89b24..db4b89b7 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -1,9 +1,9 @@ # acdream — code structure & extraction sequence **Status:** Living document. Created 2026-05-16; implementation reconciliation -completed 2026-07-21; Slices 1–6 landed by 2026-07-22. Slice 7 draw-frame -orchestration is the active structural program before new M4 subsystems enter -the App layer. +completed 2026-07-21; Slices 1–6 and the Slice 7 draw-frame cutover landed by +2026-07-22. Slice 7 connected closeout is active before the final Slice 8 +composition/shutdown cleanup and new M4 subsystems enter the App layer. **Purpose:** Describe the desired structural state of the App layer, explain the rules we've adopted, and lay out the safe extraction sequence from today's reality (one 15,723-line `GameWindow.cs` at the @@ -30,6 +30,7 @@ after Slice 3 14,310 lines / 274 fields / 190 method after Slice 4 10,301 lines / 267 fields / 163 methods after Slice 5 closeout 8,811 lines / 247 fields / 153 methods after Slice 6 closeout 7,026 lines / 241 fields / 108 methods +after Slice 7 draw-frame cutover 4,666 lines / 196 fields / 70 methods ``` `GameWindow` is the single object that: @@ -168,6 +169,45 @@ App currently wires it. --- +### Render-frame ownership versus recursive reachability + +The typed `RenderFrameOrchestrator` graph owns frame sequencing and borrows +long-lived runtime/presentation owners. The enforceable ownership invariant is +that the orchestrator and its immediate phase owners retain neither +`GameWindow`, an anonymous callback bag, nor direct window delegates. It is +**not** an invariant that recursively walking every borrowed canonical owner +can never reach an event subscriber or callback that was composed by +`GameWindow`; that claim would be false and would confuse object-graph +reachability with frame ownership. + +Known recursive callback-bearing paths include (this is evidence and design +documentation, not an exhaustive allowlist): + +- `RetainedGameplayUiFrame -> RetailUiRuntime -> RetailUiRuntimeBindings` for + retained gameplay UI state and commands. +- `PrivatePresentationRenderer -> PaperdollFramePresenter -> + RetailPaperdollFrameView/PaperdollInventoryVisibility -> UiElement tree` for + private paperdoll visibility and texture publication. This is a second, + independent retained-UI entry because `UiElement.Parent` links back to the + shared root and sibling controls. +- `DevToolsFramePresenter -> DevToolsPanelSet -> panel/ViewModel bindings` for + the optional developer UI. +- `WorldRenderFrameBuilder -> RuntimeWorldFrameSettingsPreview -> SettingsVM` + for the live settings draft preview applied before world drawing. +- `LocalPlayerPortalViewport -> LocalPlayerTeleportController -> + GameplayInputFrameController -> InputDispatcher.Fired -> GameWindow` for the + canonical portal/input lifetime and the host's input-action subscription. + +These are presentation state/command seams, not alternative owners of the +window or frame transaction. The screenshot path is deliberately not on this +list: viewport width/height now travel in `RenderFrameInput`, so +`FrameScreenshotController` no longer stores a window-size callback. New +immediate phase owners must still satisfy the direct ownership invariant; +recursive shared-owner paths are reviewed against their canonical subsystem +lifetime rather than an impossible global no-callback assertion. + +--- + ## 3. Target structure of the App layer The end state — not what we're shipping in one pass, but the shape @@ -608,7 +648,7 @@ registered; this behavior-preserving extraction introduced no new divergence. Detailed execution ledger: [`docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md`](../plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md) -(active). +(connected closeout active). Move the complete draw graph and its reusable frame-local scratch state into a GL-owning App collaborator. Preserve the exact modern pipeline order, clip @@ -622,6 +662,14 @@ Automated acceptance uses framebuffer artifacts and render/resource checkpoints. Visual acceptance compares outdoor, building, dungeon, portal exit, translucent lifestone/particles, and UI at the same camera positions. +The production draw cutover is complete. `GameWindow.OnRender` now performs one +value-only handoff to `RenderFrameOrchestrator`; focused resource, world, +private-presentation, and diagnostic owners contain the former frame body and +failure recovery. The Release suite passes 7,341 tests with five intentional +skips. `GameWindow` is 4,666 raw lines / 196 fields / 70 methods: 11,057 lines +(70.3%) smaller than the campaign baseline. The connected lifecycle and +resource-soak closeout remains before Slice 7 is final. + #### Slice 8 — composition and shutdown cleanup Keep GL/window construction in `GameWindow.OnLoad`, but group creation into diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index bf61148e..3949a045 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -260,7 +260,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; shared frame drain in `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveEffectFrameController`) | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order | | TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each ordinary object's ParticleManager then ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` instead advances that static owner's ScriptManager then ParticleManager and only then `process_hooks`, using its whole admitted elapsed interval. acdream's shared tail is Particle → Script after static hook capture. | `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveObjectFrameController` + `LiveEffectFrameController` shared `_particles.Tick` / `_scripts.Tick` tail); `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs` | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and ordinary manager order faithful without pretending the shared tails have per-owner timing or static-tail order. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static hooks can route before their script/particle managers and static default scripts/particles use render elapsed in Particle → Script order rather than `animate_static_object` elapsed/discard and Script → Particle → hooks timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum/order | | TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 | -| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slice 6 preserves the accepted host order as an ownership-only extraction. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/GameWindow.cs` (`_retailUiRuntime.Tick` in `OnRender`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison | +| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 6–7 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison | --- diff --git a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md index d10a9126..a327e38d 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md +++ b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md @@ -28,7 +28,7 @@ port, or resource-lifetime redesign. `RetailPViewPassExecutor` and data-only inputs. - [x] F — extract `WorldSceneRenderer`, including both shared-alpha scopes, PView/fallback world branches, particles, debug world draw, and completion. -- [ ] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one +- [x] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one handoff, and delete obsolete frame bodies, fields, helpers, and callbacks. - [ ] H — corrected-diff reviews, full Release suite, connected lifecycle and soak gates, framebuffer comparison, documentation, memory, and metrics. @@ -231,12 +231,15 @@ contract requires separate approval and evidence. ### 4.2 Borrowed resources and shutdown -Slice 7 collaborators borrow the already-created GL/render owners. They do not -dispose them. The existing retryable shutdown transaction keeps its proven -dependency order: wait for submitted work; withdraw UI/render publications; -release textures and mesh owners; then dispose GL backing stores, with -`GpuFrameFlightController` last among render resources. Slice 8 may group -construction/disposal after the render cutover is stable. +The frame orchestrator and phase owners borrow the already-created GL/render +owners and do not dispose them. The developer-tools backend is itself the +canonical owner of its ImGui bootstrap and disposes that bootstrap when the +existing retryable shutdown transaction retires the backend; the frame +presenter only borrows it. Shutdown keeps its proven dependency order: wait for +submitted work; withdraw UI/render publications; release textures and mesh +owners; then dispose GL backing stores, with `GpuFrameFlightController` last +among render resources. Slice 8 may group construction/disposal after the +render cutover is stable. `DisplayFramePacingController` is the focused shared owner for requested VSync, live display preview, monitor/window callbacks, and the separate @@ -354,7 +357,8 @@ Completed 2026-07-22. Paperdoll clone/re-dress/pose/FBO work now belongs to `PaperdollFramePresenter`; retained surfaces are narrow and optional, while live identity and DAT pose application resolve through focused adapters. `DevToolsFramePresenter` owns the ImGui frame, menus, panels, layout, and input -actions without taking shutdown ownership of the borrowed bootstrap. +actions while borrowing the bootstrap; `ImGuiDevToolsFrameBackend` explicitly +owns and disposes that bootstrap at shutdown. `WorldRenderDiagnostics`, `DebugVmRenderFactsPublisher`, and `RenderFrameDiagnosticsController` own the former render probes, debug facts, title/resource cadence, and public snapshot. The production handoff now reports @@ -471,12 +475,29 @@ corrected-diff reviews are clean. - compose concrete resource, world, private-presentation, and diagnostic owners; - replace `OnRender` with one `RenderFrameOrchestrator.Render` handoff; -- remove obsolete fields/helpers/callbacks and prove the transitive render graph - has no substantial path back to `GameWindow`; -- preserve the existing shutdown resource list without transferring disposal; +- remove obsolete fields/helpers/callbacks; prove immediate frame owners retain + no direct `GameWindow`/anonymous-delegate back-reference, and document rather + than deny recursive paths through borrowed canonical UI/input owners; +- preserve the existing shutdown dependency order while making the + developer-tools backend's pre-existing bootstrap ownership explicit; - measure line/field/method counts. Ownership decides completion; the expected signal is roughly 5,000–5,500 lines before Slice 8 composition cleanup. +Completed 2026-07-22. `GameWindow.OnRender` now creates one value-only +`RenderFrameInput` and hands the complete frame to `RenderFrameOrchestrator`. +Focused preparation, world-scene, private-presentation, and diagnostic owners +preserve the accepted resource → ImGui begin → weather → world → portal → +paperdoll → retained UI → ImGui submit → screenshot → diagnostics → GPU close +order. Recovery now closes an active ImGui frame through the owning Silk +controller and restores the exact text-render GL state after success or +failure. Screenshot dimensions travel in the frame input rather than through a +window callback, and immediate frame owners have no direct `GameWindow` or +anonymous-delegate back-reference. `GameWindow.cs` is 4,666 raw lines / 196 +fields / 70 methods: 11,057 lines (70.3%) below the 15,723-line campaign +baseline. The Release build and complete suite pass (7,341 passed / 5 skipped), +and retail-conformance, architecture, and adversarial corrected-diff reviews +are clean. + ### H — release and connected gates After three clean corrected-diff reviews: diff --git a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs index 8a050504..0170a5e3 100644 --- a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs +++ b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs @@ -20,7 +20,6 @@ internal sealed class FrameScreenshotController private sealed record CaptureStatus(CaptureState State, string? Error = null); - private readonly Func<(int Width, int Height)> _getSize; private readonly Func _readRgba; private readonly string _directory; private readonly Action _log; @@ -30,21 +29,18 @@ internal sealed class FrameScreenshotController public FrameScreenshotController( GL gl, - Func<(int Width, int Height)> getSize, string directory, Action? log = null) - : this(getSize, (width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log) + : this((width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log) { ArgumentNullException.ThrowIfNull(gl); } internal FrameScreenshotController( - Func<(int Width, int Height)> getSize, Func readRgba, string directory, Action? log = null) { - _getSize = getSize ?? throw new ArgumentNullException(nameof(getSize)); _readRgba = readRgba ?? throw new ArgumentNullException(nameof(readRgba)); _directory = string.IsNullOrWhiteSpace(directory) ? throw new ArgumentException("A screenshot directory is required.", nameof(directory)) @@ -76,7 +72,7 @@ internal sealed class FrameScreenshotController && status.State == CaptureState.Complete; /// Captures at most one queued image on the current GL thread. - public bool CapturePending() + public bool CapturePending(int width, int height) { if (_pending.Count == 0) return false; @@ -84,7 +80,6 @@ internal sealed class FrameScreenshotController string name = _pending.Dequeue(); try { - (int width, int height) = _getSize(); if (width <= 0 || height <= 0) throw new InvalidOperationException($"invalid framebuffer size {width}x{height}"); diff --git a/src/AcDream.App/Rendering/DevToolsFramePresenter.cs b/src/AcDream.App/Rendering/DevToolsFramePresenter.cs index d1bb4143..c4586550 100644 --- a/src/AcDream.App/Rendering/DevToolsFramePresenter.cs +++ b/src/AcDream.App/Rendering/DevToolsFramePresenter.cs @@ -25,10 +25,19 @@ internal enum DevToolsPanelKind Settings, } +internal interface IDevToolsFrameLifecycle : IRenderFrameFailureRecovery +{ + void BeginFrame(float deltaSeconds); + + void Render(double deltaSeconds, int viewportWidth, int viewportHeight); +} + internal interface IDevToolsFrameBackend { void BeginFrame(float deltaSeconds); + void AbortFrame(); + bool BeginMainMenuBar(); void EndMainMenuBar(); @@ -82,10 +91,11 @@ internal interface IDevToolsPanelSet } /// Concrete ImGui backend for the developer presentation owner. -internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend +internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend, IDisposable { private readonly ImGuiBootstrapper _bootstrap; private readonly ImGuiPanelHost _panels; + private bool _disposed; public ImGuiDevToolsFrameBackend( ImGuiBootstrapper bootstrap, @@ -97,6 +107,8 @@ internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend public void BeginFrame(float deltaSeconds) => _bootstrap.BeginFrame(deltaSeconds); + public void AbortFrame() => _bootstrap.AbortFrame(); + public bool BeginMainMenuBar() => ImGuiNET.ImGui.BeginMainMenuBar(); public void EndMainMenuBar() => ImGuiNET.ImGui.EndMainMenuBar(); @@ -130,6 +142,15 @@ internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend ImGuiNET.ImGui.SetWindowSize(title, size, imguiCondition); } + public void Dispose() + { + if (_disposed) + return; + + _bootstrap.Dispose(); + _disposed = true; + } + } /// Mutable late-composition seam for player-mode menu operations. @@ -229,13 +250,14 @@ internal sealed class DevToolsPanelSet : IDevToolsPanelSet /// reusable default layout. Retained gameplay UI remains a separate earlier /// presentation phase. /// -internal sealed class DevToolsFramePresenter +internal sealed class DevToolsFramePresenter : IDevToolsFrameLifecycle { private readonly IDevToolsFrameBackend _backend; private readonly IDevToolsCameraMenuOperations _camera; private readonly IDevToolsCommandBusSource _commands; private readonly IDevToolsPanelSet _panels; private readonly AcDream.App.Diagnostics.FrameProfiler _profiler; + private bool _frameOpen; public DevToolsFramePresenter( IDevToolsFrameBackend backend, @@ -251,15 +273,47 @@ internal sealed class DevToolsFramePresenter _panels = panels ?? throw new ArgumentNullException(nameof(panels)); } - public void BeginFrame(float deltaSeconds) => _backend.BeginFrame(deltaSeconds); + public void BeginFrame(float deltaSeconds) + { + if (_frameOpen) + { + throw new InvalidOperationException( + "The previous developer-tools frame must render or abort before a new frame begins."); + } + + _frameOpen = true; + _backend.BeginFrame(deltaSeconds); + } public void Render(double deltaSeconds, int viewportWidth, int viewportHeight) { + if (!_frameOpen) + { + throw new InvalidOperationException( + "BeginFrame must open the developer-tools frame before Render."); + } + var context = new PanelContext((float)deltaSeconds, _commands.Current); DrawMenu(viewportWidth, viewportHeight); _backend.RenderPanels(context); using var stage = _profiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui); _backend.RenderDrawData(); + _frameOpen = false; + } + + public void AbortFrame() + { + if (!_frameOpen) + return; + + try + { + _backend.AbortFrame(); + } + finally + { + _frameOpen = false; + } } public void ToggleDebugPanel() diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 57f1e7a9..8161ebe6 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -93,23 +93,14 @@ public sealed class GameWindow : IDisposable new AcDream.App.Rendering.ConsoleRenderFrameDiagnosticLog(); private readonly AcDream.App.Rendering.DebugVmRenderFactsPublisher _debugVmRenderFacts = new(); - private AcDream.App.Rendering.WorldRenderDiagnostics? _worldRenderDiagnostics; private AcDream.App.Rendering.RenderFrameDiagnosticsController? _renderFrameDiagnostics; private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots; private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; - private AcDream.App.Rendering.RenderFrameResourceController? - _renderFrameResources; - private AcDream.App.Rendering.RuntimeRenderFrameLivePreparation? - _renderFrameLivePreparation; - private AcDream.App.Rendering.RenderWeatherFrameController? - _renderWeatherFrame; - private AcDream.App.Rendering.WorldRenderFrameBuilder? - _worldRenderFrameBuilder; + private AcDream.App.Rendering.RenderFrameOrchestrator? + _renderFrameOrchestrator; private AcDream.App.Rendering.WorldSceneSkyState? _worldSceneSkyState; - private AcDream.App.Rendering.WorldSceneRenderer? _worldSceneRenderer; - private AcDream.App.Rendering.SkyPesFrameController? _skyPesFrame; private ResourceShutdownTransaction? _shutdown; private readonly DisplayFramePacingController _displayFramePacing; @@ -221,13 +212,8 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer; private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum; - // R1 (render redesign): the per-cell DrawInside flood and concrete pass - // executor are borrowed by WorldSceneRenderer for each normal-world frame. - private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; - private AcDream.App.Rendering.RetailPViewPassExecutor? _retailPViewPassExecutor; - private AcDream.App.Rendering.RetailPViewCellSource? _retailPViewCells; - private AcDream.App.Rendering.TerrainDrawDiagnosticsController? - _terrainDrawDiagnostics; + // R1 (render redesign): portal-view draw owners are retained transitively + // by the frame orchestrator rather than duplicated as GameWindow roots. private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel; @@ -746,7 +732,7 @@ public sealed class GameWindow : IDisposable _gl = GL.GetApi(_window!); _gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl); - _worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics( + var worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics( new AcDream.App.Rendering.SilkRenderGlStateReader(_gl), _renderDiagnosticLog); _input = _window!.CreateInput(); @@ -1682,7 +1668,6 @@ public sealed class GameWindow : IDisposable { _frameScreenshots = new AcDream.App.Diagnostics.FrameScreenshotController( _gl!, - () => (_window!.Size.X, _window.Size.Y), System.IO.Path.Combine(artifactDirectory, "screenshots"), UiProbeLog); _worldLifecycleAutomation = @@ -2285,8 +2270,6 @@ public sealed class GameWindow : IDisposable landblockRenderPublisher.PrepareAfterRenderPins); _clipFrame ??= ClipFrame.NoClip(); - _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(); - // T1: invisible portal depth writes (seal/punch) — retail // DrawPortalPolyInternal (Ghidra 0x0059bc90). _portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl); @@ -2782,7 +2765,7 @@ public sealed class GameWindow : IDisposable _localPlayerMode); var renderFrameGlState = new AcDream.App.Rendering.RenderFrameGlStateController( new AcDream.App.Rendering.SilkRenderFrameGlStateApi(_gl!)); - _renderFrameLivePreparation = + var renderFrameLivePreparation = new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation( _textureCache, _wbMeshAdapter, @@ -2795,7 +2778,7 @@ public sealed class GameWindow : IDisposable _particleRenderer, _frameProfiler, _frameDiag); - _renderFrameResources = + var renderFrameResources = new AcDream.App.Rendering.RenderFrameResourceController( _gpuFrameFlights!, new AcDream.App.Rendering.RuntimeRenderFrameBeginResources( @@ -2816,14 +2799,14 @@ public sealed class GameWindow : IDisposable Weather, teleportRenderState, _particleVisibility, - _worldRenderDiagnostics!, + worldRenderDiagnostics, renderFrameGlState), - _renderFrameLivePreparation); - _renderWeatherFrame = + renderFrameLivePreparation); + var renderWeatherFrame = new AcDream.App.Rendering.RenderWeatherFrameController( WorldTime, Weather); - _skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController( + var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController( _scriptRunner!, _particleSink!, _effectPoses, @@ -2837,8 +2820,8 @@ public sealed class GameWindow : IDisposable _envCellRenderer, _sceneLightingUbo, _renderRange, - _skyPesFrame); - _worldRenderFrameBuilder = + skyPesFrame); + var worldRenderFrameBuilder = new AcDream.App.Rendering.WorldRenderFrameBuilder( new AcDream.App.Rendering.RuntimeWorldFrameCameraSource( _cameraController!, @@ -2869,22 +2852,22 @@ public sealed class GameWindow : IDisposable new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource( _landblockPresentationPipeline!, _cellVisibility)); - _terrainDrawDiagnostics = + var terrainDrawDiagnostics = new AcDream.App.Rendering.TerrainDrawDiagnosticsController( _frameDiag, - _worldRenderDiagnostics!, + worldRenderDiagnostics, new AcDream.App.Rendering.RuntimeFramePipelineDiagnosticFactsSource( _terrain, _landblockPresentationPipeline, - _renderFrameLivePreparation, + renderFrameLivePreparation, _wbDrawDispatcher, _streamingController, _liveEntities!, _worldState), _renderDiagnosticLog); - _retailPViewCells = new AcDream.App.Rendering.RetailPViewCellSource( + var retailPViewCells = new AcDream.App.Rendering.RetailPViewCellSource( _cellVisibility); - _retailPViewPassExecutor = + var retailPViewPassExecutor = new AcDream.App.Rendering.RetailPViewPassExecutor( _gl!, renderFrameGlState, @@ -2899,11 +2882,11 @@ public sealed class GameWindow : IDisposable _particleRenderer, _portalDepthMask, _retailAlphaQueue, - _worldRenderDiagnostics!, - _terrainDrawDiagnostics); + worldRenderDiagnostics, + terrainDrawDiagnostics); var worldSceneDiagnostics = new AcDream.App.Rendering.WorldSceneDiagnosticsController( - _worldRenderDiagnostics!, + worldRenderDiagnostics, new AcDream.App.Rendering.RuntimeWorldScenePViewDiagnosticSource( _playerControllerSlot, _physicsEngine, @@ -2922,27 +2905,63 @@ public sealed class GameWindow : IDisposable _wbDrawDispatcher!, _envCellRenderer!, _terrain, - _terrainDrawDiagnostics, + terrainDrawDiagnostics, _skyRenderer, _particleSystem, _particleRenderer); - _worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer( - _renderFrameResources, + var worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer( + renderFrameResources, renderLoginState, _worldSceneSkyState!, - _worldRenderFrameBuilder, + worldRenderFrameBuilder, new AcDream.App.Rendering.RuntimeWorldSceneEntitySource(_worldState), _retailSelectionScene, _retailAlphaQueue, _particleVisibility, new AcDream.App.Rendering.WorldScenePViewRenderer( - _retailPViewRenderer!, - _retailPViewPassExecutor, - _retailPViewPassExecutor), - _retailPViewCells, + new AcDream.App.Rendering.RetailPViewRenderer(), + retailPViewPassExecutor, + retailPViewPassExecutor), + retailPViewCells, worldScenePasses, _renderRange, worldSceneDiagnostics); + AcDream.App.Rendering.IRetainedGameplayUiFrame? retainedGameplayUi = + _options.RetailUi && _retailUiRuntime is not null + ? new AcDream.App.Rendering.RetainedGameplayUiFrame( + _retailUiRuntime, + _input) + : null; + AcDream.App.Rendering.IPrivateFrameScreenshot? privateScreenshot = + _frameScreenshots is not null + ? new AcDream.App.Rendering.PrivateFrameScreenshot( + _frameScreenshots) + : null; + var privatePresentation = + new AcDream.App.Rendering.PrivatePresentationRenderer( + new AcDream.App.Rendering.LocalPlayerPortalViewport( + _localPlayerTeleport!, + _cameraController!), + renderFrameResources, + _paperdollFramePresenter, + retainedGameplayUi, + _devToolsFramePresenter, + privateScreenshot); + var framePreparation = + new AcDream.App.Rendering.RenderFramePreparationController( + renderFrameResources, + _devToolsFramePresenter, + renderWeatherFrame); + _renderFrameOrchestrator = + new AcDream.App.Rendering.RenderFrameOrchestrator( + _gpuFrameFlights!, + framePreparation, + worldSceneRenderer, + privatePresentation, + _renderFrameDiagnostics!, + (AcDream.App.Rendering.IRenderFrameFailureRecovery?) + _devToolsFramePresenter + ?? AcDream.App.Rendering.NullRenderFrameFailureRecovery.Instance); var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( liveObjectFrame, _worldState, @@ -3415,126 +3434,12 @@ public sealed class GameWindow : IDisposable // window title so there's zero rendering cost (no font/overlay needed). private void OnRender(double deltaSeconds) { - _gpuFrameFlights!.BeginFrame(); - Exception? renderFailure = null; - try - { - var frameInput = new AcDream.App.Rendering.RenderFrameInput( + Vector2D size = _window!.Size; + _renderFrameOrchestrator!.Render( + new AcDream.App.Rendering.RenderFrameInput( deltaSeconds, - _window!.Size.X, - _window.Size.Y); - _renderFrameResources!.Prepare(frameInput); - AcDream.App.Rendering.RenderFrameFoundation foundation = - _renderFrameResources.Foundation; - - // gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the - // normal SmartBox viewport and UIElement_Viewport's portal - // CreatureMode; it does not redraw the world behind portal space. - bool portalViewportVisible = foundation.PortalViewportVisible; - - // Phase D.2a — begin ImGui frame. Paired with the Render() call - // after the scene draws (below). ImGuiController.Update() - // consumes buffered Silk.NET input events and calls ImGui.NewFrame. - _devToolsFramePresenter?.BeginFrame((float)deltaSeconds); - - // Phase G.1: weather state machine — deterministic per-day roll - // + transitions + lightning flash. - _renderWeatherFrame!.Tick(deltaSeconds); - - // (Pre-Bug-A code spawned camera-attached rain/snow particle - // emitters here as a workaround for missing weather-mesh - // rendering. Deleted 2026-04-26 once the retail-faithful world- - // space mesh path landed in SkyRenderer.RenderWeather. Retail - // rain is GfxObj 0x01004C42/0x01004C44 — a hollow octagonal - // cylinder anchored at player_pos + (0, 0, -120m) per - // GameSky::UpdatePosition at 0x00506dd0 — drawn after the - // landblock pass per LScape::draw at 0x00506330. There is no - // server-driven weather event and no camera-attached emitter - // in retail. Snow renders identically when a Snowy DayGroup is - // active in some other Region; the partition by Properties&0x04 - // and the SkyRenderer.RenderWeather pass both pick up snow - // weather meshes for free.) - - AcDream.App.Rendering.WorldRenderFrameOutcome worldOutcome = - _worldSceneRenderer!.Render(frameInput); - int visibleLandblocks = worldOutcome.VisibleLandblocks; - int totalLandblocks = worldOutcome.TotalLandblocks; - bool normalWorldDrawn = worldOutcome.NormalWorldDrawn; - - // Retail gmSmartBoxUI swaps the world SmartBox viewport for a - // CreatureMode portal-space viewport. The world block above is skipped - // while this replacement scene is visible. This scene and - // its projection transition draws BEFORE retained UI, so chat, windows, - // cursor, and toolbar remain visible and interactive in transit. - _localPlayerTeleport?.DrawPortalViewport( - _window!.Size.X, - _window.Size.Y, - _cameraController!.Active.Projection); - - // Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the - // viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window - // is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle). - // The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state. - _paperdollFramePresenter?.Render(); - - // Phase D.2b — retail-look UI tree (render-only; input integration deferred). - // Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own - // blend/depth state and restores. Drawn before ImGui so the devtools - // overlay composites on top during development. - if (_options.RetailUi && _retailUiRuntime is not null) - { - _retailUiRuntime.Tick(deltaSeconds); - if (_input is not null) - _retailUiRuntime.UpdateCursor(_input.Mice); - _retailUiRuntime.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); - } - - // Devtools remain above retained gameplay UI. The focused presenter - // owns menu policy, panels, and ImGui draw-data upload. - _devToolsFramePresenter?.Render( - deltaSeconds, - _window!.Size.X, - _window.Size.Y); - - // Explicit diagnostic captures are requested by the retained-UI - // automation tick above and executed only after every presentation - // layer has drawn into the back buffer on this render thread. - bool screenshotCaptured = _frameScreenshots?.CapturePending() == true; - - _renderFrameDiagnostics?.Publish( - new AcDream.App.Rendering.RenderFrameInput( - deltaSeconds, - _window!.Size.X, - _window.Size.Y), - new AcDream.App.Rendering.RenderFrameOutcome( - new AcDream.App.Rendering.WorldRenderFrameOutcome( - visibleLandblocks, - totalLandblocks, - normalWorldDrawn), - new AcDream.App.Rendering.PrivatePresentationFrameOutcome( - portalViewportVisible, - screenshotCaptured))); - - } - catch (Exception ex) - { - renderFailure = ex; - throw; - } - finally - { - try - { - _gpuFrameFlights.EndFrame(); - } - catch (Exception closeFailure) when (renderFailure is not null) - { - throw new AggregateException( - "Rendering failed and the in-flight GPU frame could not be closed.", - renderFailure, - closeFailure); - } - } + size.X, + size.Y)); } private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot @@ -4563,14 +4468,8 @@ public sealed class GameWindow : IDisposable [ new("world frame composition", () => { - _worldSceneRenderer = null; - _worldRenderFrameBuilder = null; + _renderFrameOrchestrator = null; _worldSceneSkyState = null; - _skyPesFrame = null; - _retailPViewPassExecutor = null; - _retailPViewCells = null; - _terrainDrawDiagnostics = null; - _retailPViewRenderer = null; }), ]), new ResourceShutdownStage("session dependents", @@ -4643,11 +4542,13 @@ public sealed class GameWindow : IDisposable ]), new ResourceShutdownStage("render frontends", [ - new("frame composition", () => + new("developer tools", () => { - _renderFrameResources = null; - _renderFrameLivePreparation = null; - _renderWeatherFrame = null; + _devToolsFramePresenter = null; + _devToolsCameraMenu = null; + _devToolsCommandBus = null; + _devToolsBackend?.Dispose(); + _devToolsBackend = null; }), new("portal tunnel", () => { diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs index 1f8dc509..a3149e6f 100644 --- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs +++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs @@ -48,7 +48,7 @@ internal interface IPaperdollPoseApplicator /// The GL renderer remains a borrowed resource disposed by the existing window /// shutdown transaction. /// -internal sealed class PaperdollFramePresenter +internal sealed class PaperdollFramePresenter : IPrivatePaperdollFrame { private readonly IPaperdollDollRenderer _renderer; private readonly IPaperdollFrameView _view; diff --git a/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs new file mode 100644 index 00000000..b82aa5c3 --- /dev/null +++ b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs @@ -0,0 +1,148 @@ +using System.Numerics; +using AcDream.App.Diagnostics; +using AcDream.App.Streaming; +using AcDream.App.UI; +using Silk.NET.Input; + +namespace AcDream.App.Rendering; + +internal interface IPrivatePortalViewport +{ + void Draw(int width, int height); +} + +internal interface IPrivatePaperdollFrame +{ + void Render(); +} + +internal interface IRetainedGameplayUiFrame +{ + void Render(double deltaSeconds, int width, int height); +} + +internal interface IPrivateFrameScreenshot +{ + bool CapturePending(int width, int height); +} + +/// +/// Draws the private and 2-D presentation layers after the world alpha scope +/// has closed: portal CreatureMode, paperdoll FBO, retained gameplay UI, +/// developer UI, then the complete-frame screenshot. +/// +internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePhase +{ + private readonly IPrivatePortalViewport _portal; + private readonly IRenderFrameFoundationSource _foundation; + private readonly IPrivatePaperdollFrame? _paperdoll; + private readonly IRetainedGameplayUiFrame? _gameplayUi; + private readonly IDevToolsFrameLifecycle? _devTools; + private readonly IPrivateFrameScreenshot? _screenshots; + + public PrivatePresentationRenderer( + IPrivatePortalViewport portal, + IRenderFrameFoundationSource foundation, + IPrivatePaperdollFrame? paperdoll, + IRetainedGameplayUiFrame? gameplayUi, + IDevToolsFrameLifecycle? devTools, + IPrivateFrameScreenshot? screenshots) + { + _portal = portal ?? throw new ArgumentNullException(nameof(portal)); + _foundation = foundation + ?? throw new ArgumentNullException(nameof(foundation)); + _paperdoll = paperdoll; + _gameplayUi = gameplayUi; + _devTools = devTools; + _screenshots = screenshots; + } + + public PrivatePresentationFrameOutcome Render( + RenderFrameInput input, + WorldRenderFrameOutcome world) + { + _ = world; + // Foundation is the immutable result of this frame's clear phase. + // Snapshot it before drawing so a portal transition raised by a + // private layer cannot relabel the viewport that was just presented. + bool portalViewportVisible = + _foundation.Foundation.PortalViewportVisible; + _portal.Draw(input.ViewportWidth, input.ViewportHeight); + _paperdoll?.Render(); + _gameplayUi?.Render( + input.DeltaSeconds, + input.ViewportWidth, + input.ViewportHeight); + _devTools?.Render( + input.DeltaSeconds, + input.ViewportWidth, + input.ViewportHeight); + bool screenshotCaptured = _screenshots?.CapturePending( + input.ViewportWidth, + input.ViewportHeight) == true; + return new PrivatePresentationFrameOutcome( + portalViewportVisible, + screenshotCaptured); + } +} + +/// Typed portal viewport adapter over the canonical teleport owner. +internal sealed class LocalPlayerPortalViewport : IPrivatePortalViewport +{ + private readonly LocalPlayerTeleportController _teleport; + private readonly CameraController _camera; + + public LocalPlayerPortalViewport( + LocalPlayerTeleportController teleport, + CameraController camera) + { + _teleport = teleport ?? throw new ArgumentNullException(nameof(teleport)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + } + + public void Draw(int width, int height) => + _teleport.DrawPortalViewport(width, height, _camera.Active.Projection); +} + +/// +/// One retained-gameplay-UI tick/cursor/draw transaction. This is the explicit +/// boundary where the render graph reaches the retained UI composition owner; +/// intentionally retains its documented state and +/// command bindings. The distinction between direct frame ownership and +/// recursive shared-owner reachability is recorded in +/// docs/architecture/code-structure.md. +/// +internal sealed class RetainedGameplayUiFrame : IRetainedGameplayUiFrame +{ + private readonly RetailUiRuntime _runtime; + private readonly IInputContext? _input; + + public RetainedGameplayUiFrame( + RetailUiRuntime runtime, + IInputContext? input) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _input = input; + } + + public void Render(double deltaSeconds, int width, int height) + { + _runtime.Tick(deltaSeconds); + if (_input is not null) + _runtime.UpdateCursor(_input.Mice); + _runtime.Draw(new Vector2(width, height)); + } +} + +/// Optional complete-frame capture adapter. +internal sealed class PrivateFrameScreenshot : IPrivateFrameScreenshot +{ + private readonly FrameScreenshotController _screenshots; + + public PrivateFrameScreenshot(FrameScreenshotController screenshots) => + _screenshots = screenshots + ?? throw new ArgumentNullException(nameof(screenshots)); + + public bool CapturePending(int width, int height) => + _screenshots.CapturePending(width, height); +} diff --git a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs index 6aa79fb9..9f688d99 100644 --- a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs +++ b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs @@ -60,6 +60,29 @@ internal interface IRenderFrameDiagnosticsPhase void Publish(RenderFrameInput input, RenderFrameOutcome outcome); } +/// +/// Closes presentation transactions which may have opened before a later +/// render phase failed. Recovery is idempotent so failures before the +/// transaction opens and failures after it closes are both safe. +/// +internal interface IRenderFrameFailureRecovery +{ + void AbortFrame(); +} + +internal sealed class NullRenderFrameFailureRecovery : IRenderFrameFailureRecovery +{ + public static NullRenderFrameFailureRecovery Instance { get; } = new(); + + private NullRenderFrameFailureRecovery() + { + } + + public void AbortFrame() + { + } +} + /// /// Owns the accepted outer render transaction. Focused phase owners retain the /// detailed PView, private-viewport, UI, and diagnostic order inside their @@ -79,19 +102,22 @@ internal sealed class RenderFrameOrchestrator private readonly IWorldSceneFramePhase _world; private readonly IPrivatePresentationFramePhase _presentation; private readonly IRenderFrameDiagnosticsPhase _diagnostics; + private readonly IRenderFrameFailureRecovery _recovery; public RenderFrameOrchestrator( IRenderFrameLifetime lifetime, IRenderFrameResourcePhase resources, IWorldSceneFramePhase world, IPrivatePresentationFramePhase presentation, - IRenderFrameDiagnosticsPhase diagnostics) + IRenderFrameDiagnosticsPhase diagnostics, + IRenderFrameFailureRecovery recovery) { _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); _resources = resources ?? throw new ArgumentNullException(nameof(resources)); _world = world ?? throw new ArgumentNullException(nameof(world)); _presentation = presentation ?? throw new ArgumentNullException(nameof(presentation)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + _recovery = recovery ?? throw new ArgumentNullException(nameof(recovery)); } public RenderFrameOutcome Render(RenderFrameInput input) @@ -115,17 +141,50 @@ internal sealed class RenderFrameOrchestrator } finally { + Exception? recoveryFailure = null; + if (renderFailure is not null) + { + try + { + // ImGui NewFrame is opened during preparation but normally + // closed during private presentation. Abort it before the + // GPU flight closes when any intervening phase fails. + _recovery.AbortFrame(); + } + catch (Exception error) + { + recoveryFailure = error; + } + } + try { _lifetime.EndFrame(); } catch (Exception closeFailure) when (renderFailure is not null) { + if (recoveryFailure is not null) + { + throw new AggregateException( + "Rendering failed and neither presentation recovery nor the in-flight GPU frame could be closed.", + renderFailure, + recoveryFailure, + closeFailure); + } + throw new AggregateException( "Rendering failed and the in-flight GPU frame could not be closed.", renderFailure, closeFailure); } + + if (renderFailure is not null && recoveryFailure is not null) + { + throw new AggregateException( + "Rendering failed and the presentation frame could not be aborted.", + renderFailure, + recoveryFailure); + } } } } diff --git a/src/AcDream.App/Rendering/RenderFramePreparationController.cs b/src/AcDream.App/Rendering/RenderFramePreparationController.cs new file mode 100644 index 00000000..bb49b62f --- /dev/null +++ b/src/AcDream.App/Rendering/RenderFramePreparationController.cs @@ -0,0 +1,35 @@ +namespace AcDream.App.Rendering; + +internal interface IRenderWeatherFramePhase +{ + void Tick(double deltaSeconds); +} + +/// +/// Preserves the accepted pre-world order after the GPU/resource transaction: +/// begin optional developer UI, advance render-time weather, then hand the +/// completed preparation to the world phase. +/// +internal sealed class RenderFramePreparationController : IRenderFrameResourcePhase +{ + private readonly IRenderFrameResourcePhase _resources; + private readonly IDevToolsFrameLifecycle? _devTools; + private readonly IRenderWeatherFramePhase _weather; + + public RenderFramePreparationController( + IRenderFrameResourcePhase resources, + IDevToolsFrameLifecycle? devTools, + IRenderWeatherFramePhase weather) + { + _resources = resources ?? throw new ArgumentNullException(nameof(resources)); + _devTools = devTools; + _weather = weather ?? throw new ArgumentNullException(nameof(weather)); + } + + public void Prepare(RenderFrameInput input) + { + _resources.Prepare(input); + _devTools?.BeginFrame((float)input.DeltaSeconds); + _weather.Tick(input.DeltaSeconds); + } +} diff --git a/src/AcDream.App/Rendering/RenderFrameResourceController.cs b/src/AcDream.App/Rendering/RenderFrameResourceController.cs index 49ed2bf6..def8f00d 100644 --- a/src/AcDream.App/Rendering/RenderFrameResourceController.cs +++ b/src/AcDream.App/Rendering/RenderFrameResourceController.cs @@ -350,7 +350,7 @@ internal sealed class RuntimeRenderFrameLivePreparation } /// Owns the render-time weather clock and deterministic day index. -internal sealed class RenderWeatherFrameController +internal sealed class RenderWeatherFrameController : IRenderWeatherFramePhase { private readonly WorldTimeService _worldTime; private readonly WeatherSystem _weather; diff --git a/src/AcDream.App/Rendering/TextRenderGlStateScope.cs b/src/AcDream.App/Rendering/TextRenderGlStateScope.cs new file mode 100644 index 00000000..b65123b7 --- /dev/null +++ b/src/AcDream.App/Rendering/TextRenderGlStateScope.cs @@ -0,0 +1,156 @@ +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +internal interface ITextRenderGlStateApi +{ + bool IsEnabled(EnableCap capability); + + int GetInteger(GetPName parameter); + + bool GetBoolean(GetPName parameter); + + void SetCapability(EnableCap capability, bool enabled); + + void DepthMask(bool enabled); + + void BlendFuncSeparate( + BlendingFactor sourceRgb, + BlendingFactor destinationRgb, + BlendingFactor sourceAlpha, + BlendingFactor destinationAlpha); + + void UseProgram(uint program); + + void BindVertexArray(uint vertexArray); + + void BindBuffer(BufferTargetARB target, uint buffer); + + void ActiveTexture(TextureUnit unit); + + void BindTexture(TextureTarget target, uint texture); +} + +internal sealed class SilkTextRenderGlStateApi : ITextRenderGlStateApi +{ + private readonly GL _gl; + + public SilkTextRenderGlStateApi(GL gl) => + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + + public bool IsEnabled(EnableCap capability) => _gl.IsEnabled(capability); + + public int GetInteger(GetPName parameter) => _gl.GetInteger(parameter); + + public bool GetBoolean(GetPName parameter) => _gl.GetBoolean(parameter); + + public void SetCapability(EnableCap capability, bool enabled) + { + if (enabled) + _gl.Enable(capability); + else + _gl.Disable(capability); + } + + public void DepthMask(bool enabled) => _gl.DepthMask(enabled); + + public void BlendFuncSeparate( + BlendingFactor sourceRgb, + BlendingFactor destinationRgb, + BlendingFactor sourceAlpha, + BlendingFactor destinationAlpha) => + _gl.BlendFuncSeparate( + sourceRgb, + destinationRgb, + sourceAlpha, + destinationAlpha); + + public void UseProgram(uint program) => _gl.UseProgram(program); + + public void BindVertexArray(uint vertexArray) => _gl.BindVertexArray(vertexArray); + + public void BindBuffer(BufferTargetARB target, uint buffer) => + _gl.BindBuffer(target, buffer); + + public void ActiveTexture(TextureUnit unit) => _gl.ActiveTexture(unit); + + public void BindTexture(TextureTarget target, uint texture) => + _gl.BindTexture(target, texture); +} + +/// +/// Exact, focused state transaction for . It +/// captures every GL value that Flush or DrawLayer mutates, while avoiding the +/// dozens of unrelated synchronous reads made by the broad diagnostic scope. +/// +internal readonly struct TextRenderGlStateScope : IDisposable +{ + private readonly ITextRenderGlStateApi _gl; + private readonly bool _depthTest; + private readonly bool _blend; + private readonly bool _cullFace; + private readonly bool _alphaToCoverage; + private readonly bool _multisample; + private readonly bool _depthWrite; + private readonly int _blendSourceRgb; + private readonly int _blendDestinationRgb; + private readonly int _blendSourceAlpha; + private readonly int _blendDestinationAlpha; + private readonly int _program; + private readonly int _vertexArray; + private readonly int _arrayBuffer; + private readonly int _activeTexture; + private readonly int _texture0Binding2D; + + public TextRenderGlStateScope(ITextRenderGlStateApi gl) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _depthTest = gl.IsEnabled(EnableCap.DepthTest); + _blend = gl.IsEnabled(EnableCap.Blend); + _cullFace = gl.IsEnabled(EnableCap.CullFace); + _alphaToCoverage = gl.IsEnabled(EnableCap.SampleAlphaToCoverage); + _multisample = gl.IsEnabled(EnableCap.Multisample); + _depthWrite = gl.GetBoolean(GetPName.DepthWritemask); + _blendSourceRgb = gl.GetInteger(GetPName.BlendSrcRgb); + _blendDestinationRgb = gl.GetInteger(GetPName.BlendDstRgb); + _blendSourceAlpha = gl.GetInteger(GetPName.BlendSrcAlpha); + _blendDestinationAlpha = gl.GetInteger(GetPName.BlendDstAlpha); + _program = gl.GetInteger(GetPName.CurrentProgram); + _vertexArray = gl.GetInteger(GetPName.VertexArrayBinding); + _arrayBuffer = gl.GetInteger(GetPName.ArrayBufferBinding); + _activeTexture = gl.GetInteger(GetPName.ActiveTexture); + + try + { + gl.ActiveTexture(TextureUnit.Texture0); + _texture0Binding2D = gl.GetInteger(GetPName.TextureBinding2D); + } + finally + { + gl.ActiveTexture((TextureUnit)_activeTexture); + } + } + + public void Dispose() + { + _gl.UseProgram((uint)_program); + _gl.BindVertexArray((uint)_vertexArray); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, (uint)_arrayBuffer); + + _gl.ActiveTexture(TextureUnit.Texture0); + _gl.BindTexture(TextureTarget.Texture2D, (uint)_texture0Binding2D); + _gl.ActiveTexture((TextureUnit)_activeTexture); + + _gl.DepthMask(_depthWrite); + _gl.BlendFuncSeparate( + (BlendingFactor)_blendSourceRgb, + (BlendingFactor)_blendDestinationRgb, + (BlendingFactor)_blendSourceAlpha, + (BlendingFactor)_blendDestinationAlpha); + _gl.SetCapability(EnableCap.DepthTest, _depthTest); + _gl.SetCapability(EnableCap.Blend, _blend); + _gl.SetCapability(EnableCap.CullFace, _cullFace); + _gl.SetCapability(EnableCap.SampleAlphaToCoverage, _alphaToCoverage); + _gl.SetCapability(EnableCap.Multisample, _multisample); + } +} diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 88e1f69d..096dd929 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -24,6 +24,7 @@ public sealed unsafe class TextRenderer : IDisposable private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4) private readonly GL _gl; + private readonly ITextRenderGlStateApi _glState; private readonly Shader _shader; private uint _vao; private uint _vbo; @@ -53,6 +54,7 @@ public sealed unsafe class TextRenderer : IDisposable // at its FIRST-insertion point, so later bar sprites covered glyphs emitted // earlier via the shared dat-font atlas — the stamina/mana numbers vanished.) private sealed class SpriteSeg { public uint Texture; public readonly List Verts = new(256); } + private readonly List _spriteSegs = new(); private int _segUsed; private int _textVerts; @@ -77,6 +79,7 @@ public sealed unsafe class TextRenderer : IDisposable public TextRenderer(GL gl, string shaderDir) { _gl = gl; + _glState = new SilkTextRenderGlStateApi(gl); _shader = new Shader(gl, Path.Combine(shaderDir, "ui_text.vert"), Path.Combine(shaderDir, "ui_text.frag")); @@ -350,23 +353,24 @@ public sealed unsafe class TextRenderer : IDisposable bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0; if (!anyNormal && !anyOverlay) return; + // Retained UI is a private render pass: an upload or draw failure must + // not leak its depth/cull/blend/MSAA state into a later recoverable + // frame. The focused scope restores from Flush's generated finally, + // including when either DrawLayer call throws. + using var stateScope = new TextRenderGlStateScope(_glState); + _shader.Use(); _shader.SetVec2("uScreenSize", _screenSize); _gl.BindVertexArray(_vao); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); - // Save GL state. - bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); - bool wasBlend = _gl.IsEnabled(EnableCap.Blend); - bool wasCull = _gl.IsEnabled(EnableCap.CullFace); + // Establish the self-contained UI pass state. // The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher, // QualitySettings MSAA). If they bleed into the UI pass, each glyph's soft alpha // EDGE is converted to dithered MSAA coverage instead of a clean alpha blend — // the "text not sharp / fuzzy" artifact. The UI composites with straight alpha // blending and must own this state (feedback_render_self_contained_gl_state). - bool wasA2C = _gl.IsEnabled(EnableCap.SampleAlphaToCoverage); - bool wasMsaa = _gl.IsEnabled(EnableCap.Multisample); _gl.Disable(EnableCap.SampleAlphaToCoverage); _gl.Disable(EnableCap.Multisample); _gl.Disable(EnableCap.DepthTest); @@ -386,15 +390,6 @@ public sealed unsafe class TextRenderer : IDisposable DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font); DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font); - // Restore GL state. - _gl.DepthMask(true); - if (!wasBlend) _gl.Disable(EnableCap.Blend); - if (wasCull) _gl.Enable(EnableCap.CullFace); - if (wasDepth) _gl.Enable(EnableCap.DepthTest); - if (wasA2C) _gl.Enable(EnableCap.SampleAlphaToCoverage); - if (wasMsaa) _gl.Enable(EnableCap.Multisample); - - _gl.BindVertexArray(0); } /// Draw one compositing layer: sprites (submission order, one call per diff --git a/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs b/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs index bbcc3a81..04daaab8 100644 --- a/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs +++ b/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs @@ -60,5 +60,15 @@ public sealed class ImGuiBootstrapper : IDisposable /// public void Render() => _controller.Render(); + /// + /// Close an unfinished frame through the owning Silk controller. Silk's + /// controller clears its private frame-begun flag before uploading draw + /// data, so this is both the correct abort path for pre-render failures and + /// an idempotent no-op when a render upload failed after logical close. + /// Calling raw ImGui.EndFrame here would leave the controller's state + /// open and poison its next call. + /// + public void AbortFrame() => _controller.Render(); + public void Dispose() => _controller.Dispose(); } diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index 09f7b590..28000eda 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -19,7 +19,6 @@ public sealed class WorldLifecycleAutomationControllerTests ]; var logs = new List(); var controller = new FrameScreenshotController( - () => (2, 2), (_, _) => rgba, directory, logs.Add); @@ -29,7 +28,7 @@ public sealed class WorldLifecycleAutomationControllerTests Assert.True(controller.TryRequest("login_stable", out string error), error); Assert.False(controller.IsComplete("login_stable")); - Assert.True(controller.CapturePending()); + Assert.True(controller.CapturePending(2, 2)); Assert.True(controller.IsComplete("login_stable")); string path = Path.Combine(directory, "login_stable.png"); @@ -53,15 +52,14 @@ public sealed class WorldLifecycleAutomationControllerTests { string directory = NewDirectory(); var controller = new FrameScreenshotController( - () => (0, 0), (_, _) => [], directory); try { - Assert.False(controller.CapturePending()); + Assert.False(controller.CapturePending(0, 0)); Assert.True(controller.TryRequest("bad_frame", out string error), error); - Assert.False(controller.CapturePending()); + Assert.False(controller.CapturePending(0, 0)); Assert.False(controller.IsComplete("bad_frame")); } finally @@ -109,7 +107,6 @@ public sealed class WorldLifecycleAutomationControllerTests TrackedGpuBytes = 1234, }; var screenshots = new FrameScreenshotController( - () => (1, 1), (_, _) => [0, 0, 0, 255], Path.Combine(directory, "screenshots")); var controller = new WorldLifecycleAutomationController( diff --git a/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs index b28279a3..3b0c6139 100644 --- a/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs @@ -42,8 +42,10 @@ public sealed class DevToolsFramePresenterTests var commands = new RecordingCommandSource { Current = first }; var presenter = Create(backend, commands: commands); + presenter.BeginFrame(0.1f); presenter.Render(0.1, 800, 600); commands.Current = second; + presenter.BeginFrame(0.2f); presenter.Render(0.2, 800, 600); Assert.Same(first, backend.Contexts[0].Commands); @@ -60,6 +62,7 @@ public sealed class DevToolsFramePresenterTests var panels = new RecordingPanels(); var presenter = Create(backend, panels: panels); + presenter.BeginFrame(0.1f); presenter.Render(0.1, 1920, 1080); Assert.Equal( @@ -93,6 +96,7 @@ public sealed class DevToolsFramePresenterTests var camera = new RecordingCamera { IsFlyMode = true, CollideCamera = true }; var presenter = Create(backend, camera: camera); + presenter.BeginFrame(0.1f); presenter.Render(0.1, 1280, 720); Assert.Equal(1, camera.ToggleCount); @@ -113,6 +117,7 @@ public sealed class DevToolsFramePresenterTests presenter.ToggleSettingsPanel(); presenter.ResetLayout(1280, 720, DevToolsPanelLayoutCondition.FirstUseEver); + presenter.BeginFrame(0.1f); presenter.Render(0.1, 1280, 720); Assert.DoesNotContain(DevToolsPanelKind.Settings, panels.Toggles); @@ -156,6 +161,68 @@ public sealed class DevToolsFramePresenterTests call.WithoutCondition())); } + [Fact] + public void AbortFrame_ClosesAnOpenBackendFrameAndAllowsTheNextFrame() + { + var calls = new List(); + var backend = new RecordingBackend(calls); + var presenter = Create(backend); + + presenter.BeginFrame(0.1f); + presenter.AbortFrame(); + presenter.AbortFrame(); + presenter.BeginFrame(0.2f); + presenter.Render(0.2, 800, 600); + + Assert.Equal(1, calls.Count(call => call == "abort")); + Assert.Equal(2, calls.Count(call => call.StartsWith("begin:"))); + Assert.Equal(1, calls.Count(call => call == "draw-data")); + } + + [Fact] + public void RenderFailure_RemainsAbortableAndDoesNotPoisonTheNextFrame() + { + var calls = new List(); + var backend = new RecordingBackend(calls) + { + DrawFailure = new InvalidOperationException("draw"), + }; + var presenter = Create(backend); + + presenter.BeginFrame(0.1f); + Assert.Throws(() => + presenter.Render(0.1, 800, 600)); + presenter.AbortFrame(); + + backend.DrawFailure = null; + presenter.BeginFrame(0.2f); + presenter.Render(0.2, 800, 600); + + Assert.Equal(0, calls.Count(call => call == "abort")); + Assert.Equal(2, calls.Count(call => call == "draw-data")); + } + + [Fact] + public void BeginFailure_RemainsAbortableAndDoesNotPoisonTheNextFrame() + { + var calls = new List(); + var backend = new RecordingBackend(calls) + { + BeginFailure = new InvalidOperationException("begin"), + }; + var presenter = Create(backend); + + Assert.Throws(() => presenter.BeginFrame(0.1f)); + presenter.AbortFrame(); + + backend.BeginFailure = null; + presenter.BeginFrame(0.2f); + presenter.Render(0.2, 800, 600); + + Assert.Equal(1, calls.Count(call => call == "abort")); + Assert.Equal(2, calls.Count(call => call.StartsWith("begin:"))); + } + [Fact] public void InputFacingActionsDelegateToTheTypedPanelOwner() { @@ -198,7 +265,7 @@ public sealed class DevToolsFramePresenterTests field => field.FieldType == typeof(Silk.NET.Windowing.IWindow)); } - Assert.False(typeof(IDisposable).IsAssignableFrom( + Assert.True(typeof(IDisposable).IsAssignableFrom( typeof(ImGuiDevToolsFrameBackend))); } @@ -221,8 +288,29 @@ public sealed class DevToolsFramePresenterTests public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = []; public List Contexts { get; } = []; public List Layouts { get; } = []; + public Exception? BeginFailure { get; set; } + public Exception? DrawFailure { get; set; } - public void BeginFrame(float deltaSeconds) => calls.Add($"begin:{deltaSeconds}"); + public bool ControllerFrameOpen { get; private set; } + + public void BeginFrame(float deltaSeconds) + { + calls.Add($"begin:{deltaSeconds}"); + ControllerFrameOpen = true; + if (BeginFailure is not null) + throw BeginFailure; + } + + public void AbortFrame() + { + // Silk ImGuiController.Render is idempotent. It closes an active + // controller frame, but is a no-op if Render already cleared its + // private _frameBegun flag before a GL upload failure. + if (!ControllerFrameOpen) + return; + ControllerFrameOpen = false; + calls.Add("abort"); + } public bool BeginMainMenuBar() { @@ -254,7 +342,14 @@ public sealed class DevToolsFramePresenterTests calls.Add($"panels:{context.DeltaSeconds}"); } - public void RenderDrawData() => calls.Add("draw-data"); + public void RenderDrawData() + { + Assert.True(ControllerFrameOpen); + ControllerFrameOpen = false; + calls.Add("draw-data"); + if (DrawFailure is not null) + throw DrawFailure; + } public void SetWindowLayout( string title, diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index f8068059..91f0533b 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -7,32 +7,33 @@ public sealed class GameWindowRenderLeafCompositionTests [Fact] public void ProductionRender_PreservesPrivatePresentationAndCaptureOrder() { - string source = GameWindowSource(); + string presentation = Source("PrivatePresentationRenderer.cs"); + string orchestrator = Source("RenderFrameOrchestrator.cs"); AssertAppearsInOrder( - source, - "_localPlayerTeleport?.DrawPortalViewport(", - "_paperdollFramePresenter?.Render();", - "_retailUiRuntime.Tick(deltaSeconds);", - "_devToolsFramePresenter?.Render(", - "_frameScreenshots?.CapturePending() == true;", - "_renderFrameDiagnostics?.Publish("); + presentation, + "_portal.Draw(", + "_paperdoll?.Render();", + "_gameplayUi?.Render(", + "_devTools?.Render(", + "_screenshots?.CapturePending("); + AssertAppearsInOrder( + orchestrator, + "_world.Render(input);", + "_presentation.Render(input, world);", + "_diagnostics.Publish(input, outcome);"); } [Fact] public void ProductionRender_Prepares_resources_then_devtools_weather_and_world() { - string source = GameWindowSource(); + string source = Source("RenderFramePreparationController.cs"); AssertAppearsInOrder( source, - "_renderFrameResources!.Prepare(frameInput);", - "_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);", - "_renderWeatherFrame!.Tick(deltaSeconds);", - "_worldSceneRenderer!.Render(frameInput);"); - Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source); - Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source); - Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source); + "_resources.Prepare(input);", + "_devTools?.BeginFrame((float)input.DeltaSeconds);", + "_weather.Tick(input.DeltaSeconds);"); } [Fact] @@ -46,7 +47,9 @@ public sealed class GameWindowRenderLeafCompositionTests "_portalTunnel = null;", "_localPlayerTeleportSink.Bind(_localPlayerTeleport);", "new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(", - "new AcDream.App.Rendering.RenderFrameResourceController("); + "new AcDream.App.Rendering.RenderFrameResourceController(", + "new AcDream.App.Rendering.PrivatePresentationRenderer(", + "new AcDream.App.Rendering.RenderFrameOrchestrator("); } [Fact] @@ -78,6 +81,13 @@ public sealed class GameWindowRenderLeafCompositionTests "ApplyFramePacingPreference", "RefreshActiveMonitorFramePacing", "private void OnFrameRendered(", + "private AcDream.App.Rendering.WorldRenderDiagnostics? _worldRenderDiagnostics", + "private AcDream.App.Rendering.WorldRenderFrameBuilder?", + "private AcDream.App.Rendering.SkyPesFrameController?", + "private AcDream.App.Rendering.RetailPViewRenderer?", + "private AcDream.App.Rendering.RetailPViewPassExecutor?", + "private AcDream.App.Rendering.RetailPViewCellSource?", + "private AcDream.App.Rendering.TerrainDrawDiagnosticsController?", ]; foreach (string identifier in removed) Assert.DoesNotContain(identifier, source, StringComparison.Ordinal); @@ -86,6 +96,8 @@ public sealed class GameWindowRenderLeafCompositionTests Assert.Contains("new AcDream.App.Rendering.DevToolsFramePresenter(", source); Assert.Contains("new AcDream.App.Rendering.RenderFrameResourceController(", source); Assert.Contains("new AcDream.App.Rendering.RenderWeatherFrameController(", source); + Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source); + Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", source); Assert.Contains("new DisplayFramePacingController(", source); Assert.Contains("_inputCapture.WantCaptureMouse", source); } @@ -99,40 +111,70 @@ public sealed class GameWindowRenderLeafCompositionTests source, "new ResourceShutdownStage(\"submitted GPU work\"", "new ResourceShutdownStage(\"render frontends\"", - "new(\"frame composition\"", + "new(\"developer tools\"", + "_devToolsBackend?.Dispose()", "new(\"portal tunnel\"", "new(\"paperdoll viewport\"", "new ResourceShutdownStage(\"OpenGL context\""); - Assert.Contains("_renderFrameResources = null;", source); - Assert.Contains("_renderFrameLivePreparation = null;", source); - Assert.Contains("_renderWeatherFrame = null;", source); AssertAppearsInOrder( source, "new ResourceShutdownStage(\"frame borrowers\"", - "_retailPViewPassExecutor = null;", - "_retailPViewCells = null;", - "_terrainDrawDiagnostics = null;", - "_retailPViewRenderer = null;", + "_renderFrameOrchestrator = null;", "new ResourceShutdownStage(\"session dependents\""); AssertAppearsInOrder( source, "new(\"frame pacing\", _displayFramePacing.Dispose)", "new(\"frame profiler\", _frameProfiler.Dispose)"); - Assert.DoesNotContain("_devToolsBackend?.Dispose()", source); + AssertAppearsInOrder( + source, + "_renderFrameOrchestrator = null;", + "_devToolsBackend?.Dispose()", + "new ResourceShutdownStage(\"input\"", + "_input?.Dispose();", + "new ResourceShutdownStage(\"OpenGL context\""); } [Fact] public void ProductionOutcome_UsesObservedWorldAndScreenshotFacts() { - string source = GameWindowSource(); + string presentation = Source("PrivatePresentationRenderer.cs"); + string orchestrator = Source("RenderFrameOrchestrator.cs"); AssertAppearsInOrder( - source, - "_worldSceneRenderer!.Render(frameInput);", - "worldOutcome.NormalWorldDrawn;", - "bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;", - "normalWorldDrawn),", - "screenshotCaptured)));"); + presentation, + "_foundation.Foundation.PortalViewportVisible;", + "_portal.Draw(", + "bool screenshotCaptured = _screenshots?.CapturePending(", + "portalViewportVisible,", + "screenshotCaptured);"); + AssertAppearsInOrder( + orchestrator, + "WorldRenderFrameOutcome world = _world.Render(input);", + "_presentation.Render(input, world);", + "new RenderFrameOutcome(world, presentation);", + "_diagnostics.Publish(input, outcome);"); + } + + [Fact] + public void GameWindow_OnRenderIsOneImmutableOrchestratorHandoff() + { + string source = GameWindowSource(); + int start = source.IndexOf( + "private void OnRender(double deltaSeconds)", + StringComparison.Ordinal); + int end = source.IndexOf( + "private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot", + start, + StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + string body = source[start..end]; + + Assert.Equal(1, CountOccurrences(body, "_renderFrameOrchestrator!.Render(")); + Assert.DoesNotContain("_gpuFrameFlights", body); + Assert.DoesNotContain("_worldScene", body); + Assert.DoesNotContain("_devToolsFramePresenter", body); + Assert.DoesNotContain("_retailUiRuntime", body); + Assert.DoesNotContain("_frameScreenshots", body); } [Fact] @@ -168,6 +210,25 @@ public sealed class GameWindowRenderLeafCompositionTests "Rendering", "GameWindow.cs")); + private static string Source(string fileName) => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + fileName)); + + private static int CountOccurrences(string source, string value) + { + int count = 0; + int cursor = 0; + while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0) + { + count++; + cursor += value.Length; + } + return count; + } + private static void AssertAppearsInOrder(string source, params string[] needles) { int cursor = -1; diff --git a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs index 87dc0270..80ca7706 100644 --- a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs @@ -93,7 +93,7 @@ public sealed class PaperdollFramePresenterTests } [Fact] - public void PresenterAndProductionHelpersHaveNoWindowOrDelegateBackReference() + public void PresenterAndProductionHelpersHaveNoDirectWindowOrDelegateBackReference() { Type[] owners = [ diff --git a/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs b/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs new file mode 100644 index 00000000..4d384a67 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs @@ -0,0 +1,255 @@ +using System.Reflection; +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class PrivatePresentationRendererTests +{ + private static readonly RenderFrameInput Input = new(0.125d, 1600, 900); + private static readonly WorldRenderFrameOutcome World = new(3, 12, true); + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void Render_PreservesPortalPaperdollGameplayDevToolsScreenshotOrder( + bool portalVisible, + bool screenshotCaptured) + { + List calls = []; + var portal = new Portal(calls); + var paperdoll = new Paperdoll(calls); + var gameplay = new GameplayUi(calls); + var devTools = new DevTools(calls); + var screenshots = new Screenshots(calls, screenshotCaptured); + var presentation = new PrivatePresentationRenderer( + portal, + new FoundationSource(portalVisible), + paperdoll, + gameplay, + devTools, + screenshots); + + PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World); + + Assert.Equal( + ["portal", "paperdoll", "gameplay-ui", "devtools-render", "screenshot"], + calls); + Assert.Equal(new PrivatePresentationFrameOutcome( + portalVisible, + screenshotCaptured), outcome); + Assert.Equal((Input.ViewportWidth, Input.ViewportHeight), portal.Size); + Assert.Equal((Input.ViewportWidth, Input.ViewportHeight), screenshots.Size); + Assert.Equal(Input, gameplay.Input); + Assert.Equal(Input, devTools.Input); + } + + [Fact] + public void Render_OptionalLayersMayBeAbsentButPortalOutcomeRemainsObserved() + { + List calls = []; + var presentation = new PrivatePresentationRenderer( + new Portal(calls), + new FoundationSource(portalVisible: true), + paperdoll: null, + gameplayUi: null, + devTools: null, + screenshots: null); + + PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World); + + Assert.Equal(["portal"], calls); + Assert.True(outcome.PortalViewportDrawn); + Assert.False(outcome.ScreenshotCaptured); + } + + [Fact] + public void Render_ReportsThePreparedPortalSnapshotWhenDrawMutatesTheSource() + { + List calls = []; + var foundation = new MutableFoundationSource(portalVisible: true); + var presentation = new PrivatePresentationRenderer( + new MutatingPortal(calls, () => foundation.PortalVisible = false), + foundation, + paperdoll: null, + gameplayUi: null, + devTools: null, + screenshots: null); + + PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World); + + Assert.Equal(["portal"], calls); + Assert.False(foundation.PortalVisible); + Assert.True(outcome.PortalViewportDrawn); + } + + [Fact] + public void Constructor_RequiresTheCanonicalPortalOwner() + { + Assert.Throws(() => + new PrivatePresentationRenderer(null!, new FoundationSource(false), null, null, null, null)); + Assert.Throws(() => + new PrivatePresentationRenderer(new Portal([]), null!, null, null, null, null)); + } + + [Fact] + public void ExtractedPresentationOwner_RetainsNoDirectWindowOrDelegate() + { + FieldInfo[] fields = typeof(PrivatePresentationRenderer).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain( + fields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + + [Fact] + public void RetainedUiAdapter_UsesTheDocumentedRuntimeBindingBoundary() + { + FieldInfo[] adapterFields = typeof(RetainedGameplayUiFrame).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.Equal( + [ + typeof(AcDream.App.UI.RetailUiRuntime), + typeof(Silk.NET.Input.IInputContext), + ], + adapterFields.Select(field => field.FieldType).OrderBy(type => type.FullName)); + Assert.DoesNotContain( + adapterFields, + field => field.FieldType == typeof(GameWindow) + || typeof(Delegate).IsAssignableFrom(field.FieldType)); + + FieldInfo bindings = typeof(AcDream.App.UI.RetailUiRuntime).GetField( + "_bindings", + BindingFlags.Instance | BindingFlags.NonPublic)!; + Assert.Equal(typeof(AcDream.App.UI.RetailUiRuntimeBindings), bindings.FieldType); + Assert.Contains( + typeof(AcDream.App.UI.RetailUiRuntimeBindings).GetProperties(), + property => property.PropertyType.Name.EndsWith("Bindings", StringComparison.Ordinal)); + } + + [Fact] + public void KnownSharedOwnerPaths_ArePinnedWithoutClaimingRecursivePurity() + { + static Type[] FieldTypes(Type owner) => owner.GetFields( + BindingFlags.Instance | BindingFlags.NonPublic) + .Select(field => field.FieldType) + .ToArray(); + + Assert.Contains( + typeof(AcDream.App.UI.RetailUiRuntime), + FieldTypes(typeof(RetainedGameplayUiFrame))); + Assert.Contains( + typeof(AcDream.App.UI.UiViewport), + FieldTypes(typeof(RetailPaperdollFrameView))); + Assert.Contains( + typeof(AcDream.App.UI.UiElement), + FieldTypes(typeof(PaperdollInventoryVisibility))); + Assert.Contains( + typeof(AcDream.UI.Abstractions.Panels.Debug.DebugVM), + FieldTypes(typeof(DevToolsPanelSet))); + Assert.Contains( + typeof(AcDream.UI.Abstractions.Panels.Settings.SettingsVM), + FieldTypes(typeof(RuntimeWorldFrameSettingsPreview))); + Assert.Contains( + typeof(AcDream.App.Streaming.LocalPlayerTeleportController), + FieldTypes(typeof(LocalPlayerPortalViewport))); + Assert.Contains( + typeof(AcDream.App.Streaming.ILocalPlayerTeleportInputLifetime), + FieldTypes(typeof(AcDream.App.Streaming.LocalPlayerTeleportController))); + + Assert.DoesNotContain( + FieldTypes(typeof(PrivateFrameScreenshot)), + type => typeof(Delegate).IsAssignableFrom(type)); + Assert.Null(typeof(AcDream.App.Diagnostics.FrameScreenshotController).GetField( + "_getSize", + BindingFlags.Instance | BindingFlags.NonPublic)); + } + + private sealed class Portal(List calls) : IPrivatePortalViewport + { + public (int Width, int Height) Size { get; private set; } + + public void Draw(int width, int height) + { + Size = (width, height); + calls.Add("portal"); + } + } + + private sealed class FoundationSource(bool portalVisible) : IRenderFrameFoundationSource + { + public RenderFrameFoundation Foundation { get; } = new( + portalVisible, + default, + default); + } + + private sealed class MutableFoundationSource(bool portalVisible) : + IRenderFrameFoundationSource + { + public bool PortalVisible { get; set; } = portalVisible; + + public RenderFrameFoundation Foundation => new( + PortalVisible, + default, + default); + } + + private sealed class MutatingPortal(List calls, Action mutate) : + IPrivatePortalViewport + { + public void Draw(int width, int height) + { + calls.Add("portal"); + mutate(); + } + } + + private sealed class Paperdoll(List calls) : IPrivatePaperdollFrame + { + public void Render() => calls.Add("paperdoll"); + } + + private sealed class GameplayUi(List calls) : IRetainedGameplayUiFrame + { + public RenderFrameInput Input { get; private set; } + + public void Render(double deltaSeconds, int width, int height) + { + Input = new RenderFrameInput(deltaSeconds, width, height); + calls.Add("gameplay-ui"); + } + } + + private sealed class DevTools(List calls) : IDevToolsFrameLifecycle + { + public RenderFrameInput Input { get; private set; } + + public void BeginFrame(float deltaSeconds) => calls.Add("devtools-begin"); + + public void AbortFrame() => calls.Add("devtools-abort"); + + public void Render(double deltaSeconds, int viewportWidth, int viewportHeight) + { + Input = new RenderFrameInput(deltaSeconds, viewportWidth, viewportHeight); + calls.Add("devtools-render"); + } + } + + private sealed class Screenshots(List calls, bool result) : + IPrivateFrameScreenshot + { + public (int Width, int Height) Size { get; private set; } + + public bool CapturePending(int width, int height) + { + Size = (width, height); + calls.Add("screenshot"); + return result; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs index b132cadb..34807786 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs @@ -148,21 +148,77 @@ public sealed class RenderFrameOrchestratorTests phases.ObservedInputs); } + [Fact] + public void RenderAndRecoveryFailure_AreAggregatedBeforeGpuClose() + { + var calls = new List(); + var renderFailure = new InvalidOperationException("render"); + var recoveryFailure = new InvalidOperationException("recovery"); + var phases = new RecordingPhases(calls) + { + FailurePoint = "world", + Failure = renderFailure, + RecoveryFailure = recoveryFailure, + }; + + AggregateException actual = Assert.Throws( + () => Create(phases).Render(Input)); + + Assert.Equal(2, actual.InnerExceptions.Count); + Assert.Same(renderFailure, actual.InnerExceptions[0]); + Assert.Same(recoveryFailure, actual.InnerExceptions[1]); + Assert.Equal( + ["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"], + calls); + } + + [Fact] + public void RenderRecoveryAndCloseFailures_AllRemainObservableInCausalOrder() + { + var calls = new List(); + var renderFailure = new InvalidOperationException("render"); + var recoveryFailure = new InvalidOperationException("recovery"); + var closeFailure = new InvalidOperationException("close"); + var phases = new RecordingPhases(calls) + { + FailurePoint = "presentation", + Failure = renderFailure, + RecoveryFailure = recoveryFailure, + CloseFailure = closeFailure, + }; + + AggregateException actual = Assert.Throws( + () => Create(phases).Render(Input)); + + Assert.Equal(3, actual.InnerExceptions.Count); + Assert.Same(renderFailure, actual.InnerExceptions[0]); + Assert.Same(recoveryFailure, actual.InnerExceptions[1]); + Assert.Same(closeFailure, actual.InnerExceptions[2]); + Assert.Equal( + [ + "gpu-begin", "resources", "world", "presentation", + "recovery-abort", "gpu-end", + ], + calls); + } + [Fact] public void Constructor_RejectsEveryMissingRequiredOwner() { var phases = new RecordingPhases([]); Assert.Throws(() => new RenderFrameOrchestrator( - null!, phases, phases, phases, phases)); + null!, phases, phases, phases, phases, phases)); Assert.Throws(() => new RenderFrameOrchestrator( - phases, null!, phases, phases, phases)); + phases, null!, phases, phases, phases, phases)); Assert.Throws(() => new RenderFrameOrchestrator( - phases, phases, null!, phases, phases)); + phases, phases, null!, phases, phases, phases)); Assert.Throws(() => new RenderFrameOrchestrator( - phases, phases, phases, null!, phases)); + phases, phases, phases, null!, phases, phases)); Assert.Throws(() => new RenderFrameOrchestrator( - phases, phases, phases, phases, null!)); + phases, phases, phases, phases, null!, phases)); + Assert.Throws(() => new RenderFrameOrchestrator( + phases, phases, phases, phases, phases, null!)); } [Fact] @@ -175,6 +231,7 @@ public sealed class RenderFrameOrchestratorTests typeof(IWorldSceneFramePhase), typeof(IPrivatePresentationFramePhase), typeof(IRenderFrameDiagnosticsPhase), + typeof(IRenderFrameFailureRecovery), ]; FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields( BindingFlags.Instance | BindingFlags.NonPublic); @@ -239,14 +296,17 @@ public sealed class RenderFrameOrchestratorTests private static string[] ExpectedFailureCalls(string failurePoint) => failurePoint switch { - "resources" => ["gpu-begin", "resources", "gpu-end"], - "world" => ["gpu-begin", "resources", "world", "gpu-end"], + "resources" => ["gpu-begin", "resources", "recovery-abort", "gpu-end"], + "world" => ["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"], "presentation" => - ["gpu-begin", "resources", "world", "presentation", "gpu-end"], + [ + "gpu-begin", "resources", "world", "presentation", + "recovery-abort", "gpu-end", + ], "diagnostics" => [ "gpu-begin", "resources", "world", "presentation", "diagnostics", - "gpu-end", + "recovery-abort", "gpu-end", ], _ => throw new ArgumentOutOfRangeException(nameof(failurePoint)), }; @@ -266,14 +326,15 @@ public sealed class RenderFrameOrchestratorTests } private static RenderFrameOrchestrator Create(RecordingPhases phases) => - new(phases, phases, phases, phases, phases); + new(phases, phases, phases, phases, phases, phases); private sealed class RecordingPhases : IRenderFrameLifetime, IRenderFrameResourcePhase, IWorldSceneFramePhase, IPrivatePresentationFramePhase, - IRenderFrameDiagnosticsPhase + IRenderFrameDiagnosticsPhase, + IRenderFrameFailureRecovery { private readonly List _calls; @@ -285,6 +346,7 @@ public sealed class RenderFrameOrchestratorTests public string? FailurePoint { get; init; } public Exception? Failure { get; init; } public Exception? CloseFailure { get; init; } + public Exception? RecoveryFailure { get; init; } public WorldRenderFrameOutcome World { get; init; } = new(7, 19, true); public PrivatePresentationFrameOutcome Presentation { get; init; } = new(false, false); public List<(string Phase, RenderFrameInput Input)> ObservedInputs { get; } = []; @@ -300,6 +362,13 @@ public sealed class RenderFrameOrchestratorTests throw CloseFailure; } + public void AbortFrame() + { + _calls.Add("recovery-abort"); + if (RecoveryFailure is not null) + throw RecoveryFailure; + } + public void Prepare(RenderFrameInput input) { ObservedInputs.Add(("resources", input)); diff --git a/tests/AcDream.App.Tests/Rendering/RenderFramePreparationControllerTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFramePreparationControllerTests.cs new file mode 100644 index 00000000..d3746dbd --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RenderFramePreparationControllerTests.cs @@ -0,0 +1,92 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RenderFramePreparationControllerTests +{ + private static readonly RenderFrameInput Input = new(0.25d, 1280, 720); + + [Fact] + public void Prepare_PreservesResourcesThenDevToolsThenWeatherOrder() + { + List calls = []; + var resources = new Resources(calls); + var devTools = new DevTools(calls); + var weather = new Weather(calls); + var preparation = new RenderFramePreparationController( + resources, + devTools, + weather); + + preparation.Prepare(Input); + + Assert.Equal(["resources", "devtools-begin", "weather"], calls); + Assert.Equal(Input, resources.Input); + Assert.Equal((float)Input.DeltaSeconds, devTools.DeltaSeconds); + Assert.Equal(Input.DeltaSeconds, weather.DeltaSeconds); + } + + [Fact] + public void Prepare_DevToolsAreOptionalWithoutChangingRequiredOrder() + { + List calls = []; + var preparation = new RenderFramePreparationController( + new Resources(calls), + devTools: null, + new Weather(calls)); + + preparation.Prepare(Input); + + Assert.Equal(["resources", "weather"], calls); + } + + [Fact] + public void Constructor_RejectsMissingRequiredOwners() + { + var resources = new Resources([]); + var weather = new Weather([]); + + Assert.Throws(() => + new RenderFramePreparationController(null!, null, weather)); + Assert.Throws(() => + new RenderFramePreparationController(resources, null, null!)); + } + + private sealed class Resources(List calls) : IRenderFrameResourcePhase + { + public RenderFrameInput Input { get; private set; } + + public void Prepare(RenderFrameInput input) + { + Input = input; + calls.Add("resources"); + } + } + + private sealed class DevTools(List calls) : IDevToolsFrameLifecycle + { + public float DeltaSeconds { get; private set; } + + public void BeginFrame(float deltaSeconds) + { + DeltaSeconds = deltaSeconds; + calls.Add("devtools-begin"); + } + + public void AbortFrame() => calls.Add("devtools-abort"); + + public void Render(double deltaSeconds, int viewportWidth, int viewportHeight) => + calls.Add("devtools-render"); + } + + private sealed class Weather(List calls) : IRenderWeatherFramePhase + { + public double DeltaSeconds { get; private set; } + + public void Tick(double deltaSeconds) + { + DeltaSeconds = deltaSeconds; + calls.Add("weather"); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs new file mode 100644 index 00000000..ec0a10a5 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs @@ -0,0 +1,178 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RenderFrameRecoveryIntegrationTests +{ + private static readonly RenderFrameInput Input = new(1d / 60d, 1280, 720); + + [Theory] + [InlineData("devtools-begin")] + [InlineData("weather")] + [InlineData("world")] + [InlineData("portal")] + [InlineData("paperdoll")] + [InlineData("gameplay-ui")] + [InlineData("devtools-render")] + public void DownstreamFailure_AbortsDevToolsBeforeGpuClose_ThenNextFrameSucceeds( + string failurePoint) + { + var failure = new FailureSwitch { Point = failurePoint }; + var gpu = new GpuLifetime(failure); + var devTools = new DevTools(failure); + var preparation = new RenderFramePreparationController( + new Resources(), + devTools, + new Weather(failure)); + var presentation = new PrivatePresentationRenderer( + new Portal(failure), + new FoundationSource(), + new Paperdoll(failure), + new GameplayUi(failure), + devTools, + new Screenshots()); + var orchestrator = new RenderFrameOrchestrator( + gpu, + preparation, + new World(failure), + presentation, + new Diagnostics(), + devTools); + + Assert.Throws(() => orchestrator.Render(Input)); + + Assert.False(devTools.IsOpen); + Assert.Equal(1, devTools.AbortCount); + Assert.Equal(1, gpu.EndCount); + Assert.True(devTools.AbortSequence < gpu.LastEndSequence); + + failure.Point = null; + RenderFrameOutcome outcome = orchestrator.Render(Input); + + Assert.False(devTools.IsOpen); + Assert.Equal(2, devTools.BeginCount); + Assert.Equal(2, gpu.EndCount); + Assert.True(outcome.World.NormalWorldDrawn); + } + + private sealed class FailureSwitch + { + private int _sequence; + + public string? Point { get; set; } + + public int NextSequence() => ++_sequence; + + public void ThrowIf(string point) + { + if (Point == point) + throw new InvalidOperationException(point); + } + } + + private sealed class GpuLifetime(FailureSwitch failure) : IRenderFrameLifetime + { + public int EndCount { get; private set; } + public int LastEndSequence { get; private set; } + + public void BeginFrame() + { + } + + public void EndFrame() + { + EndCount++; + LastEndSequence = failure.NextSequence(); + } + } + + private sealed class DevTools(FailureSwitch failure) : IDevToolsFrameLifecycle + { + public bool IsOpen { get; private set; } + public int BeginCount { get; private set; } + public int AbortCount { get; private set; } + public int AbortSequence { get; private set; } + + public void BeginFrame(float deltaSeconds) + { + Assert.False(IsOpen); + IsOpen = true; + BeginCount++; + failure.ThrowIf("devtools-begin"); + } + + public void Render(double deltaSeconds, int viewportWidth, int viewportHeight) + { + Assert.True(IsOpen); + failure.ThrowIf("devtools-render"); + IsOpen = false; + } + + public void AbortFrame() + { + if (!IsOpen) + return; + + AbortCount++; + AbortSequence = failure.NextSequence(); + IsOpen = false; + } + } + + private sealed class Resources : IRenderFrameResourcePhase + { + public void Prepare(RenderFrameInput input) + { + } + } + + private sealed class Weather(FailureSwitch failure) : IRenderWeatherFramePhase + { + public void Tick(double deltaSeconds) => failure.ThrowIf("weather"); + } + + private sealed class World(FailureSwitch failure) : IWorldSceneFramePhase + { + public WorldRenderFrameOutcome Render(RenderFrameInput input) + { + failure.ThrowIf("world"); + return new WorldRenderFrameOutcome(1, 1, NormalWorldDrawn: true); + } + } + + private sealed class Portal(FailureSwitch failure) : IPrivatePortalViewport + { + public void Draw(int width, int height) => failure.ThrowIf("portal"); + } + + private sealed class Paperdoll(FailureSwitch failure) : IPrivatePaperdollFrame + { + public void Render() => failure.ThrowIf("paperdoll"); + } + + private sealed class GameplayUi(FailureSwitch failure) : IRetainedGameplayUiFrame + { + public void Render(double deltaSeconds, int width, int height) => + failure.ThrowIf("gameplay-ui"); + } + + private sealed class FoundationSource : IRenderFrameFoundationSource + { + public RenderFrameFoundation Foundation { get; } = new( + PortalViewportVisible: false, + default, + default); + } + + private sealed class Screenshots : IPrivateFrameScreenshot + { + public bool CapturePending(int width, int height) => false; + } + + private sealed class Diagnostics : IRenderFrameDiagnosticsPhase + { + public void Publish(RenderFrameInput input, RenderFrameOutcome outcome) + { + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs b/tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs new file mode 100644 index 00000000..77154717 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs @@ -0,0 +1,217 @@ +using System.Reflection; +using AcDream.App.Rendering; +using Silk.NET.OpenGL; + +namespace AcDream.App.Tests.Rendering; + +public sealed class TextRendererFailureSafetyTests +{ + [Fact] + public void Flush_CompilesCompleteGlStateScopeAsFinallyAroundBothDrawLayers() + { + MethodInfo flush = typeof(TextRenderer).GetMethod(nameof(TextRenderer.Flush))!; + MethodBody body = flush.GetMethodBody()!; + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "TextRenderer.cs")); + + Assert.Contains( + body.ExceptionHandlingClauses, + clause => clause.Flags == ExceptionHandlingClauseOptions.Finally); + AssertAppearsInOrder( + source, + "using var stateScope = new TextRenderGlStateScope(_glState);", + "_gl.Disable(EnableCap.Multisample);", + "DrawLayer(_spriteSegs,", + "DrawLayer(_overlaySpriteSegs,"); + } + + [Fact] + public void FailedDraw_RestoresEveryGlValueMutatedByTheTextPass() + { + var gl = new RecordingGlState + { + DepthWrite = false, + BlendSourceRgb = BlendingFactor.DstAlpha, + BlendDestinationRgb = BlendingFactor.OneMinusDstAlpha, + BlendSourceAlpha = BlendingFactor.One, + BlendDestinationAlpha = BlendingFactor.Zero, + Program = 17, + VertexArray = 23, + ArrayBuffer = 31, + ActiveUnit = TextureUnit.Texture2, + }; + gl.SetCapability(EnableCap.DepthTest, enabled: true); + gl.SetCapability(EnableCap.Blend, enabled: false); + gl.SetCapability(EnableCap.CullFace, enabled: true); + gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: true); + gl.SetCapability(EnableCap.Multisample, enabled: false); + gl.TextureBindings[TextureUnit.Texture0] = 41; + gl.TextureBindings[TextureUnit.Texture2] = 43; + StateSnapshot expected = gl.Capture(); + + Action failedDraw = () => + { + using var stateScope = new TextRenderGlStateScope(gl); + gl.SetCapability(EnableCap.DepthTest, enabled: false); + gl.SetCapability(EnableCap.Blend, enabled: true); + gl.SetCapability(EnableCap.CullFace, enabled: false); + gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false); + gl.SetCapability(EnableCap.Multisample, enabled: true); + gl.DepthMask(true); + gl.BlendFuncSeparate( + BlendingFactor.SrcAlpha, + BlendingFactor.OneMinusSrcAlpha, + BlendingFactor.SrcAlpha, + BlendingFactor.OneMinusSrcAlpha); + gl.UseProgram(101); + gl.BindVertexArray(103); + gl.BindBuffer(BufferTargetARB.ArrayBuffer, 107); + gl.ActiveTexture(TextureUnit.Texture0); + gl.BindTexture(TextureTarget.Texture2D, 109); + throw new InvalidOperationException("draw upload"); + }; + + Assert.Throws(failedDraw); + + Assert.Equal(expected, gl.Capture()); + } + + private readonly record struct StateSnapshot( + bool DepthTest, + bool Blend, + bool Cull, + bool AlphaToCoverage, + bool Multisample, + bool DepthWrite, + BlendingFactor BlendSourceRgb, + BlendingFactor BlendDestinationRgb, + BlendingFactor BlendSourceAlpha, + BlendingFactor BlendDestinationAlpha, + uint Program, + uint VertexArray, + uint ArrayBuffer, + TextureUnit ActiveUnit, + uint Texture0, + uint Texture2); + + private sealed class RecordingGlState : ITextRenderGlStateApi + { + private readonly Dictionary _capabilities = []; + + public bool DepthWrite { get; set; } + public BlendingFactor BlendSourceRgb { get; set; } + public BlendingFactor BlendDestinationRgb { get; set; } + public BlendingFactor BlendSourceAlpha { get; set; } + public BlendingFactor BlendDestinationAlpha { get; set; } + public uint Program { get; set; } + public uint VertexArray { get; set; } + public uint ArrayBuffer { get; set; } + public TextureUnit ActiveUnit { get; set; } + public Dictionary TextureBindings { get; } = []; + + public StateSnapshot Capture() => new( + IsEnabled(EnableCap.DepthTest), + IsEnabled(EnableCap.Blend), + IsEnabled(EnableCap.CullFace), + IsEnabled(EnableCap.SampleAlphaToCoverage), + IsEnabled(EnableCap.Multisample), + DepthWrite, + BlendSourceRgb, + BlendDestinationRgb, + BlendSourceAlpha, + BlendDestinationAlpha, + Program, + VertexArray, + ArrayBuffer, + ActiveUnit, + TextureBindings.GetValueOrDefault(TextureUnit.Texture0), + TextureBindings.GetValueOrDefault(TextureUnit.Texture2)); + + public bool IsEnabled(EnableCap capability) => + _capabilities.GetValueOrDefault(capability); + + public int GetInteger(GetPName parameter) => parameter switch + { + GetPName.BlendSrcRgb => (int)BlendSourceRgb, + GetPName.BlendDstRgb => (int)BlendDestinationRgb, + GetPName.BlendSrcAlpha => (int)BlendSourceAlpha, + GetPName.BlendDstAlpha => (int)BlendDestinationAlpha, + GetPName.CurrentProgram => (int)Program, + GetPName.VertexArrayBinding => (int)VertexArray, + GetPName.ArrayBufferBinding => (int)ArrayBuffer, + GetPName.ActiveTexture => (int)ActiveUnit, + GetPName.TextureBinding2D => + (int)TextureBindings.GetValueOrDefault(ActiveUnit), + _ => throw new ArgumentOutOfRangeException(nameof(parameter)), + }; + + public bool GetBoolean(GetPName parameter) => + parameter == GetPName.DepthWritemask + ? DepthWrite + : throw new ArgumentOutOfRangeException(nameof(parameter)); + + public void SetCapability(EnableCap capability, bool enabled) => + _capabilities[capability] = enabled; + + public void DepthMask(bool enabled) => DepthWrite = enabled; + + public void BlendFuncSeparate( + BlendingFactor sourceRgb, + BlendingFactor destinationRgb, + BlendingFactor sourceAlpha, + BlendingFactor destinationAlpha) + { + BlendSourceRgb = sourceRgb; + BlendDestinationRgb = destinationRgb; + BlendSourceAlpha = sourceAlpha; + BlendDestinationAlpha = destinationAlpha; + } + + public void UseProgram(uint program) => Program = program; + + public void BindVertexArray(uint vertexArray) => VertexArray = vertexArray; + + public void BindBuffer(BufferTargetARB target, uint buffer) + { + Assert.Equal(BufferTargetARB.ArrayBuffer, target); + ArrayBuffer = buffer; + } + + public void ActiveTexture(TextureUnit unit) => ActiveUnit = unit; + + public void BindTexture(TextureTarget target, uint texture) + { + Assert.Equal(TextureTarget.Texture2D, target); + TextureBindings[ActiveUnit] = texture; + } + } + + private static void AssertAppearsInOrder(string source, params string[] needles) + { + int cursor = -1; + foreach (string needle in needles) + { + int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal); + Assert.True(next >= 0, $"Missing expected source fragment: {needle}"); + Assert.True(next > cursor, $"Out-of-order source fragment: {needle}"); + cursor = next; + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs index 2af6301d..d81a56f3 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs @@ -325,7 +325,7 @@ public sealed class WorldRenderFrameBuilderTests } [Fact] - public void World_scene_uses_the_typed_builder_and_game_window_releases_it_before_render_owners() + public void World_scene_uses_the_typed_builder_and_orchestrator_owns_its_local_composition() { string gameWindow = GameWindowSource(); string worldScene = File.ReadAllText(Path.Combine( @@ -341,6 +341,14 @@ public sealed class WorldRenderFrameBuilderTests Assert.DoesNotContain("private void UpdateSunFromSky(", gameWindow, StringComparison.Ordinal); Assert.DoesNotContain("private void UpdateSkyPes(", gameWindow, StringComparison.Ordinal); Assert.DoesNotContain("private static float ParseEnvFloat(", gameWindow, StringComparison.Ordinal); + Assert.DoesNotContain( + "private AcDream.App.Rendering.WorldRenderFrameBuilder?", + gameWindow, + StringComparison.Ordinal); + Assert.DoesNotContain( + "private AcDream.App.Rendering.SkyPesFrameController?", + gameWindow, + StringComparison.Ordinal); AssertAppearsInOrder( worldScene, "_alpha.BeginFrame();", @@ -350,9 +358,10 @@ public sealed class WorldRenderFrameBuilderTests "NormalWorldDrawn: true"); AssertAppearsInOrder( gameWindow, - "_worldSceneRenderer = null;", - "_worldRenderFrameBuilder = null;", - "_skyPesFrame = null;", + "var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(", + "var worldRenderFrameBuilder =", + "new AcDream.App.Rendering.RenderFrameOrchestrator(", + "_renderFrameOrchestrator = null;", "new(\"equipped children\"", "new(\"effect network state\"", "new(\"audio\"", diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs index b99d7c1f..de2cd9fc 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs @@ -283,7 +283,8 @@ public sealed class WorldSceneRendererTests "GameWindow.cs")); Assert.Contains("new AcDream.App.Rendering.WorldSceneRenderer(", source); - Assert.Equal(1, CountOccurrences(source, "_worldSceneRenderer!.Render(frameInput);")); + Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", source); + Assert.DoesNotContain("_worldSceneRenderer", source); Assert.DoesNotContain("_retailPViewRenderer.DrawInside(", source); Assert.DoesNotContain("_retailAlphaQueue.BeginFrame();", source); Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source);