refactor(render): compose render frame orchestrator

Move the accepted draw transaction and failure recovery behind typed frame-phase owners so GameWindow only supplies immutable frame input. Preserve retail draw order, make ImGui/bootstrap shutdown ownership explicit, and restore exact text-render GL state on failures.\n\nCo-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 08:03:49 +02:00
parent 28e1cf8029
commit 9d7df1bfc5
25 changed files with 1675 additions and 279 deletions

View file

@ -1,9 +1,9 @@
# acdream — code structure & extraction sequence # acdream — code structure & extraction sequence
**Status:** Living document. Created 2026-05-16; implementation reconciliation **Status:** Living document. Created 2026-05-16; implementation reconciliation
completed 2026-07-21; Slices 16 landed by 2026-07-22. Slice 7 draw-frame completed 2026-07-21; Slices 16 and the Slice 7 draw-frame cutover landed by
orchestration is the active structural program before new M4 subsystems enter 2026-07-22. Slice 7 connected closeout is active before the final Slice 8
the App layer. composition/shutdown cleanup and new M4 subsystems enter the App layer.
**Purpose:** Describe the desired structural state of 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 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 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 4 10,301 lines / 267 fields / 163 methods
after Slice 5 closeout 8,811 lines / 247 fields / 153 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 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: `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 ## 3. Target structure of the App layer
The end state — not what we're shipping in one pass, but the shape 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: 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) [`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 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 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 checkpoints. Visual acceptance compares outdoor, building, dungeon, portal
exit, translucent lifestone/particles, and UI at the same camera positions. 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 #### Slice 8 — composition and shutdown cleanup
Keep GL/window construction in `GameWindow.OnLoad`, but group creation into Keep GL/window construction in `GameWindow.OnLoad`, but group creation into

View file

@ -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-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-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-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 67 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 |
--- ---

View file

@ -28,7 +28,7 @@ port, or resource-lifetime redesign.
`RetailPViewPassExecutor` and data-only inputs. `RetailPViewPassExecutor` and data-only inputs.
- [x] F — extract `WorldSceneRenderer`, including both shared-alpha scopes, - [x] F — extract `WorldSceneRenderer`, including both shared-alpha scopes,
PView/fallback world branches, particles, debug world draw, and completion. 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. handoff, and delete obsolete frame bodies, fields, helpers, and callbacks.
- [ ] H — corrected-diff reviews, full Release suite, connected lifecycle and - [ ] H — corrected-diff reviews, full Release suite, connected lifecycle and
soak gates, framebuffer comparison, documentation, memory, and metrics. soak gates, framebuffer comparison, documentation, memory, and metrics.
@ -231,12 +231,15 @@ contract requires separate approval and evidence.
### 4.2 Borrowed resources and shutdown ### 4.2 Borrowed resources and shutdown
Slice 7 collaborators borrow the already-created GL/render owners. They do not The frame orchestrator and phase owners borrow the already-created GL/render
dispose them. The existing retryable shutdown transaction keeps its proven owners and do not dispose them. The developer-tools backend is itself the
dependency order: wait for submitted work; withdraw UI/render publications; canonical owner of its ImGui bootstrap and disposes that bootstrap when the
release textures and mesh owners; then dispose GL backing stores, with existing retryable shutdown transaction retires the backend; the frame
`GpuFrameFlightController` last among render resources. Slice 8 may group presenter only borrows it. Shutdown keeps its proven dependency order: wait for
construction/disposal after the render cutover is stable. 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, `DisplayFramePacingController` is the focused shared owner for requested VSync,
live display preview, monitor/window callbacks, and the separate 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 `PaperdollFramePresenter`; retained surfaces are narrow and optional, while
live identity and DAT pose application resolve through focused adapters. live identity and DAT pose application resolve through focused adapters.
`DevToolsFramePresenter` owns the ImGui frame, menus, panels, layout, and input `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 `WorldRenderDiagnostics`, `DebugVmRenderFactsPublisher`, and
`RenderFrameDiagnosticsController` own the former render probes, debug facts, `RenderFrameDiagnosticsController` own the former render probes, debug facts,
title/resource cadence, and public snapshot. The production handoff now reports 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; - compose concrete resource, world, private-presentation, and diagnostic owners;
- replace `OnRender` with one `RenderFrameOrchestrator.Render` handoff; - replace `OnRender` with one `RenderFrameOrchestrator.Render` handoff;
- remove obsolete fields/helpers/callbacks and prove the transitive render graph - remove obsolete fields/helpers/callbacks; prove immediate frame owners retain
has no substantial path back to `GameWindow`; no direct `GameWindow`/anonymous-delegate back-reference, and document rather
- preserve the existing shutdown resource list without transferring disposal; 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 - measure line/field/method counts. Ownership decides completion; the expected
signal is roughly 5,0005,500 lines before Slice 8 composition cleanup. signal is roughly 5,0005,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 ### H — release and connected gates
After three clean corrected-diff reviews: After three clean corrected-diff reviews:

View file

@ -20,7 +20,6 @@ internal sealed class FrameScreenshotController
private sealed record CaptureStatus(CaptureState State, string? Error = null); private sealed record CaptureStatus(CaptureState State, string? Error = null);
private readonly Func<(int Width, int Height)> _getSize;
private readonly Func<int, int, byte[]> _readRgba; private readonly Func<int, int, byte[]> _readRgba;
private readonly string _directory; private readonly string _directory;
private readonly Action<string> _log; private readonly Action<string> _log;
@ -30,21 +29,18 @@ internal sealed class FrameScreenshotController
public FrameScreenshotController( public FrameScreenshotController(
GL gl, GL gl,
Func<(int Width, int Height)> getSize,
string directory, string directory,
Action<string>? log = null) Action<string>? log = null)
: this(getSize, (width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log) : this((width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log)
{ {
ArgumentNullException.ThrowIfNull(gl); ArgumentNullException.ThrowIfNull(gl);
} }
internal FrameScreenshotController( internal FrameScreenshotController(
Func<(int Width, int Height)> getSize,
Func<int, int, byte[]> readRgba, Func<int, int, byte[]> readRgba,
string directory, string directory,
Action<string>? log = null) Action<string>? log = null)
{ {
_getSize = getSize ?? throw new ArgumentNullException(nameof(getSize));
_readRgba = readRgba ?? throw new ArgumentNullException(nameof(readRgba)); _readRgba = readRgba ?? throw new ArgumentNullException(nameof(readRgba));
_directory = string.IsNullOrWhiteSpace(directory) _directory = string.IsNullOrWhiteSpace(directory)
? throw new ArgumentException("A screenshot directory is required.", nameof(directory)) ? throw new ArgumentException("A screenshot directory is required.", nameof(directory))
@ -76,7 +72,7 @@ internal sealed class FrameScreenshotController
&& status.State == CaptureState.Complete; && status.State == CaptureState.Complete;
/// <summary>Captures at most one queued image on the current GL thread.</summary> /// <summary>Captures at most one queued image on the current GL thread.</summary>
public bool CapturePending() public bool CapturePending(int width, int height)
{ {
if (_pending.Count == 0) if (_pending.Count == 0)
return false; return false;
@ -84,7 +80,6 @@ internal sealed class FrameScreenshotController
string name = _pending.Dequeue(); string name = _pending.Dequeue();
try try
{ {
(int width, int height) = _getSize();
if (width <= 0 || height <= 0) if (width <= 0 || height <= 0)
throw new InvalidOperationException($"invalid framebuffer size {width}x{height}"); throw new InvalidOperationException($"invalid framebuffer size {width}x{height}");

View file

@ -25,10 +25,19 @@ internal enum DevToolsPanelKind
Settings, Settings,
} }
internal interface IDevToolsFrameLifecycle : IRenderFrameFailureRecovery
{
void BeginFrame(float deltaSeconds);
void Render(double deltaSeconds, int viewportWidth, int viewportHeight);
}
internal interface IDevToolsFrameBackend internal interface IDevToolsFrameBackend
{ {
void BeginFrame(float deltaSeconds); void BeginFrame(float deltaSeconds);
void AbortFrame();
bool BeginMainMenuBar(); bool BeginMainMenuBar();
void EndMainMenuBar(); void EndMainMenuBar();
@ -82,10 +91,11 @@ internal interface IDevToolsPanelSet
} }
/// <summary>Concrete ImGui backend for the developer presentation owner.</summary> /// <summary>Concrete ImGui backend for the developer presentation owner.</summary>
internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend, IDisposable
{ {
private readonly ImGuiBootstrapper _bootstrap; private readonly ImGuiBootstrapper _bootstrap;
private readonly ImGuiPanelHost _panels; private readonly ImGuiPanelHost _panels;
private bool _disposed;
public ImGuiDevToolsFrameBackend( public ImGuiDevToolsFrameBackend(
ImGuiBootstrapper bootstrap, ImGuiBootstrapper bootstrap,
@ -97,6 +107,8 @@ internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend
public void BeginFrame(float deltaSeconds) => _bootstrap.BeginFrame(deltaSeconds); public void BeginFrame(float deltaSeconds) => _bootstrap.BeginFrame(deltaSeconds);
public void AbortFrame() => _bootstrap.AbortFrame();
public bool BeginMainMenuBar() => ImGuiNET.ImGui.BeginMainMenuBar(); public bool BeginMainMenuBar() => ImGuiNET.ImGui.BeginMainMenuBar();
public void EndMainMenuBar() => ImGuiNET.ImGui.EndMainMenuBar(); public void EndMainMenuBar() => ImGuiNET.ImGui.EndMainMenuBar();
@ -130,6 +142,15 @@ internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend
ImGuiNET.ImGui.SetWindowSize(title, size, imguiCondition); ImGuiNET.ImGui.SetWindowSize(title, size, imguiCondition);
} }
public void Dispose()
{
if (_disposed)
return;
_bootstrap.Dispose();
_disposed = true;
}
} }
/// <summary>Mutable late-composition seam for player-mode menu operations.</summary> /// <summary>Mutable late-composition seam for player-mode menu operations.</summary>
@ -229,13 +250,14 @@ internal sealed class DevToolsPanelSet : IDevToolsPanelSet
/// reusable default layout. Retained gameplay UI remains a separate earlier /// reusable default layout. Retained gameplay UI remains a separate earlier
/// presentation phase. /// presentation phase.
/// </summary> /// </summary>
internal sealed class DevToolsFramePresenter internal sealed class DevToolsFramePresenter : IDevToolsFrameLifecycle
{ {
private readonly IDevToolsFrameBackend _backend; private readonly IDevToolsFrameBackend _backend;
private readonly IDevToolsCameraMenuOperations _camera; private readonly IDevToolsCameraMenuOperations _camera;
private readonly IDevToolsCommandBusSource _commands; private readonly IDevToolsCommandBusSource _commands;
private readonly IDevToolsPanelSet _panels; private readonly IDevToolsPanelSet _panels;
private readonly AcDream.App.Diagnostics.FrameProfiler _profiler; private readonly AcDream.App.Diagnostics.FrameProfiler _profiler;
private bool _frameOpen;
public DevToolsFramePresenter( public DevToolsFramePresenter(
IDevToolsFrameBackend backend, IDevToolsFrameBackend backend,
@ -251,15 +273,47 @@ internal sealed class DevToolsFramePresenter
_panels = panels ?? throw new ArgumentNullException(nameof(panels)); _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) 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); var context = new PanelContext((float)deltaSeconds, _commands.Current);
DrawMenu(viewportWidth, viewportHeight); DrawMenu(viewportWidth, viewportHeight);
_backend.RenderPanels(context); _backend.RenderPanels(context);
using var stage = _profiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui); using var stage = _profiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui);
_backend.RenderDrawData(); _backend.RenderDrawData();
_frameOpen = false;
}
public void AbortFrame()
{
if (!_frameOpen)
return;
try
{
_backend.AbortFrame();
}
finally
{
_frameOpen = false;
}
} }
public void ToggleDebugPanel() public void ToggleDebugPanel()

View file

@ -93,23 +93,14 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Rendering.ConsoleRenderFrameDiagnosticLog(); new AcDream.App.Rendering.ConsoleRenderFrameDiagnosticLog();
private readonly AcDream.App.Rendering.DebugVmRenderFactsPublisher private readonly AcDream.App.Rendering.DebugVmRenderFactsPublisher
_debugVmRenderFacts = new(); _debugVmRenderFacts = new();
private AcDream.App.Rendering.WorldRenderDiagnostics? _worldRenderDiagnostics;
private AcDream.App.Rendering.RenderFrameDiagnosticsController? private AcDream.App.Rendering.RenderFrameDiagnosticsController?
_renderFrameDiagnostics; _renderFrameDiagnostics;
private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots; private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots;
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation; private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
private AcDream.App.Rendering.RenderFrameResourceController? private AcDream.App.Rendering.RenderFrameOrchestrator?
_renderFrameResources; _renderFrameOrchestrator;
private AcDream.App.Rendering.RuntimeRenderFrameLivePreparation?
_renderFrameLivePreparation;
private AcDream.App.Rendering.RenderWeatherFrameController?
_renderWeatherFrame;
private AcDream.App.Rendering.WorldRenderFrameBuilder?
_worldRenderFrameBuilder;
private AcDream.App.Rendering.WorldSceneSkyState? _worldSceneSkyState; private AcDream.App.Rendering.WorldSceneSkyState? _worldSceneSkyState;
private AcDream.App.Rendering.WorldSceneRenderer? _worldSceneRenderer;
private AcDream.App.Rendering.SkyPesFrameController? _skyPesFrame;
private ResourceShutdownTransaction? _shutdown; private ResourceShutdownTransaction? _shutdown;
private readonly DisplayFramePacingController _displayFramePacing; 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.EnvCellRenderer? _envCellRenderer;
private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum; private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum;
// R1 (render redesign): the per-cell DrawInside flood and concrete pass // R1 (render redesign): portal-view draw owners are retained transitively
// executor are borrowed by WorldSceneRenderer for each normal-world frame. // by the frame orchestrator rather than duplicated as GameWindow roots.
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
private AcDream.App.Rendering.RetailPViewPassExecutor? _retailPViewPassExecutor;
private AcDream.App.Rendering.RetailPViewCellSource? _retailPViewCells;
private AcDream.App.Rendering.TerrainDrawDiagnosticsController?
_terrainDrawDiagnostics;
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel; private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
@ -746,7 +732,7 @@ public sealed class GameWindow : IDisposable
_gl = GL.GetApi(_window!); _gl = GL.GetApi(_window!);
_gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl); _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), new AcDream.App.Rendering.SilkRenderGlStateReader(_gl),
_renderDiagnosticLog); _renderDiagnosticLog);
_input = _window!.CreateInput(); _input = _window!.CreateInput();
@ -1682,7 +1668,6 @@ public sealed class GameWindow : IDisposable
{ {
_frameScreenshots = new AcDream.App.Diagnostics.FrameScreenshotController( _frameScreenshots = new AcDream.App.Diagnostics.FrameScreenshotController(
_gl!, _gl!,
() => (_window!.Size.X, _window.Size.Y),
System.IO.Path.Combine(artifactDirectory, "screenshots"), System.IO.Path.Combine(artifactDirectory, "screenshots"),
UiProbeLog); UiProbeLog);
_worldLifecycleAutomation = _worldLifecycleAutomation =
@ -2285,8 +2270,6 @@ public sealed class GameWindow : IDisposable
landblockRenderPublisher.PrepareAfterRenderPins); landblockRenderPublisher.PrepareAfterRenderPins);
_clipFrame ??= ClipFrame.NoClip(); _clipFrame ??= ClipFrame.NoClip();
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer();
// T1: invisible portal depth writes (seal/punch) — retail // T1: invisible portal depth writes (seal/punch) — retail
// DrawPortalPolyInternal (Ghidra 0x0059bc90). // DrawPortalPolyInternal (Ghidra 0x0059bc90).
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl); _portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
@ -2782,7 +2765,7 @@ public sealed class GameWindow : IDisposable
_localPlayerMode); _localPlayerMode);
var renderFrameGlState = new AcDream.App.Rendering.RenderFrameGlStateController( var renderFrameGlState = new AcDream.App.Rendering.RenderFrameGlStateController(
new AcDream.App.Rendering.SilkRenderFrameGlStateApi(_gl!)); new AcDream.App.Rendering.SilkRenderFrameGlStateApi(_gl!));
_renderFrameLivePreparation = var renderFrameLivePreparation =
new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation( new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation(
_textureCache, _textureCache,
_wbMeshAdapter, _wbMeshAdapter,
@ -2795,7 +2778,7 @@ public sealed class GameWindow : IDisposable
_particleRenderer, _particleRenderer,
_frameProfiler, _frameProfiler,
_frameDiag); _frameDiag);
_renderFrameResources = var renderFrameResources =
new AcDream.App.Rendering.RenderFrameResourceController( new AcDream.App.Rendering.RenderFrameResourceController(
_gpuFrameFlights!, _gpuFrameFlights!,
new AcDream.App.Rendering.RuntimeRenderFrameBeginResources( new AcDream.App.Rendering.RuntimeRenderFrameBeginResources(
@ -2816,14 +2799,14 @@ public sealed class GameWindow : IDisposable
Weather, Weather,
teleportRenderState, teleportRenderState,
_particleVisibility, _particleVisibility,
_worldRenderDiagnostics!, worldRenderDiagnostics,
renderFrameGlState), renderFrameGlState),
_renderFrameLivePreparation); renderFrameLivePreparation);
_renderWeatherFrame = var renderWeatherFrame =
new AcDream.App.Rendering.RenderWeatherFrameController( new AcDream.App.Rendering.RenderWeatherFrameController(
WorldTime, WorldTime,
Weather); Weather);
_skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController( var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(
_scriptRunner!, _scriptRunner!,
_particleSink!, _particleSink!,
_effectPoses, _effectPoses,
@ -2837,8 +2820,8 @@ public sealed class GameWindow : IDisposable
_envCellRenderer, _envCellRenderer,
_sceneLightingUbo, _sceneLightingUbo,
_renderRange, _renderRange,
_skyPesFrame); skyPesFrame);
_worldRenderFrameBuilder = var worldRenderFrameBuilder =
new AcDream.App.Rendering.WorldRenderFrameBuilder( new AcDream.App.Rendering.WorldRenderFrameBuilder(
new AcDream.App.Rendering.RuntimeWorldFrameCameraSource( new AcDream.App.Rendering.RuntimeWorldFrameCameraSource(
_cameraController!, _cameraController!,
@ -2869,22 +2852,22 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource( new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource(
_landblockPresentationPipeline!, _landblockPresentationPipeline!,
_cellVisibility)); _cellVisibility));
_terrainDrawDiagnostics = var terrainDrawDiagnostics =
new AcDream.App.Rendering.TerrainDrawDiagnosticsController( new AcDream.App.Rendering.TerrainDrawDiagnosticsController(
_frameDiag, _frameDiag,
_worldRenderDiagnostics!, worldRenderDiagnostics,
new AcDream.App.Rendering.RuntimeFramePipelineDiagnosticFactsSource( new AcDream.App.Rendering.RuntimeFramePipelineDiagnosticFactsSource(
_terrain, _terrain,
_landblockPresentationPipeline, _landblockPresentationPipeline,
_renderFrameLivePreparation, renderFrameLivePreparation,
_wbDrawDispatcher, _wbDrawDispatcher,
_streamingController, _streamingController,
_liveEntities!, _liveEntities!,
_worldState), _worldState),
_renderDiagnosticLog); _renderDiagnosticLog);
_retailPViewCells = new AcDream.App.Rendering.RetailPViewCellSource( var retailPViewCells = new AcDream.App.Rendering.RetailPViewCellSource(
_cellVisibility); _cellVisibility);
_retailPViewPassExecutor = var retailPViewPassExecutor =
new AcDream.App.Rendering.RetailPViewPassExecutor( new AcDream.App.Rendering.RetailPViewPassExecutor(
_gl!, _gl!,
renderFrameGlState, renderFrameGlState,
@ -2899,11 +2882,11 @@ public sealed class GameWindow : IDisposable
_particleRenderer, _particleRenderer,
_portalDepthMask, _portalDepthMask,
_retailAlphaQueue, _retailAlphaQueue,
_worldRenderDiagnostics!, worldRenderDiagnostics,
_terrainDrawDiagnostics); terrainDrawDiagnostics);
var worldSceneDiagnostics = var worldSceneDiagnostics =
new AcDream.App.Rendering.WorldSceneDiagnosticsController( new AcDream.App.Rendering.WorldSceneDiagnosticsController(
_worldRenderDiagnostics!, worldRenderDiagnostics,
new AcDream.App.Rendering.RuntimeWorldScenePViewDiagnosticSource( new AcDream.App.Rendering.RuntimeWorldScenePViewDiagnosticSource(
_playerControllerSlot, _playerControllerSlot,
_physicsEngine, _physicsEngine,
@ -2922,27 +2905,63 @@ public sealed class GameWindow : IDisposable
_wbDrawDispatcher!, _wbDrawDispatcher!,
_envCellRenderer!, _envCellRenderer!,
_terrain, _terrain,
_terrainDrawDiagnostics, terrainDrawDiagnostics,
_skyRenderer, _skyRenderer,
_particleSystem, _particleSystem,
_particleRenderer); _particleRenderer);
_worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer( var worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer(
_renderFrameResources, renderFrameResources,
renderLoginState, renderLoginState,
_worldSceneSkyState!, _worldSceneSkyState!,
_worldRenderFrameBuilder, worldRenderFrameBuilder,
new AcDream.App.Rendering.RuntimeWorldSceneEntitySource(_worldState), new AcDream.App.Rendering.RuntimeWorldSceneEntitySource(_worldState),
_retailSelectionScene, _retailSelectionScene,
_retailAlphaQueue, _retailAlphaQueue,
_particleVisibility, _particleVisibility,
new AcDream.App.Rendering.WorldScenePViewRenderer( new AcDream.App.Rendering.WorldScenePViewRenderer(
_retailPViewRenderer!, new AcDream.App.Rendering.RetailPViewRenderer(),
_retailPViewPassExecutor, retailPViewPassExecutor,
_retailPViewPassExecutor), retailPViewPassExecutor),
_retailPViewCells, retailPViewCells,
worldScenePasses, worldScenePasses,
_renderRange, _renderRange,
worldSceneDiagnostics); 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( var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame, liveObjectFrame,
_worldState, _worldState,
@ -3415,126 +3434,12 @@ public sealed class GameWindow : IDisposable
// window title so there's zero rendering cost (no font/overlay needed). // window title so there's zero rendering cost (no font/overlay needed).
private void OnRender(double deltaSeconds) private void OnRender(double deltaSeconds)
{ {
_gpuFrameFlights!.BeginFrame(); Vector2D<int> size = _window!.Size;
Exception? renderFailure = null; _renderFrameOrchestrator!.Render(
try new AcDream.App.Rendering.RenderFrameInput(
{
var frameInput = new AcDream.App.Rendering.RenderFrameInput(
deltaSeconds, deltaSeconds,
_window!.Size.X, size.X,
_window.Size.Y); 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);
}
}
} }
private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot
@ -4563,14 +4468,8 @@ public sealed class GameWindow : IDisposable
[ [
new("world frame composition", () => new("world frame composition", () =>
{ {
_worldSceneRenderer = null; _renderFrameOrchestrator = null;
_worldRenderFrameBuilder = null;
_worldSceneSkyState = null; _worldSceneSkyState = null;
_skyPesFrame = null;
_retailPViewPassExecutor = null;
_retailPViewCells = null;
_terrainDrawDiagnostics = null;
_retailPViewRenderer = null;
}), }),
]), ]),
new ResourceShutdownStage("session dependents", new ResourceShutdownStage("session dependents",
@ -4643,11 +4542,13 @@ public sealed class GameWindow : IDisposable
]), ]),
new ResourceShutdownStage("render frontends", new ResourceShutdownStage("render frontends",
[ [
new("frame composition", () => new("developer tools", () =>
{ {
_renderFrameResources = null; _devToolsFramePresenter = null;
_renderFrameLivePreparation = null; _devToolsCameraMenu = null;
_renderWeatherFrame = null; _devToolsCommandBus = null;
_devToolsBackend?.Dispose();
_devToolsBackend = null;
}), }),
new("portal tunnel", () => new("portal tunnel", () =>
{ {

View file

@ -48,7 +48,7 @@ internal interface IPaperdollPoseApplicator
/// The GL renderer remains a borrowed resource disposed by the existing window /// The GL renderer remains a borrowed resource disposed by the existing window
/// shutdown transaction. /// shutdown transaction.
/// </summary> /// </summary>
internal sealed class PaperdollFramePresenter internal sealed class PaperdollFramePresenter : IPrivatePaperdollFrame
{ {
private readonly IPaperdollDollRenderer _renderer; private readonly IPaperdollDollRenderer _renderer;
private readonly IPaperdollFrameView _view; private readonly IPaperdollFrameView _view;

View file

@ -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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>Typed portal viewport adapter over the canonical teleport owner.</summary>
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);
}
/// <summary>
/// One retained-gameplay-UI tick/cursor/draw transaction. This is the explicit
/// boundary where the render graph reaches the retained UI composition owner;
/// <see cref="RetailUiRuntime"/> 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.
/// </summary>
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));
}
}
/// <summary>Optional complete-frame capture adapter.</summary>
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);
}

View file

@ -60,6 +60,29 @@ internal interface IRenderFrameDiagnosticsPhase
void Publish(RenderFrameInput input, RenderFrameOutcome outcome); void Publish(RenderFrameInput input, RenderFrameOutcome outcome);
} }
/// <summary>
/// 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.
/// </summary>
internal interface IRenderFrameFailureRecovery
{
void AbortFrame();
}
internal sealed class NullRenderFrameFailureRecovery : IRenderFrameFailureRecovery
{
public static NullRenderFrameFailureRecovery Instance { get; } = new();
private NullRenderFrameFailureRecovery()
{
}
public void AbortFrame()
{
}
}
/// <summary> /// <summary>
/// Owns the accepted outer render transaction. Focused phase owners retain the /// Owns the accepted outer render transaction. Focused phase owners retain the
/// detailed PView, private-viewport, UI, and diagnostic order inside their /// detailed PView, private-viewport, UI, and diagnostic order inside their
@ -79,19 +102,22 @@ internal sealed class RenderFrameOrchestrator
private readonly IWorldSceneFramePhase _world; private readonly IWorldSceneFramePhase _world;
private readonly IPrivatePresentationFramePhase _presentation; private readonly IPrivatePresentationFramePhase _presentation;
private readonly IRenderFrameDiagnosticsPhase _diagnostics; private readonly IRenderFrameDiagnosticsPhase _diagnostics;
private readonly IRenderFrameFailureRecovery _recovery;
public RenderFrameOrchestrator( public RenderFrameOrchestrator(
IRenderFrameLifetime lifetime, IRenderFrameLifetime lifetime,
IRenderFrameResourcePhase resources, IRenderFrameResourcePhase resources,
IWorldSceneFramePhase world, IWorldSceneFramePhase world,
IPrivatePresentationFramePhase presentation, IPrivatePresentationFramePhase presentation,
IRenderFrameDiagnosticsPhase diagnostics) IRenderFrameDiagnosticsPhase diagnostics,
IRenderFrameFailureRecovery recovery)
{ {
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
_resources = resources ?? throw new ArgumentNullException(nameof(resources)); _resources = resources ?? throw new ArgumentNullException(nameof(resources));
_world = world ?? throw new ArgumentNullException(nameof(world)); _world = world ?? throw new ArgumentNullException(nameof(world));
_presentation = presentation ?? throw new ArgumentNullException(nameof(presentation)); _presentation = presentation ?? throw new ArgumentNullException(nameof(presentation));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_recovery = recovery ?? throw new ArgumentNullException(nameof(recovery));
} }
public RenderFrameOutcome Render(RenderFrameInput input) public RenderFrameOutcome Render(RenderFrameInput input)
@ -115,17 +141,50 @@ internal sealed class RenderFrameOrchestrator
} }
finally 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 try
{ {
_lifetime.EndFrame(); _lifetime.EndFrame();
} }
catch (Exception closeFailure) when (renderFailure is not null) 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( throw new AggregateException(
"Rendering failed and the in-flight GPU frame could not be closed.", "Rendering failed and the in-flight GPU frame could not be closed.",
renderFailure, renderFailure,
closeFailure); 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);
}
} }
} }
} }

View file

@ -0,0 +1,35 @@
namespace AcDream.App.Rendering;
internal interface IRenderWeatherFramePhase
{
void Tick(double deltaSeconds);
}
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -350,7 +350,7 @@ internal sealed class RuntimeRenderFrameLivePreparation
} }
/// <summary>Owns the render-time weather clock and deterministic day index.</summary> /// <summary>Owns the render-time weather clock and deterministic day index.</summary>
internal sealed class RenderWeatherFrameController internal sealed class RenderWeatherFrameController : IRenderWeatherFramePhase
{ {
private readonly WorldTimeService _worldTime; private readonly WorldTimeService _worldTime;
private readonly WeatherSystem _weather; private readonly WeatherSystem _weather;

View file

@ -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);
}
/// <summary>
/// Exact, focused state transaction for <see cref="TextRenderer.Flush"/>. It
/// captures every GL value that Flush or DrawLayer mutates, while avoiding the
/// dozens of unrelated synchronous reads made by the broad diagnostic scope.
/// </summary>
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);
}
}

View file

@ -24,6 +24,7 @@ public sealed unsafe class TextRenderer : IDisposable
private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4) private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4)
private readonly GL _gl; private readonly GL _gl;
private readonly ITextRenderGlStateApi _glState;
private readonly Shader _shader; private readonly Shader _shader;
private uint _vao; private uint _vao;
private uint _vbo; 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 // at its FIRST-insertion point, so later bar sprites covered glyphs emitted
// earlier via the shared dat-font atlas — the stamina/mana numbers vanished.) // earlier via the shared dat-font atlas — the stamina/mana numbers vanished.)
private sealed class SpriteSeg { public uint Texture; public readonly List<float> Verts = new(256); } private sealed class SpriteSeg { public uint Texture; public readonly List<float> Verts = new(256); }
private readonly List<SpriteSeg> _spriteSegs = new(); private readonly List<SpriteSeg> _spriteSegs = new();
private int _segUsed; private int _segUsed;
private int _textVerts; private int _textVerts;
@ -77,6 +79,7 @@ public sealed unsafe class TextRenderer : IDisposable
public TextRenderer(GL gl, string shaderDir) public TextRenderer(GL gl, string shaderDir)
{ {
_gl = gl; _gl = gl;
_glState = new SilkTextRenderGlStateApi(gl);
_shader = new Shader(gl, _shader = new Shader(gl,
Path.Combine(shaderDir, "ui_text.vert"), Path.Combine(shaderDir, "ui_text.vert"),
Path.Combine(shaderDir, "ui_text.frag")); Path.Combine(shaderDir, "ui_text.frag"));
@ -350,23 +353,24 @@ public sealed unsafe class TextRenderer : IDisposable
bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0; bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0;
if (!anyNormal && !anyOverlay) return; 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.Use();
_shader.SetVec2("uScreenSize", _screenSize); _shader.SetVec2("uScreenSize", _screenSize);
_gl.BindVertexArray(_vao); _gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
// Save GL state. // Establish the self-contained UI pass state.
bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest);
bool wasBlend = _gl.IsEnabled(EnableCap.Blend);
bool wasCull = _gl.IsEnabled(EnableCap.CullFace);
// The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher, // The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher,
// QualitySettings MSAA). If they bleed into the UI pass, each glyph's soft alpha // 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 — // 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 // the "text not sharp / fuzzy" artifact. The UI composites with straight alpha
// blending and must own this state (feedback_render_self_contained_gl_state). // 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.SampleAlphaToCoverage);
_gl.Disable(EnableCap.Multisample); _gl.Disable(EnableCap.Multisample);
_gl.Disable(EnableCap.DepthTest); _gl.Disable(EnableCap.DepthTest);
@ -386,15 +390,6 @@ public sealed unsafe class TextRenderer : IDisposable
DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font); DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font);
DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, 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);
} }
/// <summary>Draw one compositing layer: sprites (submission order, one call per /// <summary>Draw one compositing layer: sprites (submission order, one call per

View file

@ -60,5 +60,15 @@ public sealed class ImGuiBootstrapper : IDisposable
/// </summary> /// </summary>
public void Render() => _controller.Render(); public void Render() => _controller.Render();
/// <summary>
/// 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 <c>ImGui.EndFrame</c> here would leave the controller's state
/// open and poison its next <see cref="BeginFrame"/> call.
/// </summary>
public void AbortFrame() => _controller.Render();
public void Dispose() => _controller.Dispose(); public void Dispose() => _controller.Dispose();
} }

View file

@ -19,7 +19,6 @@ public sealed class WorldLifecycleAutomationControllerTests
]; ];
var logs = new List<string>(); var logs = new List<string>();
var controller = new FrameScreenshotController( var controller = new FrameScreenshotController(
() => (2, 2),
(_, _) => rgba, (_, _) => rgba,
directory, directory,
logs.Add); logs.Add);
@ -29,7 +28,7 @@ public sealed class WorldLifecycleAutomationControllerTests
Assert.True(controller.TryRequest("login_stable", out string error), error); Assert.True(controller.TryRequest("login_stable", out string error), error);
Assert.False(controller.IsComplete("login_stable")); Assert.False(controller.IsComplete("login_stable"));
Assert.True(controller.CapturePending()); Assert.True(controller.CapturePending(2, 2));
Assert.True(controller.IsComplete("login_stable")); Assert.True(controller.IsComplete("login_stable"));
string path = Path.Combine(directory, "login_stable.png"); string path = Path.Combine(directory, "login_stable.png");
@ -53,15 +52,14 @@ public sealed class WorldLifecycleAutomationControllerTests
{ {
string directory = NewDirectory(); string directory = NewDirectory();
var controller = new FrameScreenshotController( var controller = new FrameScreenshotController(
() => (0, 0),
(_, _) => [], (_, _) => [],
directory); directory);
try try
{ {
Assert.False(controller.CapturePending()); Assert.False(controller.CapturePending(0, 0));
Assert.True(controller.TryRequest("bad_frame", out string error), error); 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")); Assert.False(controller.IsComplete("bad_frame"));
} }
finally finally
@ -109,7 +107,6 @@ public sealed class WorldLifecycleAutomationControllerTests
TrackedGpuBytes = 1234, TrackedGpuBytes = 1234,
}; };
var screenshots = new FrameScreenshotController( var screenshots = new FrameScreenshotController(
() => (1, 1),
(_, _) => [0, 0, 0, 255], (_, _) => [0, 0, 0, 255],
Path.Combine(directory, "screenshots")); Path.Combine(directory, "screenshots"));
var controller = new WorldLifecycleAutomationController( var controller = new WorldLifecycleAutomationController(

View file

@ -42,8 +42,10 @@ public sealed class DevToolsFramePresenterTests
var commands = new RecordingCommandSource { Current = first }; var commands = new RecordingCommandSource { Current = first };
var presenter = Create(backend, commands: commands); var presenter = Create(backend, commands: commands);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 800, 600); presenter.Render(0.1, 800, 600);
commands.Current = second; commands.Current = second;
presenter.BeginFrame(0.2f);
presenter.Render(0.2, 800, 600); presenter.Render(0.2, 800, 600);
Assert.Same(first, backend.Contexts[0].Commands); Assert.Same(first, backend.Contexts[0].Commands);
@ -60,6 +62,7 @@ public sealed class DevToolsFramePresenterTests
var panels = new RecordingPanels(); var panels = new RecordingPanels();
var presenter = Create(backend, panels: panels); var presenter = Create(backend, panels: panels);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 1920, 1080); presenter.Render(0.1, 1920, 1080);
Assert.Equal( Assert.Equal(
@ -93,6 +96,7 @@ public sealed class DevToolsFramePresenterTests
var camera = new RecordingCamera { IsFlyMode = true, CollideCamera = true }; var camera = new RecordingCamera { IsFlyMode = true, CollideCamera = true };
var presenter = Create(backend, camera: camera); var presenter = Create(backend, camera: camera);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 1280, 720); presenter.Render(0.1, 1280, 720);
Assert.Equal(1, camera.ToggleCount); Assert.Equal(1, camera.ToggleCount);
@ -113,6 +117,7 @@ public sealed class DevToolsFramePresenterTests
presenter.ToggleSettingsPanel(); presenter.ToggleSettingsPanel();
presenter.ResetLayout(1280, 720, DevToolsPanelLayoutCondition.FirstUseEver); presenter.ResetLayout(1280, 720, DevToolsPanelLayoutCondition.FirstUseEver);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 1280, 720); presenter.Render(0.1, 1280, 720);
Assert.DoesNotContain(DevToolsPanelKind.Settings, panels.Toggles); Assert.DoesNotContain(DevToolsPanelKind.Settings, panels.Toggles);
@ -156,6 +161,68 @@ public sealed class DevToolsFramePresenterTests
call.WithoutCondition())); call.WithoutCondition()));
} }
[Fact]
public void AbortFrame_ClosesAnOpenBackendFrameAndAllowsTheNextFrame()
{
var calls = new List<string>();
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<string>();
var backend = new RecordingBackend(calls)
{
DrawFailure = new InvalidOperationException("draw"),
};
var presenter = Create(backend);
presenter.BeginFrame(0.1f);
Assert.Throws<InvalidOperationException>(() =>
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<string>();
var backend = new RecordingBackend(calls)
{
BeginFailure = new InvalidOperationException("begin"),
};
var presenter = Create(backend);
Assert.Throws<InvalidOperationException>(() => 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] [Fact]
public void InputFacingActionsDelegateToTheTypedPanelOwner() public void InputFacingActionsDelegateToTheTypedPanelOwner()
{ {
@ -198,7 +265,7 @@ public sealed class DevToolsFramePresenterTests
field => field.FieldType == typeof(Silk.NET.Windowing.IWindow)); field => field.FieldType == typeof(Silk.NET.Windowing.IWindow));
} }
Assert.False(typeof(IDisposable).IsAssignableFrom( Assert.True(typeof(IDisposable).IsAssignableFrom(
typeof(ImGuiDevToolsFrameBackend))); typeof(ImGuiDevToolsFrameBackend)));
} }
@ -221,8 +288,29 @@ public sealed class DevToolsFramePresenterTests
public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = []; public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = [];
public List<PanelContext> Contexts { get; } = []; public List<PanelContext> Contexts { get; } = [];
public List<LayoutCallWithCondition> Layouts { get; } = []; public List<LayoutCallWithCondition> 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() public bool BeginMainMenuBar()
{ {
@ -254,7 +342,14 @@ public sealed class DevToolsFramePresenterTests
calls.Add($"panels:{context.DeltaSeconds}"); 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( public void SetWindowLayout(
string title, string title,

View file

@ -7,32 +7,33 @@ public sealed class GameWindowRenderLeafCompositionTests
[Fact] [Fact]
public void ProductionRender_PreservesPrivatePresentationAndCaptureOrder() public void ProductionRender_PreservesPrivatePresentationAndCaptureOrder()
{ {
string source = GameWindowSource(); string presentation = Source("PrivatePresentationRenderer.cs");
string orchestrator = Source("RenderFrameOrchestrator.cs");
AssertAppearsInOrder( AssertAppearsInOrder(
source, presentation,
"_localPlayerTeleport?.DrawPortalViewport(", "_portal.Draw(",
"_paperdollFramePresenter?.Render();", "_paperdoll?.Render();",
"_retailUiRuntime.Tick(deltaSeconds);", "_gameplayUi?.Render(",
"_devToolsFramePresenter?.Render(", "_devTools?.Render(",
"_frameScreenshots?.CapturePending() == true;", "_screenshots?.CapturePending(");
"_renderFrameDiagnostics?.Publish("); AssertAppearsInOrder(
orchestrator,
"_world.Render(input);",
"_presentation.Render(input, world);",
"_diagnostics.Publish(input, outcome);");
} }
[Fact] [Fact]
public void ProductionRender_Prepares_resources_then_devtools_weather_and_world() public void ProductionRender_Prepares_resources_then_devtools_weather_and_world()
{ {
string source = GameWindowSource(); string source = Source("RenderFramePreparationController.cs");
AssertAppearsInOrder( AssertAppearsInOrder(
source, source,
"_renderFrameResources!.Prepare(frameInput);", "_resources.Prepare(input);",
"_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);", "_devTools?.BeginFrame((float)input.DeltaSeconds);",
"_renderWeatherFrame!.Tick(deltaSeconds);", "_weather.Tick(input.DeltaSeconds);");
"_worldSceneRenderer!.Render(frameInput);");
Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source);
Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source);
Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source);
} }
[Fact] [Fact]
@ -46,7 +47,9 @@ public sealed class GameWindowRenderLeafCompositionTests
"_portalTunnel = null;", "_portalTunnel = null;",
"_localPlayerTeleportSink.Bind(_localPlayerTeleport);", "_localPlayerTeleportSink.Bind(_localPlayerTeleport);",
"new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(", "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] [Fact]
@ -78,6 +81,13 @@ public sealed class GameWindowRenderLeafCompositionTests
"ApplyFramePacingPreference", "ApplyFramePacingPreference",
"RefreshActiveMonitorFramePacing", "RefreshActiveMonitorFramePacing",
"private void OnFrameRendered(", "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) foreach (string identifier in removed)
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal); 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.DevToolsFramePresenter(", source);
Assert.Contains("new AcDream.App.Rendering.RenderFrameResourceController(", source); Assert.Contains("new AcDream.App.Rendering.RenderFrameResourceController(", source);
Assert.Contains("new AcDream.App.Rendering.RenderWeatherFrameController(", 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("new DisplayFramePacingController(", source);
Assert.Contains("_inputCapture.WantCaptureMouse", source); Assert.Contains("_inputCapture.WantCaptureMouse", source);
} }
@ -99,40 +111,70 @@ public sealed class GameWindowRenderLeafCompositionTests
source, source,
"new ResourceShutdownStage(\"submitted GPU work\"", "new ResourceShutdownStage(\"submitted GPU work\"",
"new ResourceShutdownStage(\"render frontends\"", "new ResourceShutdownStage(\"render frontends\"",
"new(\"frame composition\"", "new(\"developer tools\"",
"_devToolsBackend?.Dispose()",
"new(\"portal tunnel\"", "new(\"portal tunnel\"",
"new(\"paperdoll viewport\"", "new(\"paperdoll viewport\"",
"new ResourceShutdownStage(\"OpenGL context\""); "new ResourceShutdownStage(\"OpenGL context\"");
Assert.Contains("_renderFrameResources = null;", source);
Assert.Contains("_renderFrameLivePreparation = null;", source);
Assert.Contains("_renderWeatherFrame = null;", source);
AssertAppearsInOrder( AssertAppearsInOrder(
source, source,
"new ResourceShutdownStage(\"frame borrowers\"", "new ResourceShutdownStage(\"frame borrowers\"",
"_retailPViewPassExecutor = null;", "_renderFrameOrchestrator = null;",
"_retailPViewCells = null;",
"_terrainDrawDiagnostics = null;",
"_retailPViewRenderer = null;",
"new ResourceShutdownStage(\"session dependents\""); "new ResourceShutdownStage(\"session dependents\"");
AssertAppearsInOrder( AssertAppearsInOrder(
source, source,
"new(\"frame pacing\", _displayFramePacing.Dispose)", "new(\"frame pacing\", _displayFramePacing.Dispose)",
"new(\"frame profiler\", _frameProfiler.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] [Fact]
public void ProductionOutcome_UsesObservedWorldAndScreenshotFacts() public void ProductionOutcome_UsesObservedWorldAndScreenshotFacts()
{ {
string source = GameWindowSource(); string presentation = Source("PrivatePresentationRenderer.cs");
string orchestrator = Source("RenderFrameOrchestrator.cs");
AssertAppearsInOrder( AssertAppearsInOrder(
source, presentation,
"_worldSceneRenderer!.Render(frameInput);", "_foundation.Foundation.PortalViewportVisible;",
"worldOutcome.NormalWorldDrawn;", "_portal.Draw(",
"bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;", "bool screenshotCaptured = _screenshots?.CapturePending(",
"normalWorldDrawn),", "portalViewportVisible,",
"screenshotCaptured)));"); "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] [Fact]
@ -168,6 +210,25 @@ public sealed class GameWindowRenderLeafCompositionTests
"Rendering", "Rendering",
"GameWindow.cs")); "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) private static void AssertAppearsInOrder(string source, params string[] needles)
{ {
int cursor = -1; int cursor = -1;

View file

@ -93,7 +93,7 @@ public sealed class PaperdollFramePresenterTests
} }
[Fact] [Fact]
public void PresenterAndProductionHelpersHaveNoWindowOrDelegateBackReference() public void PresenterAndProductionHelpersHaveNoDirectWindowOrDelegateBackReference()
{ {
Type[] owners = Type[] owners =
[ [

View file

@ -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<string> 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<string> 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<string> 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<ArgumentNullException>(() =>
new PrivatePresentationRenderer(null!, new FoundationSource(false), null, null, null, null));
Assert.Throws<ArgumentNullException>(() =>
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<string> 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<string> calls, Action mutate) :
IPrivatePortalViewport
{
public void Draw(int width, int height)
{
calls.Add("portal");
mutate();
}
}
private sealed class Paperdoll(List<string> calls) : IPrivatePaperdollFrame
{
public void Render() => calls.Add("paperdoll");
}
private sealed class GameplayUi(List<string> 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<string> 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<string> 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;
}
}
}

View file

@ -148,21 +148,77 @@ public sealed class RenderFrameOrchestratorTests
phases.ObservedInputs); phases.ObservedInputs);
} }
[Fact]
public void RenderAndRecoveryFailure_AreAggregatedBeforeGpuClose()
{
var calls = new List<string>();
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<AggregateException>(
() => 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<string>();
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<AggregateException>(
() => 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] [Fact]
public void Constructor_RejectsEveryMissingRequiredOwner() public void Constructor_RejectsEveryMissingRequiredOwner()
{ {
var phases = new RecordingPhases([]); var phases = new RecordingPhases([]);
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator( Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
null!, phases, phases, phases, phases)); null!, phases, phases, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator( Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, null!, phases, phases, phases)); phases, null!, phases, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator( Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, null!, phases, phases)); phases, phases, null!, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator( Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, null!, phases)); phases, phases, phases, null!, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator( Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, phases, null!)); phases, phases, phases, phases, null!, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, phases, phases, null!));
} }
[Fact] [Fact]
@ -175,6 +231,7 @@ public sealed class RenderFrameOrchestratorTests
typeof(IWorldSceneFramePhase), typeof(IWorldSceneFramePhase),
typeof(IPrivatePresentationFramePhase), typeof(IPrivatePresentationFramePhase),
typeof(IRenderFrameDiagnosticsPhase), typeof(IRenderFrameDiagnosticsPhase),
typeof(IRenderFrameFailureRecovery),
]; ];
FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields( FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic); BindingFlags.Instance | BindingFlags.NonPublic);
@ -239,14 +296,17 @@ public sealed class RenderFrameOrchestratorTests
private static string[] ExpectedFailureCalls(string failurePoint) => failurePoint switch private static string[] ExpectedFailureCalls(string failurePoint) => failurePoint switch
{ {
"resources" => ["gpu-begin", "resources", "gpu-end"], "resources" => ["gpu-begin", "resources", "recovery-abort", "gpu-end"],
"world" => ["gpu-begin", "resources", "world", "gpu-end"], "world" => ["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"],
"presentation" => "presentation" =>
["gpu-begin", "resources", "world", "presentation", "gpu-end"], [
"gpu-begin", "resources", "world", "presentation",
"recovery-abort", "gpu-end",
],
"diagnostics" => "diagnostics" =>
[ [
"gpu-begin", "resources", "world", "presentation", "diagnostics", "gpu-begin", "resources", "world", "presentation", "diagnostics",
"gpu-end", "recovery-abort", "gpu-end",
], ],
_ => throw new ArgumentOutOfRangeException(nameof(failurePoint)), _ => throw new ArgumentOutOfRangeException(nameof(failurePoint)),
}; };
@ -266,14 +326,15 @@ public sealed class RenderFrameOrchestratorTests
} }
private static RenderFrameOrchestrator Create(RecordingPhases phases) => private static RenderFrameOrchestrator Create(RecordingPhases phases) =>
new(phases, phases, phases, phases, phases); new(phases, phases, phases, phases, phases, phases);
private sealed class RecordingPhases : private sealed class RecordingPhases :
IRenderFrameLifetime, IRenderFrameLifetime,
IRenderFrameResourcePhase, IRenderFrameResourcePhase,
IWorldSceneFramePhase, IWorldSceneFramePhase,
IPrivatePresentationFramePhase, IPrivatePresentationFramePhase,
IRenderFrameDiagnosticsPhase IRenderFrameDiagnosticsPhase,
IRenderFrameFailureRecovery
{ {
private readonly List<string> _calls; private readonly List<string> _calls;
@ -285,6 +346,7 @@ public sealed class RenderFrameOrchestratorTests
public string? FailurePoint { get; init; } public string? FailurePoint { get; init; }
public Exception? Failure { get; init; } public Exception? Failure { get; init; }
public Exception? CloseFailure { get; init; } public Exception? CloseFailure { get; init; }
public Exception? RecoveryFailure { get; init; }
public WorldRenderFrameOutcome World { get; init; } = new(7, 19, true); public WorldRenderFrameOutcome World { get; init; } = new(7, 19, true);
public PrivatePresentationFrameOutcome Presentation { get; init; } = new(false, false); public PrivatePresentationFrameOutcome Presentation { get; init; } = new(false, false);
public List<(string Phase, RenderFrameInput Input)> ObservedInputs { get; } = []; public List<(string Phase, RenderFrameInput Input)> ObservedInputs { get; } = [];
@ -300,6 +362,13 @@ public sealed class RenderFrameOrchestratorTests
throw CloseFailure; throw CloseFailure;
} }
public void AbortFrame()
{
_calls.Add("recovery-abort");
if (RecoveryFailure is not null)
throw RecoveryFailure;
}
public void Prepare(RenderFrameInput input) public void Prepare(RenderFrameInput input)
{ {
ObservedInputs.Add(("resources", input)); ObservedInputs.Add(("resources", input));

View file

@ -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<string> 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<string> 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<ArgumentNullException>(() =>
new RenderFramePreparationController(null!, null, weather));
Assert.Throws<ArgumentNullException>(() =>
new RenderFramePreparationController(resources, null, null!));
}
private sealed class Resources(List<string> calls) : IRenderFrameResourcePhase
{
public RenderFrameInput Input { get; private set; }
public void Prepare(RenderFrameInput input)
{
Input = input;
calls.Add("resources");
}
}
private sealed class DevTools(List<string> 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<string> calls) : IRenderWeatherFramePhase
{
public double DeltaSeconds { get; private set; }
public void Tick(double deltaSeconds)
{
DeltaSeconds = deltaSeconds;
calls.Add("weather");
}
}
}

View file

@ -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<InvalidOperationException>(() => 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)
{
}
}
}

View file

@ -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<InvalidOperationException>(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<EnableCap, bool> _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<TextureUnit, uint> 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.");
}
}

View file

@ -325,7 +325,7 @@ public sealed class WorldRenderFrameBuilderTests
} }
[Fact] [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 gameWindow = GameWindowSource();
string worldScene = File.ReadAllText(Path.Combine( 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 UpdateSunFromSky(", gameWindow, StringComparison.Ordinal);
Assert.DoesNotContain("private void UpdateSkyPes(", gameWindow, StringComparison.Ordinal); Assert.DoesNotContain("private void UpdateSkyPes(", gameWindow, StringComparison.Ordinal);
Assert.DoesNotContain("private static float ParseEnvFloat(", 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( AssertAppearsInOrder(
worldScene, worldScene,
"_alpha.BeginFrame();", "_alpha.BeginFrame();",
@ -350,9 +358,10 @@ public sealed class WorldRenderFrameBuilderTests
"NormalWorldDrawn: true"); "NormalWorldDrawn: true");
AssertAppearsInOrder( AssertAppearsInOrder(
gameWindow, gameWindow,
"_worldSceneRenderer = null;", "var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(",
"_worldRenderFrameBuilder = null;", "var worldRenderFrameBuilder =",
"_skyPesFrame = null;", "new AcDream.App.Rendering.RenderFrameOrchestrator(",
"_renderFrameOrchestrator = null;",
"new(\"equipped children\"", "new(\"equipped children\"",
"new(\"effect network state\"", "new(\"effect network state\"",
"new(\"audio\"", "new(\"audio\"",

View file

@ -283,7 +283,8 @@ public sealed class WorldSceneRendererTests
"GameWindow.cs")); "GameWindow.cs"));
Assert.Contains("new AcDream.App.Rendering.WorldSceneRenderer(", source); 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("_retailPViewRenderer.DrawInside(", source);
Assert.DoesNotContain("_retailAlphaQueue.BeginFrame();", source); Assert.DoesNotContain("_retailAlphaQueue.BeginFrame();", source);
Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source); Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source);