From c87b15303d9930a7361f5d93a1d30f998310bcdc Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 14:42:14 +0200 Subject: [PATCH] refactor(lifetime): own render resource prefixes Give terrain, sky, retained UI, portal preparation, and the update/render frame pair explicit single owners. Make shader, texture, text, bindless, and GL construction prefixes checked and retryable so partial failure cannot lose or replay resource ownership. Co-authored-by: Codex --- AGENTS.md | 14 +- CLAUDE.md | 14 +- docs/architecture/acdream-architecture.md | 11 +- docs/architecture/code-structure.md | 16 +- docs/plans/2026-04-11-roadmap.md | 17 +- docs/plans/2026-05-12-milestones.md | 13 +- ...slice-8-checkpoint-h-resource-ownership.md | 208 +++++++++ ...amewindow-slice-8-composition-lifecycle.md | 18 +- memory/project_gamewindow_decomposition.md | 29 +- .../Rendering/BindlessTextureMutationGuard.cs | 68 +++ .../Rendering/BindlessTexturePair.cs | 94 +++++ .../Rendering/GameFrameGraphSlot.cs | 67 +++ .../Rendering/GameRenderResourceLifetime.cs | 21 + src/AcDream.App/Rendering/GameWindow.cs | 126 +++--- .../Rendering/GlConstructionCleanupLedger.cs | 102 +++++ .../Rendering/GlResourceCommand.cs | 152 +++++++ .../GlTextureConstructionTransaction.cs | 85 ++++ .../Rendering/RenderFrameOrchestrator.cs | 2 +- .../Rendering/ResourceCleanupGroup.cs | 63 +++ .../RestoredTextureBindingMutation.cs | 83 ++++ .../Rendering/RuntimeResourceSlots.cs | 171 ++++++++ src/AcDream.App/Rendering/Shader.cs | 40 +- .../Rendering/ShaderProgramConstruction.cs | 325 ++++++++++++++ src/AcDream.App/Rendering/TerrainAtlas.cs | 336 +++++++++------ src/AcDream.App/Rendering/TextRenderer.cs | 177 +++++--- .../Rendering/TrackedTextureConstruction.cs | 31 ++ .../Rendering/Wb/BindlessSupport.cs | 42 +- .../Rendering/Wb/TrackedGlResource.cs | 80 ++-- src/AcDream.App/UI/RetailUiRuntime.cs | 13 +- src/AcDream.App/UI/RetailUiRuntimeLease.cs | 263 ++++++++++++ src/AcDream.App/UI/UiHost.cs | 4 +- .../Update/UpdateFrameOrchestrator.cs | 2 +- .../GameWindowRenderLeafCompositionTests.cs | 9 +- .../GameWindowSlice8BoundaryTests.cs | 79 +++- .../Rendering/GlTextureOwnershipTests.cs | 396 ++++++++++++++++++ .../Rendering/ResourceCleanupGroupTests.cs | 122 ++++++ .../Rendering/RuntimeResourceSlotTests.cs | 271 ++++++++++++ .../ShaderProgramConstructionTests.cs | 197 +++++++++ .../Rendering/WorldRenderFrameBuilderTests.cs | 2 +- .../UI/RetailUiRuntimeLeaseTests.cs | 352 ++++++++++++++++ .../World/UpdateFrameOrchestratorTests.cs | 8 +- 41 files changed, 3768 insertions(+), 355 deletions(-) create mode 100644 docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-h-resource-ownership.md create mode 100644 src/AcDream.App/Rendering/BindlessTextureMutationGuard.cs create mode 100644 src/AcDream.App/Rendering/BindlessTexturePair.cs create mode 100644 src/AcDream.App/Rendering/GameFrameGraphSlot.cs create mode 100644 src/AcDream.App/Rendering/GameRenderResourceLifetime.cs create mode 100644 src/AcDream.App/Rendering/GlConstructionCleanupLedger.cs create mode 100644 src/AcDream.App/Rendering/GlResourceCommand.cs create mode 100644 src/AcDream.App/Rendering/GlTextureConstructionTransaction.cs create mode 100644 src/AcDream.App/Rendering/ResourceCleanupGroup.cs create mode 100644 src/AcDream.App/Rendering/RestoredTextureBindingMutation.cs create mode 100644 src/AcDream.App/Rendering/RuntimeResourceSlots.cs create mode 100644 src/AcDream.App/Rendering/ShaderProgramConstruction.cs create mode 100644 src/AcDream.App/Rendering/TrackedTextureConstruction.cs create mode 100644 src/AcDream.App/UI/RetailUiRuntimeLease.cs create mode 100644 tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ShaderProgramConstructionTests.cs create mode 100644 tests/AcDream.App.Tests/UI/RetailUiRuntimeLeaseTests.cs diff --git a/AGENTS.md b/AGENTS.md index b5d4a495..13c1c3e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,8 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,524 tests pass / 5 skip. +Release builds; Checkpoint H passes 61 focused ownership tests, 3,236 App tests +/ 3 skips, and 7,606 complete-solution tests / 5 skips. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -127,7 +128,7 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–G are also complete: dead host residue is removed, +Slice 8 checkpoints A–H are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT @@ -146,8 +147,13 @@ application, and inert late target binding. `GameWindow` is now 3,663 raw lines 162 fields / 37 methods, down 12,060 lines (76.7%) from the 15,723-line campaign baseline. The 7,553-test / 5-skip Checkpoint G Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second synchronized nine-stop soak pass. -Slice 8 checkpoints H–L—resource ownership, ordered composition, -retryable shutdown, soak snapshots, and final connected gates—remain active. +Checkpoint H adds sole terrain/sky render-resource roots, one retained +Host/runtime lease, an atomic update/render frame-root pair, prepared portal +fallback/transfer, and checked retryable GL construction/state boundaries. +`GameWindow` is 3,689 raw lines / 162 fields / 37 methods, down 12,034 lines +(76.5%) from baseline. All three final corrected-diff reviews are clean. Slice +8 checkpoints I–L—ordered composition, retryable shutdown, canonical soak +snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, #116, remaining diff --git a/CLAUDE.md b/CLAUDE.md index 518ad669..8fae80e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,8 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,524 tests pass / 5 skip. +Release builds; Checkpoint H passes 61 focused ownership tests, 3,236 App tests +/ 3 skips, and 7,606 complete-solution tests / 5 skips. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -125,7 +126,7 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–G are also complete: dead host residue is removed, +Slice 8 checkpoints A–H are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT @@ -144,8 +145,13 @@ application, and inert late target binding. `GameWindow` is now 3,663 raw lines 162 fields / 37 methods, down 12,060 lines (76.7%) from the 15,723-line campaign baseline. The 7,553-test / 5-skip Checkpoint G Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second synchronized nine-stop soak pass. -Slice 8 checkpoints H–L—resource ownership, ordered composition, -retryable shutdown, soak snapshots, and final connected gates—remain active. +Checkpoint H adds sole terrain/sky render-resource roots, one retained +Host/runtime lease, an atomic update/render frame-root pair, prepared portal +fallback/transfer, and checked retryable GL construction/state boundaries. +`GameWindow` is 3,689 raw lines / 162 fields / 37 methods, down 12,034 lines +(76.5%) from baseline. All three final corrected-diff reviews are clean. Slice +8 checkpoints I–L—ordered composition, retryable shutdown, canonical soak +snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, #116, remaining diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index c2605a03..4179c50b 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -829,15 +829,18 @@ Current movement/collision ownership: routers; Slice 4 moved hydration, teardown, inbound authority, network updates, and reusable motion-runtime policy over canonical `LiveEntityRuntime`. Slices 5–7 complete landblock presentation and the - update/render frame graphs. Slice 8 checkpoints A–G now own the native + update/render frame graphs. Slice 8 checkpoints A–H now own the native callback shell, live-session composition, world environment, reversible input/pointer lifetime, physical framebuffer publication, the sole gameplay action-priority graph, typed commands, the retained-root item-drop edge, and - two-phase runtime settings. `RuntimeSettingsController` is the sole settings + two-phase runtime settings, sole terrain/sky render-resource lifetime, + retained Host/runtime lease, atomic frame-root pair, and prepared portal + fallback/transfer. `RuntimeSettingsController` is the sole settings persistence/current-state owner, supplies the immutable pre-window snapshot, applies startup pacing/display/FOV/audio once, and later borrows complete - runtime targets without replay. Explicit resource ownership and frame slots - are the next checkpoint. The reconciled ledger and sequence live in + runtime targets without replay. Checked GL construction/state boundaries + retain exact cleanup obligations across failure. Ordered production + composition is the next checkpoint. The reconciled ledger and sequence live in `docs/architecture/code-structure.md`. This decision does not reclassify the remaining L.2 conformance work or Track LH. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 70e4a417..de7332ef 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -694,7 +694,7 @@ slices. `OnClosing` delegates to the existing retryable shutdown transaction. Silk callbacks become narrow calls into the input, update, render, resize, focus, and shutdown owners. -Checkpoints E, F, and G are complete. `CameraPointerInputController` owns raw pointer, +Checkpoints E, F, G, and H are complete. `CameraPointerInputController` owns raw pointer, camera cursor/focus/scroll/sensitivity behavior; `FramebufferResizeController` alone publishes physical framebuffer changes. All Silk, dispatcher, retained UI, and optional devtools device edges are reversible, transactional, and @@ -715,10 +715,16 @@ then borrows late-bound runtime targets without replay. Saved FOV now applies at startup even when devtools are disabled; this is a deliberate correction of the old accidental SettingsVM gate. The controller also preserves unrelated unsaved Gameplay draft fields when combat preferences change. `GameWindow` is now 3,663 -raw lines / 162 fields / 37 methods. Checkpoints H–L remain active, with explicit -resource ownership and frame slots next. The Checkpoint G production App build -is warning-free, all three corrected-diff reviews are clean, and the complete -Release suite passes 7,553 tests / 5 intentional skips. +raw lines / 162 fields / 37 methods at G. H adds sole lifetime roots for the +terrain atlas and dedicated sky shader, one retained Host/runtime lease, an +atomic update/render frame-root slot, and a prepare-aware portal fallback and +transfer slot. GL construction and state mutations now use checked commit +boundaries with exact retry ownership for failed names, bindless residency, +and texture-binding restoration. `GameWindow` is 3,689 raw lines / 162 fields / +37 methods. Checkpoints I–L remain active, with ordered production composition +next. The Checkpoint H production App build is warning-free, its 61 focused +ownership tests pass, the complete Release suite passes 7,606 tests / 5 skips, +and all three final corrected-diff reviews are clean. ### 4.4 Exit criteria diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index dd23dcdb..1e0d45d9 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–G are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–H are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- @@ -79,7 +79,7 @@ second synchronized soak pass with graceful exits, and the six stable PNG checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse process-residency gate's diagnostic blind spot; no tolerance was loosened. -Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–G now own +Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–H now own the frozen shell boundary, all nine native callback edges, live-session reset/selection/entry/route composition, and world clock/sky/day/weather/ AdminEnvirons state. Checkpoint E adds reversible, transactional Silk, @@ -94,10 +94,15 @@ retained-root item-drop binding. `GameWindow` is now 4,057 lines / 198 fields / Checkpoint G adds the pre-window `RuntimeSettingsController`, one immutable startup snapshot, ordered startup pacing/display/FOV/audio application, late runtime-target binding without replay, and one current/toon settings owner. -`GameWindow` is now 3,663 lines / 162 fields / 37 methods. Resource transfer, -ordered composition, shutdown, soak, and closeout checkpoints H–L remain. The -Checkpoint G Release suite passes 7,553 tests / 5 intentional skips, and all -three corrected-diff reviews are clean. +`GameWindow` is 3,663 lines / 162 fields / 37 methods at G. Checkpoint H adds +sole terrain-atlas and sky-shader roots, one retained Host/runtime lease, an +atomic update/render frame-root pair, prepared portal fallback/transfer, and +checked retryable GL leaf construction and state mutation. `GameWindow` is now +3,689 lines / 162 fields / 37 methods. Ordered composition, shutdown, canonical +soak snapshots, and closeout checkpoints I–L remain. Checkpoint H passes 61 +focused ownership tests and 3,236 App tests / 3 intentional skips; the complete +Release suite passes 7,606 tests / 5 skips and all three final corrected-diff +reviews are clean. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 5cd9a405..5b8fe6ee 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -39,7 +39,7 @@ diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the 315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized nine-destination soak pass with graceful exits, and six stable PNG checkpoints -preserve Slice 6 presentation. Final Slice 8 checkpoints A–G are complete: +preserve Slice 6 presentation. Final Slice 8 checkpoints A–H are complete: native callbacks and live-session composition now have explicit retryable owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky, day group, weather, and AdminEnvirons state. Checkpoint E adds terminal, @@ -50,10 +50,15 @@ gameplay action-priority owner, typed combat/diagnostic/general commands, and a reversible retained-root item-drop binding. Checkpoint G adds the sole pre-window/runtime settings owner, one startup snapshot, ordered one-shot startup application, and inert late target binding. `GameWindow` is 3,663 -lines / 162 fields / 37 methods. Checkpoints H–L remain active. Issue #232 +lines / 162 fields / 37 methods at G. Checkpoint H adds sole terrain/sky +resource roots, one retained Host/runtime lease, an atomic update/render root +pair, a prepared portal fallback/transfer transaction, and checked retryable GL +leaf construction. `GameWindow` is 3,689 lines / 162 fields / 37 methods. +Checkpoints I–L remain active. Issue #232 tracks process-residency variance in the soak without loosening its leak -threshold. The Checkpoint G Release suite passes 7,553 tests / 5 skips and all -three corrected-diff reviews are clean. +threshold. Checkpoint H passes 61 focused ownership tests and 3,236 App tests / +3 skips; the complete Release suite passes 7,606 tests / 5 skips and all three +final corrected-diff reviews are clean. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-h-resource-ownership.md b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-h-resource-ownership.md new file mode 100644 index 00000000..f6485182 --- /dev/null +++ b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-h-resource-ownership.md @@ -0,0 +1,208 @@ +# GameWindow Slice 8 — Checkpoint H Resource Ownership Plan + +## Objective + +Replace the remaining implicit, constructor-local, and nullable-field resource +ownership in `GameWindow` with explicit single-owner transactions. This is a +behavior-preserving lifetime cutover: draw/update order, portal presentation, +retained UI behavior, settings behavior, and the frozen shutdown order do not +change. + +Checkpoint H does not move the complete shutdown manifest; that is Checkpoint +J. It supplies the typed roots and retry semantics that J will consume. + +## Frozen invariants + +1. Every GL/kernel-owning object is published to exactly one owner immediately + after successful construction. +2. Renderers borrow the terrain atlas and dedicated sky shader. They never + dispose those resources. The renderer borrower retires before the sole + resource owner releases the borrowed atlas/shader. +3. A leaf factory that throws before returning must release every GL name it + created. Outer phase/lifetime rollback cannot recover a name that was never + published. +4. Bindless residency is a resource prefix: partial handle acquisition rolls + back, successful non-residency is not replayed after a later failure, and + textures are not deleted while a resident handle remains. +5. Retained UI input is quiesced before the live-session barrier and physically + detached afterward, independently of runtime/host disposal. +6. Exactly one retained-UI ownership chain exists. Before runtime construction + the lease owns the `UiHost`; after a runtime object is published the lease + retains that exact partial/full runtime and delegates final disposal to it. + Constructor or initialization failure never leaves a host in a local only. +7. Update and render roots publish atomically as one frame graph. Silk callbacks + resolve only through the slot; an empty/withdrawn slot is safely inert. +8. Portal tunnel ownership starts in one fallback slot. Transfer clears the + fallback only after the complete local-teleport controller factory returns; + any earlier failure leaves the same tunnel reachable by shutdown. +9. Successful disposal is never replayed. Failed disposal remains retryable. + Terminal abandonment is explicit and retains references for native/process + fallback rather than silently losing ownership. + +## Typed owners + +### `OwnedResourceSlot` and `GameRenderResourceLifetime` + +- `OwnedResourceSlot` acquires through a factory, rejects duplicate + publication, exposes a typed borrower, and clears ownership only after + `Dispose` succeeds. +- `GameRenderResourceLifetime` contains separate slots for `TerrainAtlas` and + the dedicated sky `Shader`. +- `GameWindow` acquires both through this owner. Shutdown releases the sky + renderer and terrain renderer first, then the two resource slots, then GL. + +### `RetailUiRuntimeLease` + +- The lease exists before `OnLoad` and acquires the `UiHost` through a factory. +- Runtime construction and initialization are separate. The runtime is stored + in the lease before initialization begins. +- Initialization failure attempts immediate cleanup. Successful cleanup + rethrows the original initialization error; cleanup failure reports both and + leaves the exact runtime in the lease for lifetime retry. +- The lease exposes idempotent `QuiesceInput` and retryable `DeactivateInput` + operations against the retained host. +- If no runtime was constructed, lease disposal directly disposes the host. If + a runtime exists, runtime disposal remains the one path that disposes the + host. There are never two host disposers. +- Explicit terminal abandonment is allowed only after a failed disposal pass; + it makes later disposal inert while retaining the unresolved references for + the native/process fallback handled by Checkpoint J. + +### `GameFrameGraphSlot` + +- `IGameUpdateFrameRoot` and `IGameRenderFrameRoot` are the narrow frame seams. +- `UpdateFrameOrchestrator` and `RenderFrameOrchestrator` implement those seams. +- `Publish(update, render)` is atomic and one-shot until withdrawal. +- `Tick`/`Render` are inert before publication and after withdrawal. +- Shutdown withdraws the pair only after live-session convergence and before + any frame borrower retires. + +### `TransferableResourceSlot` + +- `Acquire` owns one fallback resource. +- `Transfer(factory)` retains fallback ownership while the complete destination + factory runs and clears it only after that factory returns successfully. +- Failed acquisition, resource preparation, destination construction, binding, + and normal shutdown prefixes are covered without double disposal. + +## Exception-safe GL leaves + +### Shader program construction + +`ShaderProgramConstruction` uses a narrow `IShaderProgramBuildApi`. It tracks +the vertex shader, fragment shader, and linked program as soon as each name is +returned. Compile, attach, link, detach, or cleanup failure attempts every +remaining rollback operation. A clean rollback preserves the original failure; +rollback failures are aggregated with it. `Shader.Dispose` becomes idempotent +and retryable. + +### Terrain atlas construction and residency + +`GlTextureConstructionTransaction` tracks every texture name allocated by +terrain, alpha, and fallback paths. `TerrainAtlas.Build`, `BuildAlphaAtlas`, and +`BuildFallback` allocate only through that transaction. The transaction commits +only after the complete `TerrainAtlas` object exists; failure rolls back all +names in reverse acquisition order and reports cleanup failures with the +original error. + +`BindlessTexturePair` owns the terrain/alpha residency pair. Partial acquisition +releases the first handle. Release attempts both handles, remembers successful +substeps, and is retryable. `TerrainAtlas.Dispose` releases residency first, +then independently deletes both texture names through the existing retryable +shutdown transaction. + +## Production integration order + +1. Construct render-resource lifetime, UI lease, frame slot, and portal + fallback slot before `Window.Create`/`OnLoad` acquisitions. +2. Acquire the atlas through `GameRenderResourceLifetime`; construct + `TerrainModernRenderer` as a borrower. +3. Acquire `UiHost` through `RetailUiRuntimeLease`, wire input/assets, construct + and publish `RetailUiRuntime` before initialization, then expose host/runtime + only as borrower aliases. +4. Acquire/prepare portal presentation in the fallback slot. +5. Acquire the dedicated sky shader through the render-resource lifetime, then + construct `SkyRenderer` as a borrower. +6. Transfer the portal fallback only through the complete + `LocalPlayerTeleportController` factory. +7. Construct update/render roots as locals and publish them atomically through + `GameFrameGraphSlot`. +8. Update/render callbacks resolve only through the slot. +9. Shutdown quiesces/deactivates UI through the lease, withdraws frame roots, + disposes the UI lease and renderer borrowers, releases atlas/sky roots, then + reaches GL. + +## Automated gates + +- owned-resource acquire/borrow/release, duplicate acquire, transient failure, + retry, and no replay; +- UI host acquisition, runtime constructor failure, Initialize failure with + successful cleanup, transient cleanup then lifetime retry, persistent cleanup + then explicit abandonment, separate input quiescence/deactivation, and exact + host disposal count; +- frame pair atomic publication, duplicate publication, update/render routing, + withdrawal, pre-publication/post-withdrawal silence, and republish policy; +- portal fallback acquisition/preparation/transfer failures plus successful + transfer and every shutdown prefix, with exact one-resource disposal; +- vertex/fragment compile failure, program creation/link failure, and cleanup + failure after every created shader/program name; +- terrain/alpha/fallback texture allocation and upload failure after every + created name, reverse rollback, cleanup aggregation, and committed-name + non-deletion; +- bindless first/second acquisition failure, release partial failure/retry, and + no deletion before residency release; +- source boundary proves direct frame-root fields, constructor-local atlas/sky + ownership, direct UI host/runtime ownership, and nullable portal fallback are + removed from `GameWindow`; +- focused App tests, production App Release build, solution Release build, and + complete Release suite. + +## Review and commit gate + +The existing architecture/integration, retail-conformance, and adversarial +reviewers inspect the complete tracked and untracked diff independently. Every +confirmed finding is corrected and all three repeat until clean. Architecture, +roadmap, milestones, issues, memory, `CLAUDE.md`, and `AGENTS.md` are reconciled +in the same bisectable Checkpoint H commit. + +## Implementation result — 2026-07-22 + +Checkpoint H landed the planned ownership graph without changing the accepted +update/render order or gameplay behavior: + +- `GameRenderResourceLifetime` owns the terrain atlas and dedicated sky shader; + renderers borrow them and retire first. +- `RetailUiRuntimeLease` owns Host/runtime construction, initialization, input + cutoff, retryable disposal, and explicit terminal abandonment as one chain. +- `GameFrameGraphSlot` publishes update/render roots atomically and makes empty + or withdrawn callbacks inert. +- `TransferableResourceSlot.AcquirePrepared` publishes the portal presentation + before mesh preparation, retries the same partial resource, and refuses + transfer until preparation has committed. +- GL name creation records the returned name before post-command validation. + Shader/program, texture, text-renderer, buffer/VAO, bindless-residency, and + texture-binding operations advance ownership only after an always-on checked + GL commit boundary. +- Failed construction cleanup is retained by `GlConstructionCleanupLedger`; + exact pending names and binding-restore obligations remain retryable, while + successful cleanup stages never replay. + +Four corrected-diff cycles closed issues found by the architecture, retail, +and adversarial reviewers: partial bindless mutation, lost GL rollback names, +constructor-local text resources, reentrant UI ownership, portal preparation, +unchecked production GL calls, leaked texture binding, and the bindless post- +commit verification gap. All three final reviews are clean. + +Automated acceptance: + +- focused Checkpoint H ownership/lifetime gate: 61 passed; +- App Release suite: 3,236 passed / 3 intentional skips; +- production App Release build: zero warnings and zero errors; +- complete solution Release suite: 7,606 passed / 5 intentional skips; solution + build has zero errors and the 17 existing test-project warnings tracked by + #228; +- `GameWindow.cs`: 3,689 raw lines / 162 fields / 37 methods, versus 15,723 / + 278 / 205 at the campaign baseline. + +No AC-specific algorithm or intended retail behavior changed, so Checkpoint H +adds no retail-divergence row. Checkpoint I is the next active unit. diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index 61a38b97..bf9c5000 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -26,7 +26,7 @@ audit. - [x] F — extract the sole gameplay input-action router plus focused combat and diagnostic command owners. - [x] G — extract two-phase persisted settings/display/quality ownership. -- [ ] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots +- [x] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots explicit single ownership and transfer seams. - [ ] I — group `OnLoad` into small ordered, fakeable composition phases with transactional partial-acquisition rollback. @@ -598,6 +598,22 @@ intentional skips (17 existing test-project warnings remain tracked by #228). Inject GL compile/link/texture-allocation failure after every internal name and prove exact rollback/residency release. +Checkpoint H is complete. `GameRenderResourceLifetime` is the sole terrain- +atlas and dedicated-sky-shader owner; renderers borrow and retire before those +roots. `RetailUiRuntimeLease` retains the Host/runtime chain through partial +construction, input cutoff, retryable disposal, and explicit terminal +abandonment. `GameFrameGraphSlot` publishes update/render roots atomically, and +the portal tunnel now uses one prepare-aware fallback/transfer transaction. +Shader, terrain, text-renderer, buffer/VAO, bindless, and texture-binding +construction/mutation paths use always-on checked GL commit boundaries. A +failed cleanup retains the exact name/handle/binding obligation for retry, and +successful substeps never replay. `GameWindow` is 3,689 raw lines / 162 fields +/ 37 methods. The focused ownership gate passes 61 tests, the App Release suite +passes 3,236 tests / 3 intentional skips, and all three independent final +corrected-diff reviews are clean. The complete Release suite passes 7,606 tests +/ 5 intentional skips; the 17 existing test-project warnings remain tracked by +#228. + ### I — ordered production composition - Split the 2,289-line `OnLoad` body at §3.6 boundaries using the fixed typed diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index de8cd214..cca15fc2 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -3,9 +3,9 @@ ## Current state The behavior-preserving App ownership campaign is complete through Slice 7 and -Slice 8 checkpoints A–G. `GameWindow.cs` moved from the 2026-07-21 baseline of -15,723 lines / 278 fields / 205 methods to 3,663 lines / 162 fields / 37 -methods: 12,060 lines (76.7%) were removed without changing accepted gameplay +Slice 8 checkpoints A–H. `GameWindow.cs` moved from the 2026-07-21 baseline of +15,723 lines / 278 fields / 205 methods to 3,689 lines / 162 fields / 37 +methods: 12,034 lines (76.5%) were removed without changing accepted gameplay or rendering behavior. | Slice | Ownership moved out | Closeout size | @@ -20,6 +20,7 @@ or rendering behavior. | 8 A–E | shell freeze, native/session/environment composition, reversible pointer/input/framebuffer ownership | 4,266 / 194 / 65 | | 8 F | sole gameplay action route, typed commands, retained-root callback lifetime | 4,057 / 198 / 54 | | 8 G | pre-window/runtime settings ownership, one startup snapshot, inert late target binding | 3,663 / 162 / 37 | +| 8 H | render/UI/frame/portal resource roots plus checked retryable GL leaves | 3,689 / 162 / 37 | Slice 8 is an ordered checkpoint campaign rather than another feature-body move. A froze/pruned the shell boundary; B gave all nine native window callback @@ -72,6 +73,18 @@ Saved FOV now applies without devtools, fixing the former accidental SettingsVM gate. AP-121 records the intentional modern persistence added to the named-retail `/framerate` live toggle/notice. +H adds explicit ownership for the terrain atlas, dedicated sky shader, +retained Host/runtime chain, atomic update/render root pair, and portal tunnel +fallback/preparation/transfer. Every GL construction or mutation prefix added +to the checkpoint advances CPU ownership only after an always-on checked +driver commit. Failed shader/program/texture/buffer/VAO/text construction +retains exact names through the construction ledger; bindless residency and +the prior texture binding retain retry obligations across transient failure. +UI input cutoff and resource slots reject reentrant ownership mutation, and +portal preparation retries the same published fallback instead of replaying +the factory. This is a lifetime-only cutover; no AC algorithm or accepted +presentation order changed. + Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`, `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`, @@ -128,6 +141,12 @@ review rule live in `docs/architecture/code-structure.md`. ## Accepted verification +- Slice 8 Checkpoint H: 61 focused ownership/lifetime tests pass; App Release + passes 3,236 tests / 3 intentional skips. Four corrected-diff cycles closed + every architecture, retail, and adversarial finding; all three final reviews + are clean. The production App build has zero warnings/errors. The complete + Release suite passes 7,606 tests / 5 intentional skips; the 17 existing test- + project warnings remain tracked by #228. - Slice 8 Checkpoint G Release suite: 7,553 passed / 5 fixture or environment skips; App 3,183/3 skips, UI Abstractions 534/0, 35 focused App settings/boundary tests, and all 43 `SettingsVMTests` pass. The production App @@ -170,8 +189,8 @@ review rule live in `docs/architecture/code-structure.md`. ## Next work -Slice 8 checkpoints H–L group the remaining resource ownership, ordered -startup composition, and shutdown wiring and reduce Silk callbacks to +Slice 8 checkpoints I–L group the remaining ordered startup composition, +shutdown wiring, canonical soak snapshots, and closeout and reduce Silk callbacks to narrow calls into input, update, render, resize, focus, and shutdown owners. It removes leftover feature state only where Slices 1–7 already established a canonical owner; it does not redesign gameplay, rendering, or resource lifetime. diff --git a/src/AcDream.App/Rendering/BindlessTextureMutationGuard.cs b/src/AcDream.App/Rendering/BindlessTextureMutationGuard.cs new file mode 100644 index 00000000..f3b5d2f0 --- /dev/null +++ b/src/AcDream.App/Rendering/BindlessTextureMutationGuard.cs @@ -0,0 +1,68 @@ +using System.Runtime.ExceptionServices; + +namespace AcDream.App.Rendering; + +/// +/// Temporarily removes bindless residency around texture-state mutation while +/// retaining the obligation to restore the pair across failed mutation or +/// failed reacquisition attempts. +/// +internal sealed class BindlessTextureMutationGuard(BindlessTexturePair pair) +{ + private bool _restoreRequired; + private bool _operationActive; + + internal bool RestoreRequired => _restoreRequired; + + public void Execute(Action mutation) + { + ArgumentNullException.ThrowIfNull(mutation); + if (_operationActive) + throw new InvalidOperationException("A bindless texture mutation is already active."); + + _operationActive = true; + Exception? mutationFailure = null; + Exception? restoreFailure = null; + try + { + _restoreRequired |= pair.HasAnyResident; + if (pair.HasAnyResident) + pair.Release(); + + mutation(); + } + catch (Exception failure) + { + mutationFailure = failure; + } + finally + { + if (_restoreRequired) + { + try + { + _ = pair.Acquire(); + _restoreRequired = false; + } + catch (Exception failure) + { + restoreFailure = failure; + } + } + + _operationActive = false; + } + + if (mutationFailure is not null && restoreFailure is not null) + { + throw new AggregateException( + "Texture-state mutation failed and bindless residency could not be restored.", + mutationFailure, + restoreFailure); + } + if (mutationFailure is not null) + ExceptionDispatchInfo.Capture(mutationFailure).Throw(); + if (restoreFailure is not null) + ExceptionDispatchInfo.Capture(restoreFailure).Throw(); + } +} diff --git a/src/AcDream.App/Rendering/BindlessTexturePair.cs b/src/AcDream.App/Rendering/BindlessTexturePair.cs new file mode 100644 index 00000000..2b0cd50b --- /dev/null +++ b/src/AcDream.App/Rendering/BindlessTexturePair.cs @@ -0,0 +1,94 @@ +namespace AcDream.App.Rendering; + +/// +/// Owns the two independent residency edges used by TerrainAtlas. Acquisition +/// and release publish each successful edge immediately, so a partial failure +/// retries only work that is still pending. +/// +internal sealed class BindlessTexturePair( + uint firstTexture, + uint secondTexture, + Func acquire, + Action release) +{ + private ulong _firstHandle; + private ulong _secondHandle; + private bool _firstResident; + private bool _secondResident; + + public bool IsFullyResident => _firstResident && _secondResident; + public bool HasAnyResident => _firstResident || _secondResident; + + public (ulong First, ulong Second) Acquire() + { + if (!_firstResident) + { + _firstHandle = acquire(firstTexture); + _firstResident = true; + } + + if (!_secondResident) + { + try + { + _secondHandle = acquire(secondTexture); + _secondResident = true; + } + catch (Exception acquisitionFailure) + { + try + { + Release(); + } + catch (Exception cleanupFailure) + { + throw new AggregateException( + "Second bindless texture acquisition failed and the resident prefix did not cleanly roll back.", + acquisitionFailure, + cleanupFailure); + } + + throw; + } + } + + return (_firstHandle, _secondHandle); + } + + public void Release() + { + List? failures = null; + if (_firstResident) + { + try + { + release(_firstHandle); + _firstResident = false; + _firstHandle = 0; + } + catch (Exception failure) + { + (failures ??= []).Add(failure); + } + } + + if (_secondResident) + { + try + { + release(_secondHandle); + _secondResident = false; + _secondHandle = 0; + } + catch (Exception failure) + { + (failures ??= []).Add(failure); + } + } + + if (failures is not null) + throw new AggregateException( + "One or more bindless texture handles could not be made non-resident.", + failures); + } +} diff --git a/src/AcDream.App/Rendering/GameFrameGraphSlot.cs b/src/AcDream.App/Rendering/GameFrameGraphSlot.cs new file mode 100644 index 00000000..bde9adc0 --- /dev/null +++ b/src/AcDream.App/Rendering/GameFrameGraphSlot.cs @@ -0,0 +1,67 @@ +using AcDream.App.Update; + +namespace AcDream.App.Rendering; + +internal interface IGameUpdateFrameRoot +{ + void Tick(UpdateFrameInput input); +} + +internal interface IGameRenderFrameRoot +{ + RenderFrameOutcome Render(RenderFrameInput input); +} + +/// +/// Sole atomic publication point for the current update/render frame pair. +/// Native callbacks are deliberately inert before publication and after +/// withdrawal so a partial load or converging shutdown cannot enter half a +/// frame graph. +/// +internal sealed class GameFrameGraphSlot +{ + private sealed record FrameGraphPair( + IGameUpdateFrameRoot Update, + IGameRenderFrameRoot Render); + + private FrameGraphPair? _pair; + + public bool IsPublished => Volatile.Read(ref _pair) is not null; + + public void Publish(IGameUpdateFrameRoot update, IGameRenderFrameRoot render) + { + ArgumentNullException.ThrowIfNull(update); + ArgumentNullException.ThrowIfNull(render); + var pair = new FrameGraphPair(update, render); + if (Interlocked.CompareExchange(ref _pair, pair, null) is not null) + throw new InvalidOperationException("A game frame graph is already published."); + } + + public bool Tick(UpdateFrameInput input) + { + FrameGraphPair? pair = Volatile.Read(ref _pair); + if (pair is null) + return false; + + pair.Update.Tick(input); + return true; + } + + public bool Render(RenderFrameInput input, out RenderFrameOutcome outcome) + { + FrameGraphPair? pair = Volatile.Read(ref _pair); + if (pair is null) + { + outcome = default; + return false; + } + + outcome = pair.Render.Render(input); + return true; + } + + public void Withdraw() + { + Interlocked.Exchange(ref _pair, null); + } +} diff --git a/src/AcDream.App/Rendering/GameRenderResourceLifetime.cs b/src/AcDream.App/Rendering/GameRenderResourceLifetime.cs new file mode 100644 index 00000000..e24575f5 --- /dev/null +++ b/src/AcDream.App/Rendering/GameRenderResourceLifetime.cs @@ -0,0 +1,21 @@ +namespace AcDream.App.Rendering; + +/// +/// Sole lifetime owner for render resources that are borrowed by, but not +/// owned by, their renderers. +/// +internal sealed class GameRenderResourceLifetime +{ + private readonly OwnedResourceSlot _terrainAtlas = new(); + private readonly OwnedResourceSlot _skyShader = new(); + + public TerrainAtlas AcquireTerrainAtlas(Func factory) => + _terrainAtlas.Acquire(factory); + + public Shader AcquireSkyShader(Func factory) => + _skyShader.Acquire(factory); + + public void ReleaseTerrainAtlas() => _terrainAtlas.Release(); + + public void ReleaseSkyShader() => _skyShader.Release(); +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 24818871..fa02ff8a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -92,8 +92,11 @@ public sealed class GameWindow : IDisposable private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots; private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; - private AcDream.App.Rendering.RenderFrameOrchestrator? - _renderFrameOrchestrator; + private readonly AcDream.App.Rendering.GameFrameGraphSlot _frameGraphs = new(); + private readonly AcDream.App.Rendering.GameRenderResourceLifetime + _renderResourceLifetime = new(); + private readonly AcDream.App.Rendering.GlConstructionCleanupLedger + _glConstructionCleanup = new(); private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment = new(Console.WriteLine); private ResourceShutdownTransaction? _shutdown; @@ -148,7 +151,6 @@ public sealed class GameWindow : IDisposable private readonly AcDream.App.Input.MovementTruthDiagnosticController _movementTruthDiagnostics; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; - private AcDream.App.Update.UpdateFrameOrchestrator _updateFrameOrchestrator = null!; private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new(); private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; // Step 7 projectile presentation. The controller owns no identity map; @@ -202,7 +204,8 @@ public sealed class GameWindow : IDisposable // R1 (render redesign): portal-view draw owners are retained transitively // by the frame orchestrator rather than duplicated as GameWindow roots. private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; - private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel; + private readonly AcDream.App.Rendering.TransferableResourceSlot< + AcDream.App.Rendering.PortalTunnelPresentation> _portalTunnelFallback = new(); // Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain // UBO). In U.3 a single ClipFrame.NoClip() instance is created lazily (??=) and @@ -342,6 +345,7 @@ public sealed class GameWindow : IDisposable // Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1. private AcDream.App.UI.UiHost? _uiHost; private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime; + private readonly AcDream.App.UI.RetailUiRuntimeLease _retailUiLease = new(); private readonly AcDream.App.Combat.CombatAttackOperationsSlot _combatAttackOperations = new(); private readonly AcDream.App.Combat.CombatFeedbackSlot @@ -638,7 +642,15 @@ public sealed class GameWindow : IDisposable _displayFramePacing, _hostQuiescence); _windowCallbacks.Attach(); - _window.Run(); + try + { + _window.Run(); + } + catch (Exception failure) + { + _glConstructionCleanup.RetainFrom(failure); + throw; + } } private void OnLoad() @@ -1100,7 +1112,11 @@ public sealed class GameWindow : IDisposable // Build the terrain atlas once from the Region dat. Phase N.5b: the // atlas exposes bindless handles for the modern terrain path, so // BindlessSupport is threaded through. - var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats, _bindlessSupport); + var terrainAtlas = _renderResourceLifetime.AcquireTerrainAtlas( + () => AcDream.App.Rendering.TerrainAtlas.Build( + _gl, + _dats, + _bindlessSupport)); // A.5 T22.5: apply anisotropic level from quality preset. Build() // hard-codes 16x; override here to match the resolved quality so Low // (4x) and Medium (8x) actually take effect. @@ -1230,11 +1246,12 @@ public sealed class GameWindow : IDisposable if (_options.RetailUi) { _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); - _uiHost = new AcDream.App.UI.UiHost( - _gl, - shadersDir, - _debugFont, - _hostQuiescence); + _uiHost = _retailUiLease.AcquireHost( + () => new AcDream.App.UI.UiHost( + _gl, + shadersDir, + _debugFont, + _hostQuiescence)); _retainedInputCapture.Root = _uiHost.Root; _uiHost.Root.UiLocked = _runtimeSettings.Gameplay.LockUI; var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( @@ -1360,8 +1377,7 @@ public sealed class GameWindow : IDisposable UiProbeLog); } - _retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount( - new AcDream.App.UI.RetailUiRuntimeBindings( + var retailUiBindings = new AcDream.App.UI.RetailUiRuntimeBindings( Host: _uiHost, Assets: new AcDream.App.UI.RetailUiAssets( _dats!, _datLock, ResolveChrome, ResolveDatFont, @@ -1511,7 +1527,10 @@ public sealed class GameWindow : IDisposable action => _inputDispatcher?.TryInvokeAutomationAction(action) == true, (action, held) => _inputDispatcher?.TrySetAutomationActionHeld(action, held) == true, - _worldLifecycleAutomation))); + _worldLifecycleAutomation)); + _retailUiRuntime = _retailUiLease.Mount( + () => AcDream.App.UI.RetailUiRuntime.CreateUninitialized( + retailUiBindings)); } @@ -1966,26 +1985,29 @@ public sealed class GameWindow : IDisposable // T1: invisible portal depth writes (seal/punch) — retail // DrawPortalPolyInternal (Ghidra 0x0059bc90). _portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl); - _portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired( - _gl, - _dats!, - _animLoader!, - _hookRouter, - _wbDrawDispatcher, - _sceneLightingUbo!, - _wbMeshAdapter!); + _portalTunnelFallback.AcquirePrepared( + () => AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired( + _gl, + _dats!, + _animLoader!, + _hookRouter, + _wbDrawDispatcher, + _sceneLightingUbo!, + _wbMeshAdapter!), + static portalTunnel => portalTunnel.PrepareResources()); // Publish the presentation before touching the mesh-reference // backend. Partial acquisition remains reachable by staged // shutdown instead of becoming constructor-local leaked state. - _portalTunnel.PrepareResources(); } // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) // with depth writes off + far plane 1e6 so celestial meshes // never clip. Shares the TextureCache with the static pipeline. - var skyShader = new Shader(_gl, - Path.Combine(shadersDir, "sky.vert"), - Path.Combine(shadersDir, "sky.frag")); + var skyShader = _renderResourceLifetime.AcquireSkyShader( + () => new Shader( + _gl, + Path.Combine(shadersDir, "sky.vert"), + Path.Combine(shadersDir, "sky.frag"))); _skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer( _gl, _dats, skyShader, _textureCache!, _samplerCache); @@ -2422,13 +2444,8 @@ public sealed class GameWindow : IDisposable _localPlayerMode, _playerModeController)); _playerModeController.BindAutoEntry(_playerModeAutoEntry); - var localTeleportPresentation = - new AcDream.App.Streaming.LocalPlayerTeleportPresentation( - _portalTunnel - ?? throw new InvalidOperationException( - "Portal presentation was not composed before local teleport.")); - _localPlayerTeleport = - new AcDream.App.Streaming.LocalPlayerTeleportController( + _localPlayerTeleport = _portalTunnelFallback.Transfer( + portalTunnel => new AcDream.App.Streaming.LocalPlayerTeleportController( new AcDream.App.Streaming.LiveLocalPlayerTeleportAuthority( _liveEntities, _localPlayerIdentity), @@ -2451,11 +2468,8 @@ public sealed class GameWindow : IDisposable liveSpatialReconciler), new AcDream.App.Streaming.LocalPlayerTeleportSession( _liveSessionController), - localTeleportPresentation); - // Ownership transferred to LocalPlayerTeleportController. This field - // remains only as the staged-startup fallback used by shutdown when - // composition fails before the transfer. - _portalTunnel = null; + new AcDream.App.Streaming.LocalPlayerTeleportPresentation( + portalTunnel))); _localPlayerTeleportSink.Bind(_localPlayerTeleport); var teleportRenderState = new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource( @@ -2652,7 +2666,7 @@ public sealed class GameWindow : IDisposable renderFrameResources, _devToolsFramePresenter, renderWeatherFrame); - _renderFrameOrchestrator = + var renderFrameOrchestrator = new AcDream.App.Rendering.RenderFrameOrchestrator( _gpuFrameFlights!, framePreparation, @@ -2681,7 +2695,7 @@ public sealed class GameWindow : IDisposable Combat, _selection, _worldSelectionQuery!)); - _updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator( + var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator( new AcDream.App.Update.LiveEntityTeardownFramePhase( _liveEntities), new AcDream.App.Update.ConsoleUpdateFrameFailureSink(), @@ -2697,6 +2711,7 @@ public sealed class GameWindow : IDisposable new AcDream.App.Update.PlayerModeAutoEntryFramePhase( _playerModeAutoEntry), cameraFrame); + _frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator); _liveSessionHost = CreateLiveSessionHost(); AcDream.UI.Abstractions.Panels.Debug.DebugVM? debugVm = _debugVm; @@ -3055,8 +3070,7 @@ public sealed class GameWindow : IDisposable { using var _updStage = _frameProfiler.BeginStage( AcDream.App.Diagnostics.FrameStage.Update); - _updateFrameOrchestrator.Tick( - new AcDream.App.Update.UpdateFrameInput(dt)); + _frameGraphs.Tick(new AcDream.App.Update.UpdateFrameInput(dt)); } // Performance overlay state — updated every ~0.5s and written to the @@ -3064,11 +3078,12 @@ public sealed class GameWindow : IDisposable private void OnRender(double deltaSeconds) { Vector2D size = _window!.Size; - _renderFrameOrchestrator!.Render( + _frameGraphs.Render( new AcDream.App.Rendering.RenderFrameInput( deltaSeconds, size.X, - size.Y)); + size.Y), + out _); } private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot @@ -3336,7 +3351,7 @@ public sealed class GameWindow : IDisposable new("dispatcher", () => _inputDispatcher?.Deactivate()), new("mouse source", () => _mouseSource?.Deactivate()), new("keyboard source", () => _kbSource?.Deactivate()), - new("retained UI input", () => _uiHost?.QuiesceInput()), + new("retained UI input", _retailUiLease.QuiesceInput), new("developer tools input", () => _devToolsInputContext?.Deactivate()), ]), // Live-session reset retires the complete streaming window. Every @@ -3390,7 +3405,7 @@ public sealed class GameWindow : IDisposable "Gameplay action callback removal remains pending."); _gameplayInputActions = null; }), - new("retained UI input", () => _uiHost?.DeactivateInput()), + new("retained UI input", _retailUiLease.DeactivateInput), new("developer tools input", () => _devToolsInputContext?.Dispose()), new("camera pointer", () => { @@ -3444,7 +3459,7 @@ public sealed class GameWindow : IDisposable new("runtime settings targets", _runtimeSettings.UnbindRuntimeTargets), new("world frame composition", () => { - _renderFrameOrchestrator = null; + _frameGraphs.Withdraw(); }), ]), new ResourceShutdownStage("session dependents", @@ -3461,7 +3476,10 @@ public sealed class GameWindow : IDisposable }), new("retail UI", () => { - _retailUiRuntime?.Dispose(); + _retailUiLease.Dispose(); + if (!_retailUiLease.IsDisposalComplete) + throw new InvalidOperationException( + "The retained UI ownership lease did not complete disposal."); _retailUiRuntime = null; _retainedInputCapture.Root = null; _uiHost = null; @@ -3544,8 +3562,7 @@ public sealed class GameWindow : IDisposable { _localPlayerTeleport?.Dispose(); _localPlayerTeleport = null; - _portalTunnel?.Dispose(); - _portalTunnel = null; + _portalTunnelFallback.ReleaseFallback(); }), new("paperdoll viewport", () => { @@ -3589,6 +3606,15 @@ public sealed class GameWindow : IDisposable new("frame pacing", _displayFramePacing.Dispose), new("frame profiler", _frameProfiler.Dispose), ]), + new ResourceShutdownStage("dedicated render resources", + [ + new("sky shader", _renderResourceLifetime.ReleaseSkyShader), + new("terrain atlas", _renderResourceLifetime.ReleaseTerrainAtlas), + ]), + new ResourceShutdownStage("failed render construction cleanup", + [ + new("GL construction ledger", _glConstructionCleanup.Dispose), + ]), new ResourceShutdownStage("frame flight owner", [ new("frame flights", () => diff --git a/src/AcDream.App/Rendering/GlConstructionCleanupLedger.cs b/src/AcDream.App/Rendering/GlConstructionCleanupLedger.cs new file mode 100644 index 00000000..e1bc73ef --- /dev/null +++ b/src/AcDream.App/Rendering/GlConstructionCleanupLedger.cs @@ -0,0 +1,102 @@ +namespace AcDream.App.Rendering; + +internal interface IRetryableResourceCleanup +{ + bool IsCleanupComplete { get; } + void RetryCleanup(); +} + +internal sealed class GlResourceConstructionException : AggregateException, + IRetryableResourceCleanup +{ + private readonly IRetryableResourceCleanup _cleanup; + + public GlResourceConstructionException( + string message, + IRetryableResourceCleanup cleanup, + IEnumerable failures) + : base(message, failures) + { + _cleanup = cleanup ?? throw new ArgumentNullException(nameof(cleanup)); + } + + public bool IsCleanupComplete => _cleanup.IsCleanupComplete; + + public void RetryCleanup() => _cleanup.RetryCleanup(); +} + +/// +/// Lifetime root for cleanup work that could not finish before a throwing GL +/// factory returned control. The original exception remains the retry owner; +/// this ledger prevents it and its exact pending names from becoming local-only. +/// +internal sealed class GlConstructionCleanupLedger : IDisposable +{ + private readonly List _pending = []; + private bool _disposing; + + public bool IsComplete => _pending.Count == 0; + + public bool RetainFrom(Exception failure) + { + ArgumentNullException.ThrowIfNull(failure); + bool retained = false; + Visit(failure); + return retained; + + void Visit(Exception current) + { + if (current is IRetryableResourceCleanup cleanup) + { + if (!cleanup.IsCleanupComplete && !_pending.Contains(cleanup)) + _pending.Add(cleanup); + retained = true; + } + + if (current is AggregateException aggregate) + { + foreach (Exception inner in aggregate.InnerExceptions) + Visit(inner); + } + else if (current.InnerException is { } inner) + { + Visit(inner); + } + } + } + + public void Dispose() + { + if (_disposing || _pending.Count == 0) + return; + + _disposing = true; + List? failures = null; + try + { + for (int i = _pending.Count - 1; i >= 0; i--) + { + IRetryableResourceCleanup cleanup = _pending[i]; + try + { + cleanup.RetryCleanup(); + if (cleanup.IsCleanupComplete) + _pending.RemoveAt(i); + } + catch (Exception failure) + { + (failures ??= []).Add(failure); + } + } + } + finally + { + _disposing = false; + } + + if (failures is not null) + throw new AggregateException( + "One or more failed GL construction transactions remain pending.", + failures); + } +} diff --git a/src/AcDream.App/Rendering/GlResourceCommand.cs b/src/AcDream.App/Rendering/GlResourceCommand.cs new file mode 100644 index 00000000..57478bb5 --- /dev/null +++ b/src/AcDream.App/Rendering/GlResourceCommand.cs @@ -0,0 +1,152 @@ +using System.Runtime.ExceptionServices; +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// Always-on commit boundary for GL resource commands. OpenGL reports ordinary +/// command failures through its error flag, so an ownership state machine may +/// advance only after the post-command check succeeds. +/// +internal static class GlResourceCommand +{ + public static void Execute(GL gl, string context, Action command) + { + ArgumentNullException.ThrowIfNull(gl); + ArgumentException.ThrowIfNullOrWhiteSpace(context); + ArgumentNullException.ThrowIfNull(command); + + GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); + command(); + GLHelpers.ThrowOnResourceError(gl, context); + } + + public static T Execute(GL gl, string context, Func command) + { + ArgumentNullException.ThrowIfNull(gl); + ArgumentException.ThrowIfNullOrWhiteSpace(context); + ArgumentNullException.ThrowIfNull(command); + + GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); + T result = command(); + GLHelpers.ThrowOnResourceError(gl, context); + return result; + } + + public static uint CreateName( + GL gl, + string resourceName, + Func create, + Action delete) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); + ArgumentNullException.ThrowIfNull(create); + ArgumentNullException.ThrowIfNull(delete); + + return CreateNameCore( + resourceName, + () => GLHelpers.ThrowOnResourceError( + gl, + $"create {resourceName} (precondition)"), + create, + () => GLHelpers.ThrowOnResourceError(gl, $"create {resourceName}"), + ownedName => Execute( + gl, + $"rollback {resourceName} name {ownedName}", + () => delete(ownedName))); + } + + internal static uint CreateNameCore( + string resourceName, + Action precondition, + Func create, + Action postcondition, + Action deleteChecked) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); + ArgumentNullException.ThrowIfNull(precondition); + ArgumentNullException.ThrowIfNull(create); + ArgumentNullException.ThrowIfNull(postcondition); + ArgumentNullException.ThrowIfNull(deleteChecked); + + uint name = 0; + Exception? creationFailure = null; + try + { + precondition(); + name = create(); + if (name == 0) + throw new InvalidOperationException($"OpenGL returned no {resourceName} name."); + postcondition(); + return name; + } + catch (Exception failure) + { + creationFailure = failure; + } + + if (name != 0) + { + var cleanup = new SingleNameCleanup(name, deleteChecked); + try + { + cleanup.RetryCleanup(); + } + catch (Exception cleanupFailure) + { + throw new GlResourceConstructionException( + $"Creating {resourceName} failed and its returned GL name could not be released.", + cleanup, + [creationFailure, cleanupFailure]); + } + } + + ExceptionDispatchInfo.Capture(creationFailure).Throw(); + throw new InvalidOperationException("Unreachable GL resource-creation path."); + } + + public static uint CreateTexture(GL gl, string context) => + CreateName(gl, context, gl.GenTexture, gl.DeleteTexture); + + public static void DeleteTexture(GL gl, uint texture, string context) => + Execute(gl, context, () => gl.DeleteTexture(texture)); + + public static void DeleteShader(GL gl, uint shader, string context) => + Execute(gl, context, () => gl.DeleteShader(shader)); + + public static void DeleteProgram(GL gl, uint program, string context) => + Execute(gl, context, () => gl.DeleteProgram(program)); + + public static void DeleteBuffer(GL gl, uint buffer, string context) => + Execute(gl, context, () => gl.DeleteBuffer(buffer)); + + public static void DeleteVertexArray(GL gl, uint vertexArray, string context) => + Execute(gl, context, () => gl.DeleteVertexArray(vertexArray)); + + private sealed class SingleNameCleanup(uint name, Action delete) + : IRetryableResourceCleanup + { + private uint _name = name; + private bool _running; + + public bool IsCleanupComplete => _name == 0; + + public void RetryCleanup() + { + if (_running || _name == 0) + return; + + _running = true; + try + { + delete(_name); + _name = 0; + } + finally + { + _running = false; + } + } + } +} diff --git a/src/AcDream.App/Rendering/GlTextureConstructionTransaction.cs b/src/AcDream.App/Rendering/GlTextureConstructionTransaction.cs new file mode 100644 index 00000000..a7a04e69 --- /dev/null +++ b/src/AcDream.App/Rendering/GlTextureConstructionTransaction.cs @@ -0,0 +1,85 @@ +namespace AcDream.App.Rendering; + +internal interface IGlTextureNameApi +{ + uint GenTexture(); + void DeleteTexture(uint texture); +} + +internal sealed class GlTextureNameApi(Silk.NET.OpenGL.GL gl) : IGlTextureNameApi +{ + public uint GenTexture() => + GlResourceCommand.CreateTexture(gl, "terrain construction texture"); + + public void DeleteTexture(uint texture) => + GlResourceCommand.DeleteTexture( + gl, + texture, + $"delete terrain construction texture {texture}"); +} + +/// +/// Retains every texture name immediately after allocation until the complete +/// aggregate owner has been constructed. A failed factory rolls names back in +/// reverse acquisition order and attempts every deletion. +/// +internal sealed class GlTextureConstructionTransaction(IGlTextureNameApi api) + : IRetryableResourceCleanup +{ + private readonly List _ownedNames = []; + private bool _finished; + + public bool IsCleanupComplete => _finished; + + public uint Allocate() + { + if (_finished) + throw new InvalidOperationException("The texture construction transaction is finished."); + + uint texture = api.GenTexture(); + if (texture == 0) + throw new InvalidOperationException("OpenGL returned no texture name."); + _ownedNames.Add(texture); + return texture; + } + + public void Commit() + { + if (_finished) + throw new InvalidOperationException("The texture construction transaction is finished."); + _ownedNames.Clear(); + _finished = true; + } + + public void Rollback() + { + if (_finished) + return; + + List? failures = null; + for (int i = _ownedNames.Count - 1; i >= 0; i--) + { + uint texture = _ownedNames[i]; + try + { + api.DeleteTexture(texture); + _ownedNames.RemoveAt(i); + } + catch (Exception failure) + { + (failures ??= []).Add(new InvalidOperationException( + $"Texture construction rollback could not delete OpenGL name {texture}.", + failure)); + } + } + + if (failures is not null) + throw new AggregateException( + "Texture construction rollback did not release every allocated OpenGL name.", + failures); + + _finished = true; + } + + public void RetryCleanup() => Rollback(); +} diff --git a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs index 9f688d99..0d95c3a0 100644 --- a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs +++ b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs @@ -95,7 +95,7 @@ internal sealed class NullRenderFrameFailureRecovery : IRenderFrameFailureRecove /// and close failure are reported together, while either failure alone /// propagates directly. /// -internal sealed class RenderFrameOrchestrator +internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot { private readonly IRenderFrameLifetime _lifetime; private readonly IRenderFrameResourcePhase _resources; diff --git a/src/AcDream.App/Rendering/ResourceCleanupGroup.cs b/src/AcDream.App/Rendering/ResourceCleanupGroup.cs new file mode 100644 index 00000000..fe8ba308 --- /dev/null +++ b/src/AcDream.App/Rendering/ResourceCleanupGroup.cs @@ -0,0 +1,63 @@ +namespace AcDream.App.Rendering; + +/// +/// Reverse-order, all-attempted cleanup owner used while a composite resource +/// is still under construction and after it becomes the aggregate owner. +/// +internal sealed class ResourceCleanupGroup : IRetryableResourceCleanup +{ + private sealed record Entry(string Name, Action Release) + { + public bool Complete { get; set; } + } + + private readonly List _entries = []; + private bool _running; + + public bool IsCleanupComplete => _entries.All(static entry => entry.Complete); + + public void Add(string name, Action release) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(release); + if (_running || IsCleanupComplete && _entries.Count != 0) + throw new InvalidOperationException("The resource cleanup group is no longer accepting ownership."); + _entries.Add(new Entry(name, release)); + } + + public void RetryCleanup() + { + if (_running || IsCleanupComplete) + return; + + _running = true; + List? failures = null; + try + { + for (int i = _entries.Count - 1; i >= 0; i--) + { + Entry entry = _entries[i]; + if (entry.Complete) + continue; + try + { + entry.Release(); + entry.Complete = true; + } + catch (Exception failure) + { + (failures ??= []).Add(new InvalidOperationException( + $"Resource cleanup operation '{entry.Name}' failed.", + failure)); + } + } + } + finally + { + _running = false; + } + + if (failures is not null) + throw new AggregateException("Composite resource cleanup remains incomplete.", failures); + } +} diff --git a/src/AcDream.App/Rendering/RestoredTextureBindingMutation.cs b/src/AcDream.App/Rendering/RestoredTextureBindingMutation.cs new file mode 100644 index 00000000..5fcc4342 --- /dev/null +++ b/src/AcDream.App/Rendering/RestoredTextureBindingMutation.cs @@ -0,0 +1,83 @@ +using System.Runtime.ExceptionServices; + +namespace AcDream.App.Rendering; + +/// +/// Runs a texture mutation with the caller's prior binding restored on every +/// exit path. The concrete GL adapter supplies checked commands; tests can +/// fault each edge without requiring a graphics context. +/// +internal sealed class RestoredTextureBindingMutation +{ + private uint? _pendingRestore; + private bool _operationActive; + + internal bool HasPendingRestore => _pendingRestore.HasValue; + + public void Execute( + Func readBinding, + Action bind, + uint mutationBinding, + Action mutation) + { + ArgumentNullException.ThrowIfNull(readBinding); + ArgumentNullException.ThrowIfNull(bind); + ArgumentNullException.ThrowIfNull(mutation); + if (_operationActive) + throw new InvalidOperationException("A texture-binding mutation is already active."); + + _operationActive = true; + try + { + ExecuteCore(readBinding, bind, mutationBinding, mutation); + } + finally + { + _operationActive = false; + } + } + + private void ExecuteCore( + Func readBinding, + Action bind, + uint mutationBinding, + Action mutation) + { + if (_pendingRestore is uint pending) + { + bind(pending); + _pendingRestore = null; + } + + uint previousBinding = readBinding(); + _pendingRestore = previousBinding; + Exception? mutationFailure = null; + try + { + bind(mutationBinding); + mutation(); + } + catch (Exception failure) + { + mutationFailure = failure; + } + + try + { + bind(previousBinding); + _pendingRestore = null; + } + catch (Exception restoreFailure) + { + if (mutationFailure is not null) + throw new AggregateException( + "Texture mutation and binding restoration both failed.", + mutationFailure, + restoreFailure); + throw; + } + + if (mutationFailure is not null) + ExceptionDispatchInfo.Capture(mutationFailure).Throw(); + } +} diff --git a/src/AcDream.App/Rendering/RuntimeResourceSlots.cs b/src/AcDream.App/Rendering/RuntimeResourceSlots.cs new file mode 100644 index 00000000..1dda7c26 --- /dev/null +++ b/src/AcDream.App/Rendering/RuntimeResourceSlots.cs @@ -0,0 +1,171 @@ +namespace AcDream.App.Rendering; + +/// Retryable one-owner slot for a disposable runtime resource. +internal sealed class OwnedResourceSlot + where T : class, IDisposable +{ + private T? _resource; + private bool _operationActive; + + public bool HasResource => _resource is not null; + + public T Acquire(Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + if (_operationActive || _resource is not null) + throw new InvalidOperationException($"A {typeof(T).Name} is already owned."); + + _operationActive = true; + try + { + T resource = factory() + ?? throw new InvalidOperationException($"The {typeof(T).Name} factory returned null."); + _resource = resource; + return resource; + } + finally + { + _operationActive = false; + } + } + + public T Borrow() + { + if (_operationActive) + throw new InvalidOperationException($"The {typeof(T).Name} owner is changing state."); + return _resource + ?? throw new InvalidOperationException($"No {typeof(T).Name} is currently owned."); + } + + public void Release() + { + T? resource = _resource; + if (resource is null) + return; + if (_operationActive) + throw new InvalidOperationException($"The {typeof(T).Name} owner is changing state."); + + _operationActive = true; + try + { + resource.Dispose(); + if (ReferenceEquals(_resource, resource)) + _resource = null; + } + finally + { + _operationActive = false; + } + } + + internal void TransferOwnership(T expected) + { + ArgumentNullException.ThrowIfNull(expected); + if (_operationActive) + throw new InvalidOperationException($"The {typeof(T).Name} owner is changing state."); + if (!ReferenceEquals(_resource, expected)) + throw new InvalidOperationException( + $"The expected {typeof(T).Name} is not owned by this slot."); + _resource = null; + } +} + +/// +/// Owns a fallback resource until a complete destination owner factory +/// returns. A throwing factory leaves fallback ownership unchanged. +/// +internal sealed class TransferableResourceSlot + where T : class, IDisposable +{ + private readonly OwnedResourceSlot _fallback = new(); + private bool _operationActive; + private bool _prepared; + + public bool HasFallback => _fallback.HasResource; + + public T Acquire(Func factory) => Execute(() => + { + T resource = _fallback.Acquire(factory); + _prepared = true; + return resource; + }); + + public T AcquirePrepared(Func factory, Action prepare) => Execute(() => + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(prepare); + + T resource; + if (_fallback.HasResource) + { + resource = _fallback.Borrow(); + } + else + { + resource = _fallback.Acquire(factory); + _prepared = false; + } + + if (!_prepared) + { + prepare(resource); + _prepared = true; + } + + return resource; + }); + + public T Borrow() + { + if (_operationActive) + throw new InvalidOperationException("The transferable resource owner is changing state."); + return _fallback.Borrow(); + } + + public TOwner Transfer(Func destinationFactory) + where TOwner : class + { + return Execute(() => + { + ArgumentNullException.ThrowIfNull(destinationFactory); + if (!_prepared) + throw new InvalidOperationException( + "The transferable resource has not completed preparation."); + T resource = _fallback.Borrow(); + TOwner owner = destinationFactory(resource) + ?? throw new InvalidOperationException("The destination owner factory returned null."); + + // The destination factory contract transfers ownership of resource. + // Clear without disposing only after the complete owner exists. + _fallback.TransferOwnership(resource); + _prepared = false; + return owner; + }); + } + + public void ReleaseFallback() => Execute( + () => + { + _fallback.Release(); + if (!_fallback.HasResource) + _prepared = false; + return true; + }); + + private TResult Execute(Func operation) + { + ArgumentNullException.ThrowIfNull(operation); + if (_operationActive) + throw new InvalidOperationException("The transferable resource owner is changing state."); + + _operationActive = true; + try + { + return operation(); + } + finally + { + _operationActive = false; + } + } +} diff --git a/src/AcDream.App/Rendering/Shader.cs b/src/AcDream.App/Rendering/Shader.cs index fea7ff19..278a768b 100644 --- a/src/AcDream.App/Rendering/Shader.cs +++ b/src/AcDream.App/Rendering/Shader.cs @@ -7,36 +7,17 @@ public sealed class Shader : IDisposable { private readonly GL _gl; private readonly Dictionary _uniformLocations = new(StringComparer.Ordinal); - public uint Program { get; } + public uint Program { get; private set; } public Shader(GL gl, string vertexPath, string fragmentPath) { _gl = gl; - uint vert = Compile(File.ReadAllText(vertexPath), ShaderType.VertexShader); - uint frag = Compile(File.ReadAllText(fragmentPath), ShaderType.FragmentShader); - - Program = _gl.CreateProgram(); - _gl.AttachShader(Program, vert); - _gl.AttachShader(Program, frag); - _gl.LinkProgram(Program); - _gl.GetProgram(Program, ProgramPropertyARB.LinkStatus, out int linked); - if (linked == 0) - throw new Exception("program link failed: " + _gl.GetProgramInfoLog(Program)); - _gl.DetachShader(Program, vert); - _gl.DetachShader(Program, frag); - _gl.DeleteShader(vert); - _gl.DeleteShader(frag); - } - - private uint Compile(string source, ShaderType type) - { - uint id = _gl.CreateShader(type); - _gl.ShaderSource(id, source); - _gl.CompileShader(id); - _gl.GetShader(id, ShaderParameterName.CompileStatus, out int ok); - if (ok == 0) - throw new Exception($"{type} compile failed: " + _gl.GetShaderInfoLog(id)); - return id; + string vertexSource = File.ReadAllText(vertexPath); + string fragmentSource = File.ReadAllText(fragmentPath); + Program = ShaderProgramConstruction.Build( + new GlShaderProgramBuildApi(gl), + vertexSource, + fragmentSource); } public void Use() => _gl.UseProgram(Program); @@ -90,7 +71,12 @@ public sealed class Shader : IDisposable public void Dispose() { + uint program = Program; + if (program == 0) + return; + _uniformLocations.Clear(); - _gl.DeleteProgram(Program); + GlResourceCommand.DeleteProgram(_gl, program, $"delete Shader program {program}"); + Program = 0; } } diff --git a/src/AcDream.App/Rendering/ShaderProgramConstruction.cs b/src/AcDream.App/Rendering/ShaderProgramConstruction.cs new file mode 100644 index 00000000..a01f81ab --- /dev/null +++ b/src/AcDream.App/Rendering/ShaderProgramConstruction.cs @@ -0,0 +1,325 @@ +using System.Runtime.ExceptionServices; +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +internal interface IShaderProgramBuildApi +{ + uint CreateShader(ShaderType type); + void ShaderSource(uint shader, string source); + void CompileShader(uint shader); + int GetShaderCompileStatus(uint shader); + string GetShaderInfoLog(uint shader); + uint CreateProgram(); + void AttachShader(uint program, uint shader); + void LinkProgram(uint program); + int GetProgramLinkStatus(uint program); + string GetProgramInfoLog(uint program); + void DetachShader(uint program, uint shader); + void DeleteShader(uint shader); + void DeleteProgram(uint program); +} + +internal sealed class GlShaderProgramBuildApi(GL gl) : IShaderProgramBuildApi +{ + public uint CreateShader(ShaderType type) => + GlResourceCommand.CreateName( + gl, + $"{type} shader", + () => gl.CreateShader(type), + gl.DeleteShader); + + public void ShaderSource(uint shader, string source) => + GlResourceCommand.Execute( + gl, + $"upload shader source {shader}", + () => gl.ShaderSource(shader, source)); + + public void CompileShader(uint shader) => + GlResourceCommand.Execute( + gl, + $"compile shader {shader}", + () => gl.CompileShader(shader)); + + public void GetShader(uint shader, ShaderParameterName name, out int value) => + gl.GetShader(shader, name, out value); + + public int GetShaderCompileStatus(uint shader) + { + return GlResourceCommand.Execute( + gl, + $"read shader {shader} compile status", + () => + { + gl.GetShader(shader, ShaderParameterName.CompileStatus, out int value); + return value; + }); + } + + public string GetShaderInfoLog(uint shader) => + GlResourceCommand.Execute( + gl, + $"read shader {shader} info log", + () => gl.GetShaderInfoLog(shader)); + + public uint CreateProgram() => + GlResourceCommand.CreateName( + gl, + "shader program", + gl.CreateProgram, + gl.DeleteProgram); + + public void AttachShader(uint program, uint shader) => + GlResourceCommand.Execute( + gl, + $"attach shader {shader} to program {program}", + () => gl.AttachShader(program, shader)); + + public void LinkProgram(uint program) => + GlResourceCommand.Execute( + gl, + $"link shader program {program}", + () => gl.LinkProgram(program)); + + public int GetProgramLinkStatus(uint program) + { + return GlResourceCommand.Execute( + gl, + $"read shader program {program} link status", + () => + { + gl.GetProgram(program, ProgramPropertyARB.LinkStatus, out int value); + return value; + }); + } + + public string GetProgramInfoLog(uint program) => + GlResourceCommand.Execute( + gl, + $"read shader program {program} info log", + () => gl.GetProgramInfoLog(program)); + + public void DetachShader(uint program, uint shader) => + GlResourceCommand.Execute( + gl, + $"detach shader {shader} from program {program}", + () => gl.DetachShader(program, shader)); + + public void DeleteShader(uint shader) => + GlResourceCommand.DeleteShader(gl, shader, $"delete shader {shader}"); + + public void DeleteProgram(uint program) => + GlResourceCommand.DeleteProgram(gl, program, $"delete shader program {program}"); +} + +internal sealed class ShaderProgramCleanup(IShaderProgramBuildApi api) + : IRetryableResourceCleanup +{ + public uint Vertex { get; set; } + public uint Fragment { get; set; } + public uint Program { get; set; } + public bool VertexAttached { get; set; } + public bool FragmentAttached { get; set; } + + public bool IsCleanupComplete => Vertex == 0 && Fragment == 0 && Program == 0; + + public uint TransferProgram() + { + if (Vertex != 0 || Fragment != 0 || Program == 0) + throw new InvalidOperationException("The linked program is not ready for ownership transfer."); + uint program = Program; + Program = 0; + return program; + } + + public List Release(bool includeProgram) + { + var failures = new List(); + Try( + () => + { + if (Program != 0 && Vertex != 0 && VertexAttached) + { + api.DetachShader(Program, Vertex); + VertexAttached = false; + } + }, + failures); + Try( + () => + { + if (Program != 0 && Fragment != 0 && FragmentAttached) + { + api.DetachShader(Program, Fragment); + FragmentAttached = false; + } + }, + failures); + Try( + () => + { + if (Vertex != 0) + { + api.DeleteShader(Vertex); + Vertex = 0; + VertexAttached = false; + } + }, + failures); + Try( + () => + { + if (Fragment != 0) + { + api.DeleteShader(Fragment); + Fragment = 0; + FragmentAttached = false; + } + }, + failures); + if (includeProgram) + { + Try( + () => + { + if (Program != 0) + { + api.DeleteProgram(Program); + Program = 0; + VertexAttached = false; + FragmentAttached = false; + } + }, + failures); + } + + return failures; + } + + public List ReleaseProgramOnly() + { + var failures = new List(); + Try( + () => + { + if (Program != 0) + { + api.DeleteProgram(Program); + Program = 0; + VertexAttached = false; + FragmentAttached = false; + } + }, + failures); + return failures; + } + + public void RetryCleanup() + { + List failures = Release(includeProgram: true); + if (!IsCleanupComplete) + { + throw new AggregateException( + "Shader program cleanup remains incomplete.", + failures.Count == 0 + ? [new InvalidOperationException("Shader cleanup retained names without reporting an operation failure.")] + : failures); + } + } + + private static void Try(Action operation, List failures) + { + try + { + operation(); + } + catch (Exception failure) + { + failures.Add(failure); + } + } +} + +internal static class ShaderProgramConstruction +{ + public static uint Build( + IShaderProgramBuildApi api, + string vertexSource, + string fragmentSource) + { + ArgumentNullException.ThrowIfNull(api); + ArgumentNullException.ThrowIfNull(vertexSource); + ArgumentNullException.ThrowIfNull(fragmentSource); + + var cleanup = new ShaderProgramCleanup(api); + Exception? constructionFailure = null; + + try + { + cleanup.Vertex = CreateShader(api, ShaderType.VertexShader); + Compile(api, cleanup.Vertex, ShaderType.VertexShader, vertexSource); + cleanup.Fragment = CreateShader(api, ShaderType.FragmentShader); + Compile(api, cleanup.Fragment, ShaderType.FragmentShader, fragmentSource); + cleanup.Program = api.CreateProgram(); + if (cleanup.Program == 0) + throw new InvalidOperationException("OpenGL returned no shader program name."); + + api.AttachShader(cleanup.Program, cleanup.Vertex); + cleanup.VertexAttached = true; + api.AttachShader(cleanup.Program, cleanup.Fragment); + cleanup.FragmentAttached = true; + api.LinkProgram(cleanup.Program); + if (api.GetProgramLinkStatus(cleanup.Program) == 0) + throw new InvalidOperationException( + "program link failed: " + api.GetProgramInfoLog(cleanup.Program)); + } + catch (Exception failure) + { + constructionFailure = failure; + } + + List cleanupFailures = cleanup.Release(includeProgram: false); + + if (constructionFailure is null && cleanupFailures.Count == 0) + return cleanup.TransferProgram(); + + cleanupFailures.AddRange(cleanup.ReleaseProgramOnly()); + + if (constructionFailure is not null && cleanupFailures.Count == 0) + ExceptionDispatchInfo.Capture(constructionFailure).Throw(); + + var failures = new List(cleanupFailures.Count + 1); + if (constructionFailure is not null) + failures.Add(constructionFailure); + failures.AddRange(cleanupFailures); + const string message = + "Shader program construction failed and one or more temporary OpenGL names could not be cleanly released."; + if (!cleanup.IsCleanupComplete) + throw new GlResourceConstructionException(message, cleanup, failures); + throw new AggregateException(message, failures); + } + + private static uint CreateShader( + IShaderProgramBuildApi api, + ShaderType type) + { + uint shader = api.CreateShader(type); + if (shader == 0) + throw new InvalidOperationException($"OpenGL returned no {type} name."); + return shader; + } + + private static void Compile( + IShaderProgramBuildApi api, + uint shader, + ShaderType type, + string source) + { + api.ShaderSource(shader, source); + api.CompileShader(shader); + if (api.GetShaderCompileStatus(shader) == 0) + throw new InvalidOperationException( + $"{type} compile failed: " + api.GetShaderInfoLog(shader)); + } +} diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs index 57d6b0a2..e4c7ad59 100644 --- a/src/AcDream.App/Rendering/TerrainAtlas.cs +++ b/src/AcDream.App/Rendering/TerrainAtlas.cs @@ -61,12 +61,10 @@ public sealed unsafe class TerrainAtlas : IDisposable public IReadOnlyList RoadAlphaRCodes { get; } private readonly Wb.BindlessSupport? _bindless; - - // Cached bindless handles. Generated lazily on first GetBindlessHandles() call; - // reused for the lifetime of the atlas. - private ulong _terrainHandle; - private ulong _alphaHandle; - private bool _handlesGenerated; + private readonly BindlessTexturePair? _bindlessHandles; + private readonly BindlessTextureMutationGuard? _bindlessMutation; + private readonly RestoredTextureBindingMutation _anisotropyBindingMutation = new(); + private ResourceShutdownTransaction? _shutdown; /// /// Get 64-bit bindless handles for the terrain + alpha texture arrays. @@ -80,13 +78,8 @@ public sealed unsafe class TerrainAtlas : IDisposable if (_bindless is null) throw new InvalidOperationException( "TerrainAtlas was constructed without BindlessSupport; cannot return bindless handles."); - if (!_handlesGenerated) - { - _terrainHandle = _bindless.GetResidentHandle(GlTexture); - _alphaHandle = _bindless.GetResidentHandle(GlAlphaTexture); - _handlesGenerated = true; - } - return (_terrainHandle, _alphaHandle); + (ulong terrain, ulong alpha) = _bindlessHandles!.Acquire(); + return (terrain, alpha); } private TerrainAtlas( @@ -112,6 +105,15 @@ public sealed unsafe class TerrainAtlas : IDisposable CornerAlphaTCodes = cornerTCodes; SideAlphaTCodes = sideTCodes; RoadAlphaRCodes = roadRCodes; + if (bindless is not null) + { + _bindlessHandles = new BindlessTexturePair( + glTexture, + glAlphaTexture, + bindless.GetResidentHandle, + bindless.MakeNonResident); + _bindlessMutation = new BindlessTextureMutationGuard(_bindlessHandles); + } } /// @@ -120,6 +122,37 @@ public sealed unsafe class TerrainAtlas : IDisposable /// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY. /// public static TerrainAtlas Build(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null) + { + var textures = new GlTextureConstructionTransaction(new GlTextureNameApi(gl)); + try + { + TerrainAtlas atlas = BuildCore(gl, dats, bindless, textures); + textures.Commit(); + return atlas; + } + catch (Exception constructionFailure) + { + try + { + textures.Rollback(); + } + catch (Exception cleanupFailure) + { + throw new GlResourceConstructionException( + "TerrainAtlas construction failed and its allocated OpenGL texture names did not cleanly roll back.", + textures, + [constructionFailure, cleanupFailure]); + } + + throw; + } + } + + private static TerrainAtlas BuildCore( + GL gl, + IDatReaderWriter dats, + Wb.BindlessSupport? bindless, + GlTextureConstructionTransaction textures) { var region = dats.Get(0x13000000u) ?? throw new InvalidOperationException("Region dat id 0x13000000 missing"); @@ -129,7 +162,7 @@ public sealed unsafe class TerrainAtlas : IDisposable if (terrainDesc is null || terrainDesc.Count == 0) { Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer"); - return BuildFallback(gl, bindless); + return BuildFallback(gl, bindless, textures); } // ---- Terrain atlas (unchanged Phase 2b logic) ---- @@ -179,29 +212,45 @@ public sealed unsafe class TerrainAtlas : IDisposable } int layerCount = decodedByType.Count; - uint tex = gl.GenTexture(); - gl.BindTexture(TextureTarget.Texture2DArray, tex); - gl.TexImage3D( - TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, - (uint)maxW, (uint)maxH, (uint)layerCount, - 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); - var map = new Dictionary(); - int layerIdx = 0; - foreach (var kvp in decodedByType) + uint tex = TrackedTextureConstruction.Create( + textures, + gl, + "upload terrain atlas texture array", + texture => { - byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH); - fixed (byte* p = buffer) + gl.BindTexture(TextureTarget.Texture2DArray, texture); + gl.TexImage3D( + TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, + (uint)maxW, (uint)maxH, (uint)layerCount, + 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); + + int layerIdx = 0; + foreach (var kvp in decodedByType) { - gl.TexSubImage3D( - TextureTarget.Texture2DArray, 0, - 0, 0, layerIdx, - (uint)maxW, (uint)maxH, 1, - GLPixelFormat.Rgba, PixelType.UnsignedByte, p); + byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH); + fixed (byte* p = buffer) + { + gl.TexSubImage3D( + TextureTarget.Texture2DArray, 0, + 0, 0, layerIdx, + (uint)maxW, (uint)maxH, 1, + GLPixelFormat.Rgba, PixelType.UnsignedByte, p); + } + map[kvp.Key] = (uint)layerIdx; + layerIdx++; } - map[kvp.Key] = (uint)layerIdx; - layerIdx++; - } + + // A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality. + gl.GenerateMipmap(TextureTarget.Texture2DArray); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); + // GL_TEXTURE_MAX_ANISOTROPY = 0x84FE (GL_EXT_texture_filter_anisotropic / ARB_texture_filter_anisotropic). + gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, 16.0f); + gl.BindTexture(TextureTarget.Texture2DArray, 0); + }); var tilingByLayer = TerrainTextureTilingTable.Build( map.Select(entry => @@ -209,22 +258,12 @@ public sealed unsafe class TerrainAtlas : IDisposable ? repeatCount : 1u))); - // A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality. - gl.GenerateMipmap(TextureTarget.Texture2DArray); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); - // GL_TEXTURE_MAX_ANISOTROPY = 0x84FE (GL_EXT_texture_filter_anisotropic / ARB_texture_filter_anisotropic). - gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, 16.0f); - gl.BindTexture(TextureTarget.Texture2DArray, 0); - Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} (mipmaps+aniso16x)"); // ---- Alpha atlas (new in Phase 3c.2) ---- // texMerge is guaranteed non-null here: the early return above exited // if texMerge?.TerrainDesc was null. - var alphaBuild = BuildAlphaAtlas(gl, dats, texMerge!); + var alphaBuild = BuildAlphaAtlas(gl, dats, texMerge!, textures); return new TerrainAtlas( gl, @@ -252,7 +291,10 @@ public sealed unsafe class TerrainAtlas : IDisposable IReadOnlyList cornerTCodes, IReadOnlyList sideTCodes, IReadOnlyList roadRCodes); private static AlphaAtlasBuildResult BuildAlphaAtlas( - GL gl, IDatReaderWriter dats, DatReaderWriter.Types.TexMerge texMerge) + GL gl, + IDatReaderWriter dats, + DatReaderWriter.Types.TexMerge texMerge, + GlTextureConstructionTransaction textures) { var decoded = new List(); var cornerLayers = new List(); @@ -305,15 +347,21 @@ public sealed unsafe class TerrainAtlas : IDisposable if (decoded.Count == 0) { Console.WriteLine("WARN: no alpha maps loaded; alpha atlas will be a 1x1 white fallback"); - uint fallbackAlpha = gl.GenTexture(); - gl.BindTexture(TextureTarget.Texture2DArray, fallbackAlpha); - gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, - GLPixelFormat.Rgba, PixelType.UnsignedByte, null); - var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }; - fixed (byte* p = white) - gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, - GLPixelFormat.Rgba, PixelType.UnsignedByte, p); - gl.BindTexture(TextureTarget.Texture2DArray, 0); + uint fallbackAlpha = TrackedTextureConstruction.Create( + textures, + gl, + "upload fallback terrain alpha texture array", + texture => + { + gl.BindTexture(TextureTarget.Texture2DArray, texture); + gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, + GLPixelFormat.Rgba, PixelType.UnsignedByte, null); + var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }; + fixed (byte* p = white) + gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, + GLPixelFormat.Rgba, PixelType.UnsignedByte, p); + gl.BindTexture(TextureTarget.Texture2DArray, 0); + }); return new AlphaAtlasBuildResult( fallbackAlpha, 1, cornerLayers, sideLayers, roadLayers, @@ -329,31 +377,37 @@ public sealed unsafe class TerrainAtlas : IDisposable if (d.Height > aMaxH) aMaxH = d.Height; } - uint glAlpha = gl.GenTexture(); - gl.BindTexture(TextureTarget.Texture2DArray, glAlpha); - gl.TexImage3D( - TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, - (uint)aMaxW, (uint)aMaxH, (uint)decoded.Count, - 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); - - for (int i = 0; i < decoded.Count; i++) + uint glAlpha = TrackedTextureConstruction.Create( + textures, + gl, + "upload terrain alpha texture array", + texture => { - var buffer = ResizeRgba8Nearest(decoded[i], aMaxW, aMaxH); - fixed (byte* p = buffer) - { - gl.TexSubImage3D( - TextureTarget.Texture2DArray, 0, - 0, 0, i, - (uint)aMaxW, (uint)aMaxH, 1, - GLPixelFormat.Rgba, PixelType.UnsignedByte, p); - } - } + gl.BindTexture(TextureTarget.Texture2DArray, texture); + gl.TexImage3D( + TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, + (uint)aMaxW, (uint)aMaxH, (uint)decoded.Count, + 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge); - gl.BindTexture(TextureTarget.Texture2DArray, 0); + for (int i = 0; i < decoded.Count; i++) + { + var buffer = ResizeRgba8Nearest(decoded[i], aMaxW, aMaxH); + fixed (byte* p = buffer) + { + gl.TexSubImage3D( + TextureTarget.Texture2DArray, 0, + 0, 0, i, + (uint)aMaxW, (uint)aMaxH, 1, + GLPixelFormat.Rgba, PixelType.UnsignedByte, p); + } + } + + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge); + gl.BindTexture(TextureTarget.Texture2DArray, 0); + }); Console.WriteLine( $"AlphaAtlas: {decoded.Count} layers at {aMaxW}x{aMaxH} " @@ -412,25 +466,40 @@ public sealed unsafe class TerrainAtlas : IDisposable return dst; } - private static TerrainAtlas BuildFallback(GL gl, Wb.BindlessSupport? bindless = null) + private static TerrainAtlas BuildFallback( + GL gl, + Wb.BindlessSupport? bindless, + GlTextureConstructionTransaction textures) { - uint tex = gl.GenTexture(); - gl.BindTexture(TextureTarget.Texture2DArray, tex); var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }; - gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); - fixed (byte* p = white) - gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); - gl.BindTexture(TextureTarget.Texture2DArray, 0); + uint tex = TrackedTextureConstruction.Create( + textures, + gl, + "upload fallback terrain texture array", + texture => + { + gl.BindTexture(TextureTarget.Texture2DArray, texture); + gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); + fixed (byte* p = white) + gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); + gl.BindTexture(TextureTarget.Texture2DArray, 0); + }); // Fallback alpha atlas: 1x1 white, no layers tracked - uint alphaTex = gl.GenTexture(); - gl.BindTexture(TextureTarget.Texture2DArray, alphaTex); - gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); - fixed (byte* p = white) - gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p); - gl.BindTexture(TextureTarget.Texture2DArray, 0); + uint alphaTex = TrackedTextureConstruction.Create( + textures, + gl, + "upload fallback alpha texture array", + texture => + { + gl.BindTexture(TextureTarget.Texture2DArray, texture); + gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); + fixed (byte* p = white) + gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p); + gl.BindTexture(TextureTarget.Texture2DArray, 0); + }); return new TerrainAtlas( gl, @@ -453,46 +522,65 @@ public sealed unsafe class TerrainAtlas : IDisposable /// public void SetAnisotropic(int level) { - // If bindless handles are live we must make them non-resident before - // mutating texture state, then re-resident after. - bool wasResident = _handlesGenerated && _bindless is not null; - if (wasResident) + void Mutate() { - _bindless!.MakeNonResident(_terrainHandle); - // Alpha texture is not affected by anisotropic but we must keep - // residency symmetric — re-generate both handles after. - _bindless.MakeNonResident(_alphaHandle); - _handlesGenerated = false; + _anisotropyBindingMutation.Execute( + () => unchecked((uint)GlResourceCommand.Execute( + _gl, + "read terrain-array binding before anisotropy mutation", + () => _gl.GetInteger(GetPName.TextureBinding2DArray))), + binding => GlResourceCommand.Execute( + _gl, + "set terrain-array binding for anisotropy mutation", + () => _gl.BindTexture(TextureTarget.Texture2DArray, binding)), + GlTexture, + () => GlResourceCommand.Execute( + _gl, + "set terrain atlas anisotropy", + () => + { + // GL_TEXTURE_MAX_ANISOTROPY = 0x84FE + _gl.TexParameter( + TextureTarget.Texture2DArray, + (TextureParameterName)0x84FE, + (float)level); + })); } - _gl.BindTexture(TextureTarget.Texture2DArray, GlTexture); - // GL_TEXTURE_MAX_ANISOTROPY = 0x84FE - _gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, (float)level); - _gl.BindTexture(TextureTarget.Texture2DArray, 0); - - // Re-generate bindless handles if they were live before. - if (wasResident) - { - // GetBindlessHandles regenerates and makes resident. - _ = GetBindlessHandles(); - } + if (_bindlessMutation is not null) + _bindlessMutation.Execute(Mutate); + else + Mutate(); Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x"); } public void Dispose() { - // Phase 1: release bindless residency BEFORE deleting textures. - // ARB_bindless_texture requires this ordering; interleaving is UB. - if (_handlesGenerated && _bindless is not null) - { - _bindless.MakeNonResident(_terrainHandle); - _bindless.MakeNonResident(_alphaHandle); - _handlesGenerated = false; - } - - // Phase 2: delete the underlying GL textures. - _gl.DeleteTexture(GlTexture); - _gl.DeleteTexture(GlAlphaTexture); + _shutdown ??= new ResourceShutdownTransaction( + new ResourceShutdownStage( + "terrain atlas bindless residency", + [ + new ResourceShutdownOperation( + "release terrain and alpha handles", + () => _bindlessHandles?.Release()), + ]), + new ResourceShutdownStage( + "terrain atlas textures", + [ + new ResourceShutdownOperation( + "delete terrain texture", + () => GlResourceCommand.DeleteTexture( + _gl, + GlTexture, + $"delete terrain atlas texture {GlTexture}")), + new ResourceShutdownOperation( + "delete alpha texture", + () => GlResourceCommand.DeleteTexture( + _gl, + GlAlphaTexture, + $"delete terrain alpha texture {GlAlphaTexture}")), + ])); + _shutdown.CompleteOrThrow(); } } diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 096dd929..18f8862b 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -26,6 +26,7 @@ public sealed unsafe class TextRenderer : IDisposable private readonly GL _gl; private readonly ITextRenderGlStateApi _glState; private readonly Shader _shader; + private readonly ResourceCleanupGroup _resources; private uint _vao; private uint _vbo; private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket @@ -39,7 +40,7 @@ public sealed unsafe class TextRenderer : IDisposable public int UsedBytes; } - private readonly FrameBufferSet[] _frameBuffers = new FrameBufferSet[3]; + private readonly FrameBufferSet[] _frameBuffers; private FrameBufferSet? _activeFrameBuffer; internal long DynamicBufferCapacityBytes => @@ -80,26 +81,70 @@ public sealed unsafe class TextRenderer : IDisposable { _gl = gl; _glState = new SilkTextRenderGlStateApi(gl); - _shader = new Shader(gl, - Path.Combine(shaderDir, "ui_text.vert"), - Path.Combine(shaderDir, "ui_text.frag")); + var resources = new ResourceCleanupGroup(); + Shader? shader = null; + var frameBuffers = new FrameBufferSet[3]; + uint whiteTexture = 0; + try + { + shader = new Shader(gl, + Path.Combine(shaderDir, "ui_text.vert"), + Path.Combine(shaderDir, "ui_text.frag")); + resources.Add("text shader", shader.Dispose); - for (int i = 0; i < _frameBuffers.Length; i++) - _frameBuffers[i] = CreateFrameBufferSet(); + for (int i = 0; i < frameBuffers.Length; i++) + frameBuffers[i] = CreateFrameBufferSet(resources); - // 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE - // bucket (the shader multiplies texel×color → white×color = color). Lets a panel - // background draw UNDER its text in painter order, which DrawRect's separate - // bucket cannot (it always composites after all sprites). - _whiteTex = _gl.GenTexture(); - _gl.BindTexture(TextureTarget.Texture2D, _whiteTex); - Span whitePixel = stackalloc byte[] { 255, 255, 255, 255 }; - fixed (byte* wp = whitePixel) - _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0, - PixelFormat.Rgba, PixelType.UnsignedByte, wp); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest); - _gl.BindTexture(TextureTarget.Texture2D, 0); + // 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE + // bucket (the shader multiplies texel×color → white×color = color). Lets a panel + // background draw UNDER its text in painter order, which DrawRect's separate + // bucket cannot (it always composites after all sprites). + whiteTexture = GlResourceCommand.CreateTexture( + _gl, + "TextRenderer white texture"); + uint ownedWhiteTexture = whiteTexture; + resources.Add( + "white texture", + () => GlResourceCommand.DeleteTexture( + _gl, + ownedWhiteTexture, + $"delete TextRenderer white texture {ownedWhiteTexture}")); + GlResourceCommand.Execute( + _gl, + "initialize TextRenderer white texture", + () => + { + _gl.BindTexture(TextureTarget.Texture2D, whiteTexture); + Span whitePixel = stackalloc byte[] { 255, 255, 255, 255 }; + fixed (byte* wp = whitePixel) + _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0, + PixelFormat.Rgba, PixelType.UnsignedByte, wp); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest); + _gl.BindTexture(TextureTarget.Texture2D, 0); + }); + } + catch (Exception constructionFailure) + { + try + { + resources.RetryCleanup(); + } + catch (Exception cleanupFailure) + { + throw new GlResourceConstructionException( + "TextRenderer construction failed and its published GL resources did not cleanly roll back.", + resources, + [constructionFailure, cleanupFailure]); + } + + throw; + } + + _resources = resources; + _shader = shader; + _frameBuffers = frameBuffers; + _whiteTex = whiteTexture; } /// @@ -120,43 +165,54 @@ public sealed unsafe class TextRenderer : IDisposable _vboCapacityBytes = set.CapacityBytes; } - private FrameBufferSet CreateFrameBufferSet() + private FrameBufferSet CreateFrameBufferSet(ResourceCleanupGroup resources) { - uint vao = 0; - uint vbo = 0; - try - { - vao = TrackedGlResource.CreateVertexArray( + uint vao = TrackedGlResource.CreateVertexArray( + _gl, + "TextRenderer frame VAO creation"); + RetryableGpuResourceRelease vaoRelease = + TrackedGlResource.CreateRetryableVertexArrayDeletion( _gl, - "TextRenderer frame VAO creation"); - vbo = TrackedGlResource.CreateBuffer( - _gl, - "TextRenderer frame VBO creation"); - var set = new FrameBufferSet { Vao = vao, Vbo = vbo }; + vao, + "TextRenderer frame VAO disposal"); + resources.Add("frame VAO", vaoRelease.Run); + var set = new FrameBufferSet { Vao = vao }; - _gl.BindVertexArray(set.Vao); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo); - uint stride = FloatsPerVertex * sizeof(float); - _gl.EnableVertexAttribArray(0); - _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0); - _gl.EnableVertexAttribArray(1); - _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float))); - _gl.EnableVertexAttribArray(2); - _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float))); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); - _gl.BindVertexArray(0); - return set; - } - catch - { - if (vbo != 0) - TrackedGlResource.DeleteBuffer( - _gl, vbo, 0, "TextRenderer frame VBO rollback"); - if (vao != 0) - TrackedGlResource.DeleteVertexArray( - _gl, vao, "TextRenderer frame VAO rollback"); - throw; - } + uint vbo = TrackedGlResource.CreateBuffer( + _gl, + "TextRenderer frame VBO creation"); + set.Vbo = vbo; + RetryableGpuResourceRelease? vboRelease = null; + resources.Add( + "frame VBO", + () => + { + vboRelease ??= TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + vbo, + set.CapacityBytes, + "TextRenderer frame VBO disposal"); + vboRelease.Run(); + }); + + GlResourceCommand.Execute( + _gl, + "initialize TextRenderer frame VAO and VBO", + () => + { + _gl.BindVertexArray(set.Vao); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo); + uint stride = FloatsPerVertex * sizeof(float); + _gl.EnableVertexAttribArray(0); + _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0); + _gl.EnableVertexAttribArray(1); + _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float))); + _gl.EnableVertexAttribArray(2); + _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float))); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + _gl.BindVertexArray(0); + }); + return set; } /// Begin a HUD pass. Call once per frame before any Draw* calls. @@ -471,19 +527,6 @@ public sealed unsafe class TextRenderer : IDisposable public void Dispose() { - _gl.DeleteTexture(_whiteTex); - foreach (FrameBufferSet set in _frameBuffers) - { - TrackedGlResource.DeleteBuffer( - _gl, - set.Vbo, - set.CapacityBytes, - "TextRenderer frame VBO disposal"); - TrackedGlResource.DeleteVertexArray( - _gl, - set.Vao, - "TextRenderer frame VAO disposal"); - } - _shader.Dispose(); + _resources.RetryCleanup(); } } diff --git a/src/AcDream.App/Rendering/TrackedTextureConstruction.cs b/src/AcDream.App/Rendering/TrackedTextureConstruction.cs new file mode 100644 index 00000000..5d19a0a9 --- /dev/null +++ b/src/AcDream.App/Rendering/TrackedTextureConstruction.cs @@ -0,0 +1,31 @@ +namespace AcDream.App.Rendering; + +internal static class TrackedTextureConstruction +{ + public static uint Create( + GlTextureConstructionTransaction transaction, + Action initialize) + { + ArgumentNullException.ThrowIfNull(transaction); + ArgumentNullException.ThrowIfNull(initialize); + uint texture = transaction.Allocate(); + initialize(texture); + return texture; + } + + public static uint Create( + GlTextureConstructionTransaction transaction, + Silk.NET.OpenGL.GL gl, + string context, + Action initialize) + { + ArgumentNullException.ThrowIfNull(transaction); + ArgumentNullException.ThrowIfNull(gl); + ArgumentException.ThrowIfNullOrWhiteSpace(context); + ArgumentNullException.ThrowIfNull(initialize); + + uint texture = transaction.Allocate(); + GlResourceCommand.Execute(gl, context, () => initialize(texture)); + return texture; + } +} diff --git a/src/AcDream.App/Rendering/Wb/BindlessSupport.cs b/src/AcDream.App/Rendering/Wb/BindlessSupport.cs index 64dda3cb..0047b1d0 100644 --- a/src/AcDream.App/Rendering/Wb/BindlessSupport.cs +++ b/src/AcDream.App/Rendering/Wb/BindlessSupport.cs @@ -1,5 +1,6 @@ using Silk.NET.OpenGL; using Silk.NET.OpenGL.Extensions.ARB; +using AcDream.App.Rendering; namespace AcDream.App.Rendering.Wb; @@ -10,10 +11,12 @@ namespace AcDream.App.Rendering.Wb; /// public sealed class BindlessSupport { + private readonly GL _gl; private readonly ArbBindlessTexture _ext; - private BindlessSupport(ArbBindlessTexture extension) + private BindlessSupport(GL gl, ArbBindlessTexture extension) { + _gl = gl; _ext = extension; } @@ -21,7 +24,7 @@ public sealed class BindlessSupport { if (gl.TryGetExtension(out var ext)) { - support = new BindlessSupport(ext); + support = new BindlessSupport(gl, ext); return true; } support = null; @@ -32,17 +35,42 @@ public sealed class BindlessSupport /// Idempotent: handle is the same for a given texture name. public ulong GetResidentHandle(uint textureName) { - ulong h = _ext.GetTextureHandle(textureName); - if (!_ext.IsTextureHandleResident(h)) - _ext.MakeTextureHandleResident(h); + ulong h = GlResourceCommand.Execute( + _gl, + $"get bindless handle for texture {textureName}", + () => _ext.GetTextureHandle(textureName)); + if (h == 0) + throw new InvalidOperationException( + $"OpenGL returned no bindless handle for texture {textureName}."); + + bool resident = GlResourceCommand.Execute( + _gl, + $"query bindless handle {h} residency", + () => _ext.IsTextureHandleResident(h)); + if (!resident) + { + GlResourceCommand.Execute( + _gl, + $"make bindless handle {h} resident", + () => _ext.MakeTextureHandleResident(h)); + } return h; } /// Release residency for a handle. Call before deleting the underlying texture. public void MakeNonResident(ulong handle) { - if (_ext.IsTextureHandleResident(handle)) - _ext.MakeTextureHandleNonResident(handle); + bool resident = GlResourceCommand.Execute( + _gl, + $"query bindless handle {handle} residency before release", + () => _ext.IsTextureHandleResident(handle)); + if (!resident) + return; + + GlResourceCommand.Execute( + _gl, + $"make bindless handle {handle} non-resident", + () => _ext.MakeTextureHandleNonResident(handle)); } // Phase N.5b note: a `SetSamplerHandleUniform` wrapper was added in T6 diff --git a/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs b/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs index f442268a..14b43d3f 100644 --- a/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs +++ b/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs @@ -1,5 +1,6 @@ using Silk.NET.OpenGL; using AcDream.App.Rendering; +using System.Runtime.ExceptionServices; namespace AcDream.App.Rendering.Wb; @@ -96,41 +97,68 @@ internal static unsafe class TrackedGlResource public static uint CreateBuffer(GL gl, string context) { - GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); - uint name = gl.GenBuffer(); - try - { - if (name == 0) - throw new InvalidOperationException($"OpenGL returned buffer name zero. Context: {context}"); - GLHelpers.ThrowOnResourceError(gl, context); - GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer); - return name; - } - catch - { - if (name != 0) - gl.DeleteBuffer(name); - throw; - } + return CreateTrackedName( + gl, + "buffer", + context, + gl.GenBuffer, + name => GlResourceCommand.DeleteBuffer( + gl, + name, + $"rollback buffer {name} after failed {context}"), + () => GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer)); } public static uint CreateVertexArray(GL gl, string context) { - GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); - uint name = gl.GenVertexArray(); + return CreateTrackedName( + gl, + "vertex-array", + context, + gl.GenVertexArray, + name => GlResourceCommand.DeleteVertexArray( + gl, + name, + $"rollback vertex array {name} after failed {context}"), + () => GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO)); + } + + private static uint CreateTrackedName( + GL gl, + string resourceName, + string context, + Func create, + Action rollback, + Action publishAccounting) + { + uint name = GlResourceCommand.CreateName( + gl, + $"{resourceName} for {context}", + create, + rollback); + var cleanup = new ResourceCleanupGroup(); + cleanup.Add($"{resourceName} name {name}", () => rollback(name)); try { - if (name == 0) - throw new InvalidOperationException($"OpenGL returned vertex-array name zero. Context: {context}"); - GLHelpers.ThrowOnResourceError(gl, context); - GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO); + publishAccounting(); return name; } - catch + catch (Exception publicationFailure) { - if (name != 0) - gl.DeleteVertexArray(name); - throw; + try + { + cleanup.RetryCleanup(); + } + catch (Exception cleanupFailure) + { + throw new GlResourceConstructionException( + $"Publishing {resourceName} accounting failed and GL name {name} could not be released.", + cleanup, + [publicationFailure, cleanupFailure]); + } + + ExceptionDispatchInfo.Capture(publicationFailure).Throw(); + throw new InvalidOperationException("Unreachable resource-publication path."); } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 5d59a1cd..17b46f33 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -202,6 +202,8 @@ public sealed class RetailUiRuntime : IDisposable private ResourceShutdownTransaction? _shutdown; private bool _disposed; + internal bool IsDisposalComplete => _disposed; + private RetailUiRuntime(RetailUiRuntimeBindings bindings) { _bindings = bindings; @@ -211,6 +213,15 @@ public sealed class RetailUiRuntime : IDisposable bindings.Host.HideWindow); } + internal static RetailUiRuntime CreateUninitialized( + RetailUiRuntimeBindings bindings) + { + ArgumentNullException.ThrowIfNull(bindings); + return new RetailUiRuntime(bindings); + } + + internal void InitializeForLease() => Initialize(); + private void Initialize() { RetailUiRuntimeBindings bindings = _bindings; @@ -298,7 +309,7 @@ public sealed class RetailUiRuntime : IDisposable public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) { ArgumentNullException.ThrowIfNull(bindings); - var runtime = new RetailUiRuntime(bindings); + var runtime = CreateUninitialized(bindings); try { runtime.Initialize(); diff --git a/src/AcDream.App/UI/RetailUiRuntimeLease.cs b/src/AcDream.App/UI/RetailUiRuntimeLease.cs new file mode 100644 index 00000000..b7f44bff --- /dev/null +++ b/src/AcDream.App/UI/RetailUiRuntimeLease.cs @@ -0,0 +1,263 @@ +namespace AcDream.App.UI; + +/// +/// Retains one UiHost/runtime ownership chain across partial construction, +/// initialization cleanup, lifetime retry, and explicit terminal abandonment. +/// +internal sealed class RetailUiRuntimeLease : IDisposable +{ + private object? _host; + private Action? _quiesceInput; + private Action? _deactivateInput; + private Action? _disposeHost; + private Func? _hostDisposalComplete; + + private object? _runtime; + private Action? _disposeRuntime; + private Func? _runtimeDisposalComplete; + + private bool _disposalFailed; + private bool _inputQuiesced; + private bool _inputDeactivated; + private bool _operationActive; + private bool _disposed; + private bool _abandoned; + + public bool IsDisposalComplete => _disposed; + + public bool IsAbandoned => _abandoned; + + internal bool RetainsResources => _host is not null || _runtime is not null; + + public UiHost AcquireHost(Func factory) => AcquireHostCore( + factory, + static host => host.QuiesceInput(), + static host => host.DeactivateInput(), + static host => host.Dispose(), + static host => host.IsDisposalComplete); + + internal T AcquireHostCore( + Func factory, + Action quiesceInput, + Action deactivateInput, + Action dispose, + Func disposalComplete) + where T : class + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(quiesceInput); + ArgumentNullException.ThrowIfNull(deactivateInput); + ArgumentNullException.ThrowIfNull(dispose); + ArgumentNullException.ThrowIfNull(disposalComplete); + ThrowIfUnavailable(); + if (_operationActive || _host is not null) + throw new InvalidOperationException("A retained UI host is already owned."); + + _operationActive = true; + try + { + T host = factory() + ?? throw new InvalidOperationException("The retained UI host factory returned null."); + _host = host; + _quiesceInput = () => quiesceInput(host); + _deactivateInput = () => deactivateInput(host); + _disposeHost = () => dispose(host); + _hostDisposalComplete = () => disposalComplete(host); + return host; + } + finally + { + _operationActive = false; + } + } + + public RetailUiRuntime Mount(Func factory) => MountCore( + factory, + static runtime => runtime.InitializeForLease(), + static runtime => runtime.Dispose(), + static runtime => runtime.IsDisposalComplete); + + internal T MountCore( + Func factory, + Action initialize, + Action dispose, + Func disposalComplete) + where T : class + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(initialize); + ArgumentNullException.ThrowIfNull(dispose); + ArgumentNullException.ThrowIfNull(disposalComplete); + ThrowIfUnavailable(); + if (_operationActive) + throw new InvalidOperationException("The retained UI lease is changing state."); + if (_host is null) + throw new InvalidOperationException("The retained UI host must be acquired first."); + if (_runtime is not null) + throw new InvalidOperationException("A retained UI runtime is already owned."); + + // Construction may throw while the lease still owns the host directly. + _operationActive = true; + T runtime; + try + { + runtime = factory() + ?? throw new InvalidOperationException("The retained UI runtime factory returned null."); + } + finally + { + _operationActive = false; + } + + // Publish the partial runtime before any Initialize side effect. + _runtime = runtime; + _disposeRuntime = () => dispose(runtime); + _runtimeDisposalComplete = () => disposalComplete(runtime); + + _operationActive = true; + try + { + initialize(runtime); + _operationActive = false; + return runtime; + } + catch (Exception initializationFailure) + { + _operationActive = false; + try + { + Dispose(); + } + catch (Exception cleanupFailure) + { + throw new AggregateException( + "Retail UI initialization failed and its published partial runtime did not cleanly retire.", + initializationFailure, + cleanupFailure); + } + + throw; + } + } + + public void QuiesceInput() + { + if (_disposed || _abandoned || _inputQuiesced) + return; + if (_operationActive) + throw new InvalidOperationException("The retained UI lease is changing state."); + + _operationActive = true; + _inputQuiesced = true; + try + { + _quiesceInput?.Invoke(); + } + catch + { + _inputQuiesced = false; + throw; + } + finally + { + _operationActive = false; + } + } + + public void DeactivateInput() + { + if (_disposed || _abandoned || _inputDeactivated) + return; + if (_operationActive) + throw new InvalidOperationException("The retained UI lease is changing state."); + + _operationActive = true; + _inputDeactivated = true; + try + { + _deactivateInput?.Invoke(); + } + catch + { + _inputDeactivated = false; + throw; + } + finally + { + _operationActive = false; + } + } + + public void Dispose() + { + if (_disposed || _abandoned) + return; + if (_operationActive) + throw new InvalidOperationException("The retained UI lease is changing state."); + + _operationActive = true; + try + { + if (_runtime is not null) + { + _disposeRuntime!(); + if (_runtimeDisposalComplete?.Invoke() != true) + throw new InvalidOperationException( + "The retained UI runtime returned without completing disposal."); + if (_hostDisposalComplete?.Invoke() != true) + throw new InvalidOperationException( + "The retained UI runtime completed without retiring its host."); + } + else if (_host is not null) + { + _disposeHost!(); + if (_hostDisposalComplete?.Invoke() != true) + throw new InvalidOperationException( + "The retained UI host returned without completing disposal."); + } + + _runtime = null; + _disposeRuntime = null; + _runtimeDisposalComplete = null; + _host = null; + _quiesceInput = null; + _deactivateInput = null; + _disposeHost = null; + _hostDisposalComplete = null; + _disposalFailed = false; + _disposed = true; + } + catch + { + _disposalFailed = true; + throw; + } + finally + { + _operationActive = false; + } + } + + public void AbandonAfterTerminalFailure() + { + if (_disposed || _abandoned) + return; + if (_operationActive) + throw new InvalidOperationException("The retained UI lease is changing state."); + if (!_disposalFailed) + throw new InvalidOperationException( + "Retained UI resources may be abandoned only after disposal failed."); + + // Keep the exact references rooted. Checkpoint J owns the final native + // fallback/reporting policy; abandonment must not turn this into an + // unowned local resource. + _abandoned = true; + } + + private void ThrowIfUnavailable() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_abandoned) + throw new InvalidOperationException("The retained UI lease was abandoned."); + } +} diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index ae48b966..3aa7eb20 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -56,6 +56,8 @@ public sealed class UiHost : System.IDisposable private bool _disposeRequested; private bool _disposed; + internal bool IsDisposalComplete => _disposed; + public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null) : this(gl, shaderDir, defaultFont, new HostQuiescenceGate()) { @@ -67,9 +69,9 @@ public sealed class UiHost : System.IDisposable BitmapFont? defaultFont, HostQuiescenceGate quiescence) { + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); TextRenderer = new TextRenderer(gl, shaderDir); DefaultFont = defaultFont; - _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); } // ── Per-frame ────────────────────────────────────────────────────── diff --git a/src/AcDream.App/Update/UpdateFrameOrchestrator.cs b/src/AcDream.App/Update/UpdateFrameOrchestrator.cs index 57272024..a55a7508 100644 --- a/src/AcDream.App/Update/UpdateFrameOrchestrator.cs +++ b/src/AcDream.App/Update/UpdateFrameOrchestrator.cs @@ -109,7 +109,7 @@ internal interface ICameraFramePhase /// . This host order is the accepted TS-53 /// adaptation and is not claimed to be the exact retail Client::UseTime order. /// -internal sealed class UpdateFrameOrchestrator +internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdateFrameRoot { private readonly IUpdateFrameTeardownPhase _teardown; private readonly IUpdateFrameFailureSink _failureSink; diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index ca339663..cf866a99 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -43,8 +43,9 @@ public sealed class GameWindowRenderLeafCompositionTests AssertAppearsInOrder( source, + "_portalTunnelFallback.Transfer(", "new AcDream.App.Streaming.LocalPlayerTeleportController(", - "_portalTunnel = null;", + "new AcDream.App.Streaming.LocalPlayerTeleportPresentation(", "_localPlayerTeleportSink.Bind(_localPlayerTeleport);", "new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(", "new AcDream.App.Rendering.RenderFrameResourceController(", @@ -125,7 +126,7 @@ public sealed class GameWindowRenderLeafCompositionTests AssertAppearsInOrder( source, "new ResourceShutdownStage(\"frame borrowers\"", - "_renderFrameOrchestrator = null;", + "_frameGraphs.Withdraw();", "new ResourceShutdownStage(\"session dependents\""); AssertAppearsInOrder( source, @@ -133,7 +134,7 @@ public sealed class GameWindowRenderLeafCompositionTests "new(\"frame profiler\", _frameProfiler.Dispose)"); AssertAppearsInOrder( source, - "_renderFrameOrchestrator = null;", + "_frameGraphs.Withdraw();", "_devToolsBackend?.Dispose()", "new ResourceShutdownStage(\"input\"", "_input?.Dispose();", @@ -175,7 +176,7 @@ public sealed class GameWindowRenderLeafCompositionTests Assert.True(start >= 0 && end > start); string body = source[start..end]; - Assert.Equal(1, CountOccurrences(body, "_renderFrameOrchestrator!.Render(")); + Assert.Equal(1, CountOccurrences(body, "_frameGraphs.Render(")); Assert.DoesNotContain("_gpuFrameFlights", body); Assert.DoesNotContain("_worldScene", body); Assert.DoesNotContain("_devToolsFramePresenter", body); diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 1241f58f..e1372add 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -67,17 +67,18 @@ public sealed class GameWindowSlice8BoundaryTests "_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);", "_runtimeSettings.ApplyStartup(", "new RuntimeSettingsStartupTargets(", - "_uiHost = new AcDream.App.UI.UiHost(", + "_uiHost = _retailUiLease.AcquireHost(", "_uiHost.WireMouse(m)", "_uiHost.WireKeyboard(kb)", - "_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(", + "_retailUiRuntime = _retailUiLease.Mount(", "_liveEntities = new AcDream.App.World.LiveEntityRuntime(", "_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(", "_retainedUiGameplayBinding =", "AcDream.App.Input.RetainedUiGameplayBinding.Create(", "_retainedUiGameplayBinding.Attach();", - "_renderFrameOrchestrator =", - "_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(", + "var renderFrameOrchestrator =", + "var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(", + "_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);", "_liveSessionHost = CreateLiveSessionHost();", "_liveCombatModeCommands.Bind(combatCommand);", "_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);", @@ -265,7 +266,7 @@ public sealed class GameWindowSlice8BoundaryTests shutdown, "_runtimeSettings.UnbindViewModel()", "_runtimeSettings.UnbindRuntimeTargets", - "_retailUiRuntime?.Dispose()", + "_retailUiLease.Dispose()", "_streamer?.Dispose()", "_wbDrawDispatcher?.Dispose()", "_terrain?.Dispose()"); @@ -292,12 +293,12 @@ public sealed class GameWindowSlice8BoundaryTests "private void OnClosing()", "private ResourceShutdownTransaction CreateShutdownTransaction()"); - Assert.Equal(1, CountOccurrences(update, "_updateFrameOrchestrator.Tick(")); - Assert.Equal(1, CountOccurrences(render, "_renderFrameOrchestrator!.Render(")); + Assert.Equal(1, CountOccurrences(update, "_frameGraphs.Tick(")); + Assert.Equal(1, CountOccurrences(render, "_frameGraphs.Render(")); AssertAppearsInOrder( render, "Vector2D size = _window!.Size;", - "_renderFrameOrchestrator!.Render(", + "_frameGraphs.Render(", "new AcDream.App.Rendering.RenderFrameInput("); Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal); Assert.Contains( @@ -338,6 +339,8 @@ public sealed class GameWindowSlice8BoundaryTests "new ResourceShutdownStage(\"shared texture owners\"", "new ResourceShutdownStage(\"mesh adapter\"", "new ResourceShutdownStage(\"remaining render owners\"", + "new ResourceShutdownStage(\"dedicated render resources\"", + "new ResourceShutdownStage(\"failed render construction cleanup\"", "new ResourceShutdownStage(\"frame flight owner\"", "new ResourceShutdownStage(\"content mappings\"", "new ResourceShutdownStage(\"input\"", @@ -390,6 +393,66 @@ public sealed class GameWindowSlice8BoundaryTests character => Assert.True(char.IsWhiteSpace(character) || character == '}')); } + [Fact] + public void ResourceRootsAndFramePairHaveExplicitAcquireTransferAndReleaseBoundaries() + { + string source = GameWindowSource(); + string load = MethodBody( + "private void OnLoad()", + "private AcDream.App.Net.LiveSessionHost"); + string shutdown = Slice( + source, + "private ResourceShutdownTransaction CreateShutdownTransaction()", + "private void OnFocusChanged(bool focused)"); + string terrainAtlas = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "TerrainAtlas.cs")); + + AssertAppearsInOrder( + load, + "_renderResourceLifetime.AcquireTerrainAtlas(", + "_terrain = new TerrainModernRenderer(", + "_portalTunnelFallback.AcquirePrepared(", + "static portalTunnel => portalTunnel.PrepareResources());", + "_renderResourceLifetime.AcquireSkyShader(", + "_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(", + "_portalTunnelFallback.Transfer(", + "_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);"); + AssertAppearsInOrder( + shutdown, + "_frameGraphs.Withdraw();", + "_retailUiLease.Dispose();", + "_localPlayerTeleport?.Dispose();", + "_portalTunnelFallback.ReleaseFallback();", + "_skyRenderer?.Dispose()", + "_terrain?.Dispose();", + "_renderResourceLifetime.ReleaseSkyShader", + "_renderResourceLifetime.ReleaseTerrainAtlas", + "_glConstructionCleanup.Dispose", + "_gl?.Dispose();"); + + Assert.DoesNotContain( + "RetailUiRuntime.Mount(", + load, + StringComparison.Ordinal); + Assert.DoesNotContain("gl.GenTexture()", terrainAtlas, StringComparison.Ordinal); + Assert.DoesNotContain( + "portalTunnel.PrepareResources();", + load, + StringComparison.Ordinal); + Assert.DoesNotContain("_portalTunnel =", source, StringComparison.Ordinal); + Assert.DoesNotContain("_renderFrameOrchestrator", source, StringComparison.Ordinal); + Assert.DoesNotContain("_updateFrameOrchestrator", source, StringComparison.Ordinal); + AssertAppearsInOrder( + source, + "_window.Run();", + "_glConstructionCleanup.RetainFrom(failure);", + "_glConstructionCleanup.Dispose"); + } + private static string MethodBody(string start, string end) => Slice(GameWindowSource(), start, end); diff --git a/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs b/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs new file mode 100644 index 00000000..2edc666f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs @@ -0,0 +1,396 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class GlTextureOwnershipTests +{ + [Fact] + public void ConstructionRollbackDeletesAllNamesInReverseOrder() + { + var api = new FakeTextureNameApi(); + var transaction = new GlTextureConstructionTransaction(api); + + Assert.Equal(1u, transaction.Allocate()); + Assert.Equal(2u, transaction.Allocate()); + Assert.Equal(3u, transaction.Allocate()); + + transaction.Rollback(); + transaction.Rollback(); + + Assert.Equal([3u, 2u, 1u], api.DeleteAttempts); + } + + [Fact] + public void ConstructionRollbackAttemptsEveryNameAndReportsFailures() + { + var api = new FakeTextureNameApi { FailingDelete = 2 }; + var transaction = new GlTextureConstructionTransaction(api); + _ = transaction.Allocate(); + _ = transaction.Allocate(); + _ = transaction.Allocate(); + + AggregateException failure = Assert.Throws(transaction.Rollback); + + Assert.Single(failure.InnerExceptions); + Assert.Equal([3u, 2u, 1u], api.DeleteAttempts); + Assert.Equal([3u, 1u], api.Deleted); + + api.FailingDelete = null; + transaction.RetryCleanup(); + + Assert.True(transaction.IsCleanupComplete); + Assert.Equal([3u, 2u, 1u, 2u], api.DeleteAttempts); + Assert.Equal([3u, 1u, 2u], api.Deleted); + } + + [Fact] + public void CommittedNamesAreNeverDeletedByConstructionTransaction() + { + var api = new FakeTextureNameApi(); + var transaction = new GlTextureConstructionTransaction(api); + _ = transaction.Allocate(); + _ = transaction.Allocate(); + + transaction.Commit(); + transaction.Rollback(); + + Assert.Empty(api.DeleteAttempts); + Assert.Throws(() => transaction.Allocate()); + } + + [Fact] + public void TrackedTextureUploadFailureLeavesEveryActualBranchNameInTransaction() + { + var api = new FakeTextureNameApi(); + var transaction = new GlTextureConstructionTransaction(api); + uint first = TrackedTextureConstruction.Create(transaction, _ => { }); + + Assert.Throws(() => + TrackedTextureConstruction.Create( + transaction, + _ => throw new InvalidOperationException("upload failed"))); + transaction.Rollback(); + + Assert.Equal(1u, first); + Assert.Equal([2u, 1u], api.DeleteAttempts); + } + + [Fact] + public void TerrainAtlasRoutesEveryTerrainAlphaAndFallbackUploadThroughTracker() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "TerrainAtlas.cs")); + + Assert.Equal(5, CountOccurrences(source, "TrackedTextureConstruction.Create(")); + Assert.Equal( + 5, + System.Text.RegularExpressions.Regex.Matches( + source, + "TrackedTextureConstruction\\.Create\\(\\s*textures,\\s*gl,\\s*\\\"").Count); + Assert.DoesNotContain("textures.Allocate()", source, StringComparison.Ordinal); + } + + [Fact] + public void ProductionGlResourcePathsUseAlwaysOnCheckedCommitBoundaries() + { + string root = FindRepoRoot(); + string textureNames = File.ReadAllText(Path.Combine( + root, "src", "AcDream.App", "Rendering", "GlTextureConstructionTransaction.cs")); + string shaderPrograms = File.ReadAllText(Path.Combine( + root, "src", "AcDream.App", "Rendering", "ShaderProgramConstruction.cs")); + string terrain = File.ReadAllText(Path.Combine( + root, "src", "AcDream.App", "Rendering", "TerrainAtlas.cs")); + string text = File.ReadAllText(Path.Combine( + root, "src", "AcDream.App", "Rendering", "TextRenderer.cs")); + string bindless = File.ReadAllText(Path.Combine( + root, "src", "AcDream.App", "Rendering", "Wb", "BindlessSupport.cs")); + + Assert.Contains("GlResourceCommand.CreateTexture", textureNames, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.DeleteTexture", textureNames, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.CreateName", shaderPrograms, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.DeleteShader", shaderPrograms, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.DeleteProgram", shaderPrograms, StringComparison.Ordinal); + Assert.Contains("_anisotropyBindingMutation.Execute", terrain, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.DeleteTexture", terrain, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.CreateTexture", text, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.Execute", text, StringComparison.Ordinal); + Assert.Contains("make bindless handle", bindless, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.Execute", bindless, StringComparison.Ordinal); + } + + [Fact] + public void SecondBindlessAcquireFailureRollsBackFirstHandle() + { + var residency = new FakeResidency { FailingAcquireTexture = 20 }; + var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release); + + Assert.Throws(() => pair.Acquire()); + + Assert.False(pair.IsFullyResident); + Assert.Equal([10u, 20u], residency.AcquireAttempts); + Assert.Equal([1010ul], residency.ReleaseAttempts); + } + + [Fact] + public void FailedPrefixRollbackRemainsOwnedAndRetryDoesNotReacquireIt() + { + var residency = new FakeResidency + { + FailingAcquireTexture = 20, + FailingReleaseHandle = 1010, + }; + var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release); + + Assert.Throws(() => pair.Acquire()); + residency.FailingAcquireTexture = null; + residency.FailingReleaseHandle = null; + + Assert.Equal((1010ul, 1020ul), pair.Acquire()); + + Assert.True(pair.IsFullyResident); + Assert.Equal([10u, 20u, 20u], residency.AcquireAttempts); + Assert.Equal([1010ul], residency.ReleaseAttempts); + } + + [Fact] + public void BindlessReleaseAttemptsBothAndRetriesOnlyPendingHandle() + { + var residency = new FakeResidency(); + var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release); + _ = pair.Acquire(); + residency.FailingReleaseHandle = 1010; + + Assert.Throws(pair.Release); + Assert.False(pair.IsFullyResident); + Assert.True(pair.HasAnyResident); + Assert.Equal([1010ul, 1020ul], residency.ReleaseAttempts); + + residency.FailingReleaseHandle = null; + pair.Release(); + pair.Release(); + + Assert.False(pair.HasAnyResident); + Assert.Equal([1010ul, 1020ul, 1010ul], residency.ReleaseAttempts); + } + + [Fact] + public void MutationFailureStillRestoresThePreviouslyResidentPair() + { + var residency = new FakeResidency(); + var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release); + var guard = new BindlessTextureMutationGuard(pair); + _ = pair.Acquire(); + + Assert.Throws(() => + guard.Execute(() => throw new InvalidOperationException("mutation failed"))); + + Assert.True(pair.IsFullyResident); + Assert.False(guard.RestoreRequired); + Assert.Equal([10u, 20u, 10u, 20u], residency.AcquireAttempts); + } + + [Fact] + public void FailedReacquireKeepsRestoreIntentUntilALaterMutationRetry() + { + var residency = new FakeResidency(); + var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release); + var guard = new BindlessTextureMutationGuard(pair); + _ = pair.Acquire(); + residency.FailingAcquireTexture = 20; + + Assert.Throws(() => guard.Execute(() => { })); + Assert.True(guard.RestoreRequired); + Assert.False(pair.HasAnyResident); + + residency.FailingAcquireTexture = null; + guard.Execute(() => { }); + + Assert.False(guard.RestoreRequired); + Assert.True(pair.IsFullyResident); + Assert.Equal([10u, 20u, 10u, 20u, 10u, 20u], residency.AcquireAttempts); + } + + [Fact] + public void PartialReleaseFailurePreventsMutationAndRestoresPairBeforeThrowing() + { + var residency = new FakeResidency(); + var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release); + var guard = new BindlessTextureMutationGuard(pair); + _ = pair.Acquire(); + residency.FailingReleaseHandle = 1010; + int mutations = 0; + + Assert.Throws(() => guard.Execute(() => mutations++)); + + Assert.Equal(0, mutations); + Assert.True(pair.IsFullyResident); + Assert.False(guard.RestoreRequired); + } + + [Fact] + public void FailedTextureMutationRestoresTheExactPriorBinding() + { + var bindings = new List(); + var owner = new RestoredTextureBindingMutation(); + + InvalidOperationException failure = Assert.Throws(() => + owner.Execute( + () => 77, + bindings.Add, + 42, + () => throw new InvalidOperationException("mutation failed"))); + + Assert.Equal("mutation failed", failure.Message); + Assert.Equal([42u, 77u], bindings); + } + + [Fact] + public void TextureMutationReportsBothMutationAndBindingRestoreFailure() + { + int bindCalls = 0; + var owner = new RestoredTextureBindingMutation(); + + AggregateException failure = Assert.Throws(() => + owner.Execute( + () => 77, + _ => + { + bindCalls++; + if (bindCalls == 2) + throw new InvalidOperationException("restore failed"); + }, + 42, + () => throw new InvalidOperationException("mutation failed"))); + + Assert.Equal(2, failure.InnerExceptions.Count); + Assert.Equal(2, bindCalls); + Assert.True(owner.HasPendingRestore); + } + + [Fact] + public void FailedBindingRestoreRetriesTheOriginalBindingBeforeAnotherMutation() + { + var owner = new RestoredTextureBindingMutation(); + var bindings = new List(); + int restoreFailures = 1; + int mutations = 0; + + Assert.Throws(() => + owner.Execute( + () => 77, + binding => + { + bindings.Add(binding); + if (binding == 77 && restoreFailures-- > 0) + throw new InvalidOperationException("restore failed"); + }, + 42, + () => mutations++)); + + Assert.True(owner.HasPendingRestore); + owner.Execute( + () => 77, + bindings.Add, + 42, + () => mutations++); + + Assert.False(owner.HasPendingRestore); + Assert.Equal([42u, 77u, 77u, 42u, 77u], bindings); + Assert.Equal(2, mutations); + } + + [Fact] + public void ConstructionCleanupLedgerRetainsNestedFailureUntilRetryCompletes() + { + var api = new FakeTextureNameApi { FailingDelete = 1 }; + var transaction = new GlTextureConstructionTransaction(api); + _ = transaction.Allocate(); + AggregateException cleanupFailure = Assert.Throws( + transaction.Rollback); + var constructionFailure = new GlResourceConstructionException( + "synthetic construction failure", + transaction, + [new InvalidOperationException("build failed"), cleanupFailure]); + var ledger = new GlConstructionCleanupLedger(); + + Assert.True(ledger.RetainFrom(constructionFailure)); + Assert.Throws(ledger.Dispose); + Assert.False(ledger.IsComplete); + + api.FailingDelete = null; + ledger.Dispose(); + + Assert.True(ledger.IsComplete); + Assert.True(constructionFailure.IsCleanupComplete); + } + + private sealed class FakeTextureNameApi : IGlTextureNameApi + { + private uint _nextName = 1; + + public uint? FailingDelete { get; set; } + public List DeleteAttempts { get; } = []; + public List Deleted { get; } = []; + + public uint GenTexture() => _nextName++; + + public void DeleteTexture(uint texture) + { + DeleteAttempts.Add(texture); + if (FailingDelete == texture) + throw new InvalidOperationException("delete failed"); + Deleted.Add(texture); + } + } + + 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 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."); + } + + private sealed class FakeResidency + { + public uint? FailingAcquireTexture { get; set; } + public ulong? FailingReleaseHandle { get; set; } + public List AcquireAttempts { get; } = []; + public List ReleaseAttempts { get; } = []; + + public ulong Acquire(uint texture) + { + AcquireAttempts.Add(texture); + if (FailingAcquireTexture == texture) + throw new InvalidOperationException("acquire failed"); + return 1000ul + texture; + } + + public void Release(ulong handle) + { + ReleaseAttempts.Add(handle); + if (FailingReleaseHandle == handle) + throw new InvalidOperationException("release failed"); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs b/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs new file mode 100644 index 00000000..d52c1352 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs @@ -0,0 +1,122 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class ResourceCleanupGroupTests +{ + [Fact] + public void CleanupRunsInReverseOrderAndNeverReplaysSuccess() + { + var calls = new List(); + var resources = new ResourceCleanupGroup(); + int middleFailures = 1; + resources.Add("first", () => calls.Add("first")); + resources.Add("middle", () => + { + calls.Add("middle"); + if (middleFailures-- > 0) + throw new InvalidOperationException("middle failed"); + }); + resources.Add("last", () => calls.Add("last")); + + Assert.Throws(resources.RetryCleanup); + Assert.Equal(["last", "middle", "first"], calls); + + resources.RetryCleanup(); + resources.RetryCleanup(); + + Assert.True(resources.IsCleanupComplete); + Assert.Equal(["last", "middle", "first", "middle"], calls); + } + + [Fact] + public void TextRendererPublishesEveryConstructorResourceBeforeLaterGlWork() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "TextRenderer.cs")); + + AssertAppearsInOrder( + source, + "shader = new Shader(gl,", + "resources.Add(\"text shader\", shader.Dispose);", + "frameBuffers[i] = CreateFrameBufferSet(resources);", + "whiteTexture = GlResourceCommand.CreateTexture(", + "resources.Add(", + "\"white texture\"", + "GlResourceCommand.Execute(", + "initialize TextRenderer white texture", + "resources.RetryCleanup();", + "_resources = resources;"); + AssertAppearsInOrder( + source, + "private FrameBufferSet CreateFrameBufferSet(ResourceCleanupGroup resources)", + "TrackedGlResource.CreateVertexArray(", + "resources.Add(\"frame VAO\", vaoRelease.Run);", + "TrackedGlResource.CreateBuffer(", + "resources.Add(", + "\"frame VBO\""); + Assert.Contains("_resources.RetryCleanup();", source, StringComparison.Ordinal); + Assert.DoesNotContain("private FrameBufferSet CreateFrameBufferSet()", source, + StringComparison.Ordinal); + } + + [Fact] + public void ReturnedNameIsRetainedWhenPostconditionAndFirstRollbackFail() + { + bool deleteFails = true; + int deleteCalls = 0; + + GlResourceConstructionException failure = + Assert.Throws(() => + GlResourceCommand.CreateNameCore( + "synthetic buffer", + static () => { }, + () => 42, + () => throw new InvalidOperationException("postcondition failed"), + name => + { + Assert.Equal(42u, name); + deleteCalls++; + if (deleteFails) + throw new InvalidOperationException("delete failed"); + })); + + Assert.False(failure.IsCleanupComplete); + Assert.Equal(1, deleteCalls); + + deleteFails = false; + failure.RetryCleanup(); + failure.RetryCleanup(); + + Assert.True(failure.IsCleanupComplete); + Assert.Equal(2, deleteCalls); + } + + private static void AssertAppearsInOrder(string source, params string[] values) + { + int cursor = -1; + foreach (string value in values) + { + int next = source.IndexOf(value, cursor + 1, StringComparison.Ordinal); + Assert.True(next > cursor, $"Missing or out-of-order source fragment: {value}"); + cursor = next; + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs b/tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs new file mode 100644 index 00000000..e19804df --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs @@ -0,0 +1,271 @@ +using AcDream.App.Rendering; +using AcDream.App.Update; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RuntimeResourceSlotTests +{ + [Fact] + public void OwnedSlotPublishesOnceAndRetriesOnlyFailedRelease() + { + var slot = new OwnedResourceSlot(); + var resource = new RetryableResource(remainingFailures: 1); + + Assert.Same(resource, slot.Acquire(() => resource)); + Assert.Same(resource, slot.Borrow()); + Assert.Throws(() => + slot.Acquire(() => new RetryableResource())); + + Assert.Throws(slot.Release); + Assert.True(slot.HasResource); + + slot.Release(); + slot.Release(); + + Assert.False(slot.HasResource); + Assert.Equal(2, resource.DisposeCalls); + } + + [Fact] + public void TransferSlotKeepsFallbackUntilCompleteDestinationExists() + { + var slot = new TransferableResourceSlot(); + var resource = slot.Acquire(() => new RetryableResource()); + + Assert.Throws(() => + slot.Transfer(_ => throw new InvalidOperationException("destination failed"))); + Assert.True(slot.HasFallback); + Assert.Same(resource, slot.Borrow()); + + var owner = slot.Transfer(value => new ResourceOwner(value)); + + Assert.False(slot.HasFallback); + slot.ReleaseFallback(); + Assert.Equal(0, resource.DisposeCalls); + + owner.Dispose(); + Assert.Equal(1, resource.DisposeCalls); + } + + [Fact] + public void TransferSlotReleasesOnlyFallbackOnPartialShutdown() + { + var fallback = new TransferableResourceSlot(); + RetryableResource fallbackResource = fallback.Acquire( + () => new RetryableResource()); + + fallback.ReleaseFallback(); + fallback.ReleaseFallback(); + + Assert.Equal(1, fallbackResource.DisposeCalls); + + var transferred = new TransferableResourceSlot(); + RetryableResource transferredResource = transferred.Acquire( + () => new RetryableResource()); + ResourceOwner owner = transferred.Transfer(value => new ResourceOwner(value)); + + owner.Dispose(); + transferred.ReleaseFallback(); + + Assert.Equal(1, transferredResource.DisposeCalls); + } + + [Fact] + public void TransferSlotCoversAcquirePrepareAndPostTransferBindingPrefixes() + { + var acquisition = new TransferableResourceSlot(); + Assert.Throws(() => + acquisition.Acquire(() => throw new InvalidOperationException("create failed"))); + Assert.False(acquisition.HasFallback); + + var preparation = new TransferableResourceSlot(); + var prepared = new RetryableResource(remainingPrepareFailures: 1); + Assert.Throws(() => + preparation.AcquirePrepared(() => prepared, static value => value.Prepare())); + Assert.True(preparation.HasFallback); + Assert.Throws(() => + preparation.Transfer(value => new ResourceOwner(value))); + Assert.Same( + prepared, + preparation.AcquirePrepared( + () => throw new InvalidOperationException("factory replayed"), + static value => value.Prepare())); + Assert.Equal(2, prepared.PrepareCalls); + preparation.ReleaseFallback(); + Assert.Equal(1, prepared.DisposeCalls); + + var binding = new TransferableResourceSlot(); + RetryableResource transferred = binding.Acquire(() => new RetryableResource()); + ResourceOwner? owner = null; + Assert.Throws((Action)(() => + { + owner = binding.Transfer(value => new ResourceOwner(value)); + throw new InvalidOperationException("binding failed"); + })); + Assert.False(binding.HasFallback); + Assert.NotNull(owner); + owner.Dispose(); + binding.ReleaseFallback(); + Assert.Equal(1, transferred.DisposeCalls); + } + + [Fact] + public void FrameGraphsPublishAtomicallyRouteAndBecomeSilentAfterWithdrawal() + { + var calls = new List(); + var slot = new GameFrameGraphSlot(); + + Assert.False(slot.Tick(new UpdateFrameInput(0.25))); + Assert.False(slot.Render(new RenderFrameInput(0.5, 800, 600), out _)); + + var update = new RecordingUpdateRoot(calls); + var render = new RecordingRenderRoot(calls); + slot.Publish(update, render); + + Assert.True(slot.IsPublished); + Assert.Throws(() => + slot.Publish(new RecordingUpdateRoot(calls), new RecordingRenderRoot(calls))); + Assert.True(slot.Tick(new UpdateFrameInput(0.25))); + Assert.True(slot.Render(new RenderFrameInput(0.5, 800, 600), out _)); + Assert.Equal(["update:0.25", "render:0.5:800:600"], calls); + + slot.Withdraw(); + slot.Withdraw(); + + Assert.False(slot.IsPublished); + Assert.False(slot.Tick(new UpdateFrameInput(1))); + Assert.False(slot.Render(new RenderFrameInput(1, 1, 1), out _)); + Assert.Equal(2, calls.Count); + } + + [Fact] + public void WithdrawnFrameSlotAcceptsACompleteReplacementPair() + { + var calls = new List(); + var slot = new GameFrameGraphSlot(); + slot.Publish(new RecordingUpdateRoot(calls), new RecordingRenderRoot(calls)); + slot.Withdraw(); + + slot.Publish(new RecordingUpdateRoot(calls), new RecordingRenderRoot(calls)); + + Assert.True(slot.Tick(new UpdateFrameInput(2))); + Assert.Equal(["update:2"], calls); + } + + [Fact] + public void OwnedSlotRejectsReentrantAcquireWithoutLosingOuterResource() + { + var slot = new OwnedResourceSlot(); + var outer = new RetryableResource(); + int innerFactories = 0; + + Assert.Same(outer, slot.Acquire(() => + { + Assert.Throws(() => slot.Acquire(() => + { + innerFactories++; + return new RetryableResource(); + })); + return outer; + })); + + Assert.Equal(0, innerFactories); + Assert.Same(outer, slot.Borrow()); + slot.Release(); + Assert.Equal(1, outer.DisposeCalls); + } + + [Fact] + public void OwnedSlotRejectsReentrantReleaseWithoutDoubleDisposal() + { + var slot = new OwnedResourceSlot(); + var resource = new CallbackResource(() => + Assert.Throws(slot.Release)); + slot.Acquire(() => resource); + + slot.Release(); + + Assert.Equal(1, resource.DisposeCalls); + Assert.False(slot.HasResource); + } + + [Fact] + public void TransferSlotRejectsNestedTransferAndPreservesOuterTransfer() + { + var slot = new TransferableResourceSlot(); + var resource = slot.Acquire(() => new RetryableResource()); + int nestedOwners = 0; + + ResourceOwner owner = slot.Transfer(value => + { + Assert.Throws(() => + slot.Transfer(nested => + { + nestedOwners++; + return new ResourceOwner(nested); + })); + return new ResourceOwner(value); + }); + + Assert.Equal(0, nestedOwners); + Assert.False(slot.HasFallback); + owner.Dispose(); + Assert.Equal(1, resource.DisposeCalls); + } + + private sealed class RetryableResource( + int remainingFailures = 0, + int remainingPrepareFailures = 0) : IDisposable + { + private int _remainingFailures = remainingFailures; + private int _remainingPrepareFailures = remainingPrepareFailures; + + public int DisposeCalls { get; private set; } + public int PrepareCalls { get; private set; } + + public void Prepare() + { + PrepareCalls++; + if (_remainingPrepareFailures-- > 0) + throw new InvalidOperationException("prepare failed"); + } + + public void Dispose() + { + DisposeCalls++; + if (_remainingFailures-- > 0) + throw new InvalidOperationException("synthetic release failure"); + } + } + + private sealed class ResourceOwner(RetryableResource resource) : IDisposable + { + public void Dispose() => resource.Dispose(); + } + + private sealed class CallbackResource(Action onDispose) : IDisposable + { + public int DisposeCalls { get; private set; } + + public void Dispose() + { + DisposeCalls++; + onDispose(); + } + } + + private sealed class RecordingUpdateRoot(List calls) : IGameUpdateFrameRoot + { + public void Tick(UpdateFrameInput input) => calls.Add($"update:{input.HostDeltaSeconds}"); + } + + private sealed class RecordingRenderRoot(List calls) : IGameRenderFrameRoot + { + public RenderFrameOutcome Render(RenderFrameInput input) + { + calls.Add( + $"render:{input.DeltaSeconds}:{input.ViewportWidth}:{input.ViewportHeight}"); + return default; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ShaderProgramConstructionTests.cs b/tests/AcDream.App.Tests/Rendering/ShaderProgramConstructionTests.cs new file mode 100644 index 00000000..f66113ee --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ShaderProgramConstructionTests.cs @@ -0,0 +1,197 @@ +using AcDream.App.Rendering; +using Silk.NET.OpenGL; + +namespace AcDream.App.Tests.Rendering; + +public sealed class ShaderProgramConstructionTests +{ + [Theory] + [InlineData("ShaderSource:VertexShader", 1, 0)] + [InlineData("CompileShader:VertexShader", 1, 0)] + [InlineData("CreateShader:FragmentShader", 1, 0)] + [InlineData("ShaderSource:FragmentShader", 2, 0)] + [InlineData("CompileShader:FragmentShader", 2, 0)] + [InlineData("CreateProgram", 2, 0)] + [InlineData("AttachShader:VertexShader", 2, 1)] + [InlineData("AttachShader:FragmentShader", 2, 1)] + [InlineData("LinkProgram", 2, 1)] + public void ThrowAfterEachAllocatedNameRollsBackEveryPublishedName( + string failingOperation, + int expectedShaderDeletes, + int expectedProgramDeletes) + { + var api = new FakeShaderApi { ThrowOn = failingOperation }; + + Assert.Throws(() => + ShaderProgramConstruction.Build(api, "vertex", "fragment")); + + Assert.Equal(expectedShaderDeletes, api.DeletedShaders.Count); + Assert.Equal(expectedProgramDeletes, api.DeletedPrograms.Count); + Assert.Equal(api.CreatedShaders, api.DeletedShaders.Order().ToArray()); + Assert.Equal(api.CreatedPrograms, api.DeletedPrograms.Order().ToArray()); + } + + [Fact] + public void CompileFailurePreservesOriginalFailureWhenRollbackIsClean() + { + var api = new FakeShaderApi { VertexCompiles = false }; + + InvalidOperationException failure = Assert.Throws(() => + ShaderProgramConstruction.Build(api, "vertex", "fragment")); + + Assert.Contains("VertexShader compile failed", failure.Message); + Assert.Equal([1u], api.DeletedShaders); + Assert.Empty(api.CreatedPrograms); + } + + [Fact] + public void LinkFailureDetachesAndDeletesBothShadersAndProgram() + { + var api = new FakeShaderApi { ProgramLinks = false }; + + InvalidOperationException failure = Assert.Throws(() => + ShaderProgramConstruction.Build(api, "vertex", "fragment")); + + Assert.Contains("program link failed", failure.Message); + Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls); + Assert.Equal([1u, 2u], api.DeletedShaders); + Assert.Equal([3u], api.DeletedPrograms); + } + + [Fact] + public void SuccessfulBuildCommitsOnlyProgramAndReleasesTemporaryShaders() + { + var api = new FakeShaderApi(); + + uint program = ShaderProgramConstruction.Build(api, "vertex", "fragment"); + + Assert.Equal(3u, program); + Assert.Equal([1u, 2u], api.DeletedShaders); + Assert.Empty(api.DeletedPrograms); + Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls); + } + + [Fact] + public void CleanupFailureStillAttemptsEveryOtherReleaseAndProgramRollback() + { + var api = new FakeShaderApi { ThrowOn = "DetachShader:VertexShader" }; + + AggregateException failure = Assert.Throws(() => + ShaderProgramConstruction.Build(api, "vertex", "fragment")); + + Assert.Single(failure.InnerExceptions); + Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls); + Assert.Equal([1u, 2u], api.DeletedShaders); + Assert.Equal([3u], api.DeletedPrograms); + } + + [Fact] + public void ConstructionAndRollbackFailuresAreReportedTogether() + { + var api = new FakeShaderApi + { + ProgramLinks = false, + ThrowOn = "DeleteShader:VertexShader", + }; + + GlResourceConstructionException failure = Assert.Throws(() => + ShaderProgramConstruction.Build(api, "vertex", "fragment")); + + Assert.Equal(2, failure.InnerExceptions.Count); + Assert.Contains("program link failed", failure.InnerExceptions[0].Message); + Assert.Equal([1u, 2u], api.DeleteShaderAttempts); + Assert.Equal([2u], api.DeletedShaders); + Assert.Equal([3u], api.DeletedPrograms); + + api.ThrowOn = null; + failure.RetryCleanup(); + + Assert.True(failure.IsCleanupComplete); + Assert.Equal([2u, 1u], api.DeletedShaders); + Assert.Equal([1u, 2u, 1u], api.DeleteShaderAttempts); + } + + private sealed class FakeShaderApi : IShaderProgramBuildApi + { + private readonly Dictionary _shaderTypes = []; + private uint _nextName = 1; + + public string? ThrowOn { get; set; } + public bool VertexCompiles { get; init; } = true; + public bool FragmentCompiles { get; init; } = true; + public bool ProgramLinks { get; init; } = true; + public List CreatedShaders { get; } = []; + public List CreatedPrograms { get; } = []; + public List DeleteShaderAttempts { get; } = []; + public List DeletedShaders { get; } = []; + public List DeletedPrograms { get; } = []; + public List DetachCalls { get; } = []; + + public uint CreateShader(ShaderType type) + { + Fail($"CreateShader:{type}"); + uint name = _nextName++; + CreatedShaders.Add(name); + _shaderTypes.Add(name, type); + return name; + } + + public void ShaderSource(uint shader, string source) => + Fail($"ShaderSource:{TypeOf(shader)}"); + + public void CompileShader(uint shader) => + Fail($"CompileShader:{TypeOf(shader)}"); + + public int GetShaderCompileStatus(uint shader) => TypeOf(shader) switch + { + ShaderType.VertexShader => VertexCompiles ? 1 : 0, + ShaderType.FragmentShader => FragmentCompiles ? 1 : 0, + _ => 0, + }; + + public string GetShaderInfoLog(uint shader) => $"bad {TypeOf(shader)}"; + + public uint CreateProgram() + { + Fail("CreateProgram"); + uint name = _nextName++; + CreatedPrograms.Add(name); + return name; + } + + public void AttachShader(uint program, uint shader) => + Fail($"AttachShader:{TypeOf(shader)}"); + + public void LinkProgram(uint program) => Fail("LinkProgram"); + public int GetProgramLinkStatus(uint program) => ProgramLinks ? 1 : 0; + public string GetProgramInfoLog(uint program) => "bad link"; + + public void DetachShader(uint program, uint shader) + { + string operation = $"DetachShader:{TypeOf(shader)}"; + DetachCalls.Add(operation); + Fail(operation); + } + + public void DeleteShader(uint shader) + { + DeleteShaderAttempts.Add(shader); + Fail($"DeleteShader:{TypeOf(shader)}"); + DeletedShaders.Add(shader); + } + + public void DeleteProgram(uint program) + { + Fail("DeleteProgram"); + DeletedPrograms.Add(program); + } + + private ShaderType TypeOf(uint shader) => _shaderTypes[shader]; + + private void Fail(string operation) + { + if (ThrowOn == operation) + throw new InvalidOperationException(operation + " failed"); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs index d81a56f3..cacabedd 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs @@ -361,7 +361,7 @@ public sealed class WorldRenderFrameBuilderTests "var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(", "var worldRenderFrameBuilder =", "new AcDream.App.Rendering.RenderFrameOrchestrator(", - "_renderFrameOrchestrator = null;", + "_frameGraphs.Withdraw();", "new(\"equipped children\"", "new(\"effect network state\"", "new(\"audio\"", diff --git a/tests/AcDream.App.Tests/UI/RetailUiRuntimeLeaseTests.cs b/tests/AcDream.App.Tests/UI/RetailUiRuntimeLeaseTests.cs new file mode 100644 index 00000000..8bbb7088 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/RetailUiRuntimeLeaseTests.cs @@ -0,0 +1,352 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public sealed class RetailUiRuntimeLeaseTests +{ + [Fact] + public void ConstructorFailureLeavesHostOwnedUntilLifetimeDisposal() + { + var lease = new RetailUiRuntimeLease(); + var host = AcquireHost(lease); + + Assert.Throws(() => + lease.MountCore( + () => throw new InvalidOperationException("constructor failed"), + static runtime => runtime.Initialize(), + static runtime => runtime.Dispose(), + static runtime => runtime.IsDisposalComplete)); + + Assert.True(lease.RetainsResources); + Assert.Equal(0, host.DisposeCalls); + + lease.Dispose(); + lease.Dispose(); + + Assert.True(lease.IsDisposalComplete); + Assert.False(lease.RetainsResources); + Assert.Equal(1, host.DisposeCalls); + } + + [Fact] + public void InitializeFailureCleansRuntimeAndHostExactlyOnce() + { + var lease = new RetailUiRuntimeLease(); + var host = AcquireHost(lease); + var runtime = new FakeRuntime(host) { InitializeFailure = true }; + + InvalidOperationException failure = Assert.Throws(() => + Mount(lease, runtime)); + + Assert.Equal("initialize failed", failure.Message); + Assert.True(lease.IsDisposalComplete); + Assert.False(lease.RetainsResources); + Assert.Equal(1, runtime.DisposeCalls); + Assert.Equal(1, host.DisposeCalls); + } + + [Fact] + public void TransientInitializeCleanupFailureRetainsOwnershipForLifetimeRetry() + { + var lease = new RetailUiRuntimeLease(); + var host = AcquireHost(lease); + var runtime = new FakeRuntime(host) + { + InitializeFailure = true, + RemainingDisposeFailures = 1, + }; + + AggregateException failure = Assert.Throws(() => + Mount(lease, runtime)); + + Assert.Equal(2, failure.InnerExceptions.Count); + Assert.True(lease.RetainsResources); + Assert.False(lease.IsDisposalComplete); + Assert.Equal(1, runtime.DisposeCalls); + Assert.Equal(0, host.DisposeCalls); + + lease.Dispose(); + + Assert.True(lease.IsDisposalComplete); + Assert.False(lease.RetainsResources); + Assert.Equal(2, runtime.DisposeCalls); + Assert.Equal(1, host.DisposeCalls); + } + + [Fact] + public void PersistentCleanupFailureCanBeExplicitlyAbandonedAndBecomesInert() + { + var lease = new RetailUiRuntimeLease(); + var host = AcquireHost(lease); + var runtime = new FakeRuntime(host) + { + InitializeFailure = true, + RemainingDisposeFailures = int.MaxValue, + }; + + Assert.Throws(() => Mount(lease, runtime)); + Assert.Throws(lease.Dispose); + Assert.Throws(() => + new RetailUiRuntimeLease().AbandonAfterTerminalFailure()); + + lease.AbandonAfterTerminalFailure(); + lease.Dispose(); + lease.QuiesceInput(); + lease.DeactivateInput(); + + Assert.True(lease.IsAbandoned); + Assert.False(lease.IsDisposalComplete); + Assert.True(lease.RetainsResources); + Assert.Equal(2, runtime.DisposeCalls); + Assert.Equal(0, host.DisposeCalls); + } + + [Fact] + public void QuiesceAndDeactivateAreSeparateIdempotentOperations() + { + var lease = new RetailUiRuntimeLease(); + var host = AcquireHost(lease); + + lease.QuiesceInput(); + lease.QuiesceInput(); + lease.DeactivateInput(); + lease.DeactivateInput(); + + Assert.Equal(1, host.QuiesceCalls); + Assert.Equal(1, host.DeactivateCalls); + Assert.Equal(0, host.DisposeCalls); + + lease.Dispose(); + + Assert.Equal(1, host.DisposeCalls); + } + + [Fact] + public void HostAndRuntimeMayOnlyBePublishedOnce() + { + var lease = new RetailUiRuntimeLease(); + var host = AcquireHost(lease); + var runtime = new FakeRuntime(host); + + Assert.Throws(() => AcquireHost(lease)); + Assert.Same(runtime, Mount(lease, runtime)); + Assert.Throws(() => + Mount(lease, new FakeRuntime(host))); + + lease.Dispose(); + } + + [Fact] + public void ReentrantHostAndRuntimeFactoriesCannotOverwriteOuterOwnership() + { + var lease = new RetailUiRuntimeLease(); + var host = new FakeHost(); + int nestedHosts = 0; + FakeHost acquired = lease.AcquireHostCore( + () => + { + Assert.Throws(() => + lease.AcquireHostCore( + () => + { + nestedHosts++; + return new FakeHost(); + }, + static value => value.QuiesceInput(), + static value => value.DeactivateInput(), + static value => value.Dispose(), + static value => value.IsDisposalComplete)); + return host; + }, + static value => value.QuiesceInput(), + static value => value.DeactivateInput(), + static value => value.Dispose(), + static value => value.IsDisposalComplete); + var runtime = new FakeRuntime(host); + int nestedRuntimes = 0; + + FakeRuntime mounted = lease.MountCore( + () => + { + Assert.Throws(() => + lease.MountCore( + () => + { + nestedRuntimes++; + return new FakeRuntime(host); + }, + static value => value.Initialize(), + static value => value.Dispose(), + static value => value.IsDisposalComplete)); + return runtime; + }, + static value => value.Initialize(), + static value => value.Dispose(), + static value => value.IsDisposalComplete); + + Assert.Same(host, acquired); + Assert.Same(runtime, mounted); + Assert.Equal(0, nestedHosts); + Assert.Equal(0, nestedRuntimes); + lease.Dispose(); + } + + [Fact] + public void ReentrantDisposeIsRejectedWithoutDoubleDisposingRuntimeOrHost() + { + var lease = new RetailUiRuntimeLease(); + var host = AcquireHost(lease); + var runtime = new FakeRuntime(host) + { + OnDispose = () => + Assert.Throws(lease.Dispose), + }; + Mount(lease, runtime); + + lease.Dispose(); + + Assert.Equal(1, runtime.DisposeCalls); + Assert.Equal(1, host.DisposeCalls); + Assert.True(lease.IsDisposalComplete); + } + + [Fact] + public void HostFactoryCannotPrematurelyCompleteInputCutoffOperations() + { + var lease = new RetailUiRuntimeLease(); + var host = new FakeHost(); + + lease.AcquireHostCore( + () => + { + Assert.Throws(lease.QuiesceInput); + Assert.Throws(lease.DeactivateInput); + return host; + }, + static value => value.QuiesceInput(), + static value => value.DeactivateInput(), + static value => value.Dispose(), + static value => value.IsDisposalComplete); + + lease.QuiesceInput(); + lease.DeactivateInput(); + + Assert.Equal(1, host.QuiesceCalls); + Assert.Equal(1, host.DeactivateCalls); + lease.Dispose(); + } + + [Fact] + public void InputCutoffRejectsReentrantMutationAndRetriesFailedCallback() + { + var lease = new RetailUiRuntimeLease(); + var host = new FakeHost(); + int quiesceAttempts = 0; + int deactivateAttempts = 0; + lease.AcquireHostCore( + () => host, + _ => + { + quiesceAttempts++; + Assert.Throws(() => + lease.MountCore( + () => new FakeRuntime(host), + static value => value.Initialize(), + static value => value.Dispose(), + static value => value.IsDisposalComplete)); + if (quiesceAttempts == 1) + throw new InvalidOperationException("quiesce failed"); + host.QuiesceInput(); + }, + _ => + { + deactivateAttempts++; + Assert.Throws(lease.Dispose); + if (deactivateAttempts == 1) + throw new InvalidOperationException("deactivate failed"); + host.DeactivateInput(); + }, + static value => value.Dispose(), + static value => value.IsDisposalComplete); + + Assert.Throws(lease.QuiesceInput); + lease.QuiesceInput(); + Assert.Throws(lease.DeactivateInput); + lease.DeactivateInput(); + + Assert.Equal(2, quiesceAttempts); + Assert.Equal(2, deactivateAttempts); + Assert.Equal(1, host.QuiesceCalls); + Assert.Equal(1, host.DeactivateCalls); + lease.Dispose(); + } + + private static FakeHost AcquireHost(RetailUiRuntimeLease lease) + { + var host = new FakeHost(); + return lease.AcquireHostCore( + () => host, + static value => value.QuiesceInput(), + static value => value.DeactivateInput(), + static value => value.Dispose(), + static value => value.IsDisposalComplete); + } + + private static FakeRuntime Mount( + RetailUiRuntimeLease lease, + FakeRuntime runtime) => lease.MountCore( + () => runtime, + static value => value.Initialize(), + static value => value.Dispose(), + static value => value.IsDisposalComplete); + + private sealed class FakeHost : IDisposable + { + public int QuiesceCalls { get; private set; } + public int DeactivateCalls { get; private set; } + public int DisposeCalls { get; private set; } + public bool IsDisposalComplete { get; private set; } + + public void QuiesceInput() => QuiesceCalls++; + + public void DeactivateInput() => DeactivateCalls++; + + public void Dispose() + { + if (IsDisposalComplete) + return; + + DisposeCalls++; + IsDisposalComplete = true; + } + } + + private sealed class FakeRuntime(FakeHost host) : IDisposable + { + public bool InitializeFailure { get; init; } + public int RemainingDisposeFailures { get; set; } + public int DisposeCalls { get; private set; } + public bool IsDisposalComplete { get; private set; } + public Action? OnDispose { get; init; } + + public void Initialize() + { + if (InitializeFailure) + throw new InvalidOperationException("initialize failed"); + } + + public void Dispose() + { + if (IsDisposalComplete) + return; + + DisposeCalls++; + OnDispose?.Invoke(); + if (RemainingDisposeFailures-- > 0) + throw new InvalidOperationException("dispose failed"); + + host.Dispose(); + IsDisposalComplete = true; + } + } +} diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index 6f11f50d..d13290af 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -362,7 +362,7 @@ public sealed class UpdateFrameOrchestratorTests Assert.DoesNotContain("_streamingFrame", source, StringComparison.Ordinal); Assert.Equal(1, CountOccurrences( source, - "_updateFrameOrchestrator.Tick(")); + "_frameGraphs.Tick(")); Assert.DoesNotContain("_streamingController.Tick(observerCx", source, StringComparison.Ordinal); Assert.DoesNotContain("DungeonStreamingGate.Compute", source, @@ -650,7 +650,7 @@ public sealed class UpdateFrameOrchestratorTests "GameWindow.cs")); Assert.Equal(1, CountOccurrences( source, - "_updateFrameOrchestrator.Tick(")); + "_frameGraphs.Tick(")); Assert.DoesNotContain("CanAdvanceLocalPlayer", source, StringComparison.Ordinal); Assert.DoesNotContain("GetCombatCameraTargetPoint()", source, StringComparison.Ordinal); @@ -686,7 +686,7 @@ public sealed class UpdateFrameOrchestratorTests Assert.Equal(1, CountOccurrences( source, - "_updateFrameOrchestrator.Tick(")); + "_frameGraphs.Tick(")); Assert.DoesNotContain("_updateFrameClock.Advance(", source, StringComparison.Ordinal); Assert.DoesNotContain("_liveFrameCoordinator", source, @@ -699,7 +699,7 @@ public sealed class UpdateFrameOrchestratorTests source, "private void OnUpdate(double dt)", "_frameProfiler.BeginStage(", - "_updateFrameOrchestrator.Tick(", + "_frameGraphs.Tick(", "private void OnRender(double deltaSeconds)"); }