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 <codex@openai.com>
This commit is contained in:
parent
fec0d94148
commit
c87b15303d
41 changed files with 3768 additions and 355 deletions
14
AGENTS.md
14
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,
|
user-gated. Deterministic world-lifecycle automation protects fresh login,
|
||||||
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
||||||
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
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
|
**Active prerequisite before new M4 subsystem work:** continue the
|
||||||
behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21.
|
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
|
update-frame orchestration, and render-frame orchestration all have typed
|
||||||
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
|
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
|
||||||
the frame owners have no direct window or anonymous callback-bag ownership.
|
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
|
native-window callback intake has reversible lifetime ownership, and
|
||||||
`LiveSessionHost` owns reset/binding composition over the sole canonical
|
`LiveSessionHost` owns reset/binding composition over the sole canonical
|
||||||
session controller. `WorldEnvironmentController` solely owns the clock, DAT
|
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
|
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
|
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.
|
lifecycle/reconnect gate, and 395.2-second synchronized nine-stop soak pass.
|
||||||
Slice 8 checkpoints H–L—resource ownership, ordered composition,
|
Checkpoint H adds sole terrain/sky render-resource roots, one retained
|
||||||
retryable shutdown, soak snapshots, and final connected gates—remain active.
|
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
|
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
|
||||||
`docs/architecture/code-structure.md`. **Carried:** #232, #153,
|
`docs/architecture/code-structure.md`. **Carried:** #232, #153,
|
||||||
#116, remaining
|
#116, remaining
|
||||||
|
|
|
||||||
14
CLAUDE.md
14
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,
|
user-gated. Deterministic world-lifecycle automation protects fresh login,
|
||||||
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
||||||
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
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
|
**Active prerequisite before new M4 subsystem work:** continue the
|
||||||
behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21.
|
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
|
update-frame orchestration, and render-frame orchestration all have typed
|
||||||
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
|
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
|
||||||
the frame owners have no direct window or anonymous callback-bag ownership.
|
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
|
native-window callback intake has reversible lifetime ownership, and
|
||||||
`LiveSessionHost` owns reset/binding composition over the sole canonical
|
`LiveSessionHost` owns reset/binding composition over the sole canonical
|
||||||
session controller. `WorldEnvironmentController` solely owns the clock, DAT
|
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
|
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
|
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.
|
lifecycle/reconnect gate, and 395.2-second synchronized nine-stop soak pass.
|
||||||
Slice 8 checkpoints H–L—resource ownership, ordered composition,
|
Checkpoint H adds sole terrain/sky render-resource roots, one retained
|
||||||
retryable shutdown, soak snapshots, and final connected gates—remain active.
|
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
|
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
|
||||||
`docs/architecture/code-structure.md`. **Carried:** #232, #153,
|
`docs/architecture/code-structure.md`. **Carried:** #232, #153,
|
||||||
#116, remaining
|
#116, remaining
|
||||||
|
|
|
||||||
|
|
@ -829,15 +829,18 @@ Current movement/collision ownership:
|
||||||
routers; Slice 4 moved hydration, teardown, inbound authority, network
|
routers; Slice 4 moved hydration, teardown, inbound authority, network
|
||||||
updates, and reusable motion-runtime policy over canonical
|
updates, and reusable motion-runtime policy over canonical
|
||||||
`LiveEntityRuntime`. Slices 5–7 complete landblock presentation and the
|
`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
|
callback shell, live-session composition, world environment, reversible
|
||||||
input/pointer lifetime, physical framebuffer publication, the sole gameplay
|
input/pointer lifetime, physical framebuffer publication, the sole gameplay
|
||||||
action-priority graph, typed commands, the retained-root item-drop edge, and
|
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,
|
persistence/current-state owner, supplies the immutable pre-window snapshot,
|
||||||
applies startup pacing/display/FOV/audio once, and later borrows complete
|
applies startup pacing/display/FOV/audio once, and later borrows complete
|
||||||
runtime targets without replay. Explicit resource ownership and frame slots
|
runtime targets without replay. Checked GL construction/state boundaries
|
||||||
are the next checkpoint. The reconciled ledger and sequence live in
|
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
|
`docs/architecture/code-structure.md`. This decision does not reclassify the
|
||||||
remaining L.2 conformance work or Track LH.
|
remaining L.2 conformance work or Track LH.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -694,7 +694,7 @@ slices. `OnClosing` delegates to the existing retryable shutdown transaction.
|
||||||
Silk callbacks become narrow calls into the input, update, render, resize,
|
Silk callbacks become narrow calls into the input, update, render, resize,
|
||||||
focus, and shutdown owners.
|
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`
|
camera cursor/focus/scroll/sensitivity behavior; `FramebufferResizeController`
|
||||||
alone publishes physical framebuffer changes. All Silk, dispatcher, retained
|
alone publishes physical framebuffer changes. All Silk, dispatcher, retained
|
||||||
UI, and optional devtools device edges are reversible, transactional, and
|
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
|
startup even when devtools are disabled; this is a deliberate correction of the
|
||||||
old accidental SettingsVM gate. The controller also preserves unrelated unsaved
|
old accidental SettingsVM gate. The controller also preserves unrelated unsaved
|
||||||
Gameplay draft fields when combat preferences change. `GameWindow` is now 3,663
|
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
|
raw lines / 162 fields / 37 methods at G. H adds sole lifetime roots for the
|
||||||
resource ownership and frame slots next. The Checkpoint G production App build
|
terrain atlas and dedicated sky shader, one retained Host/runtime lease, an
|
||||||
is warning-free, all three corrected-diff reviews are clean, and the complete
|
atomic update/render frame-root slot, and a prepare-aware portal fallback and
|
||||||
Release suite passes 7,553 tests / 5 intentional skips.
|
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
|
### 4.4 Exit criteria
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# acdream — strategic roadmap
|
# 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.
|
**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
|
checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse
|
||||||
process-residency gate's diagnostic blind spot; no tolerance was loosened.
|
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
|
the frozen shell boundary, all nine native callback edges, live-session
|
||||||
reset/selection/entry/route composition, and world clock/sky/day/weather/
|
reset/selection/entry/route composition, and world clock/sky/day/weather/
|
||||||
AdminEnvirons state. Checkpoint E adds reversible, transactional Silk,
|
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
|
Checkpoint G adds the pre-window `RuntimeSettingsController`, one immutable
|
||||||
startup snapshot, ordered startup pacing/display/FOV/audio application, late
|
startup snapshot, ordered startup pacing/display/FOV/audio application, late
|
||||||
runtime-target binding without replay, and one current/toon settings owner.
|
runtime-target binding without replay, and one current/toon settings owner.
|
||||||
`GameWindow` is now 3,663 lines / 162 fields / 37 methods. Resource transfer,
|
`GameWindow` is 3,663 lines / 162 fields / 37 methods at G. Checkpoint H adds
|
||||||
ordered composition, shutdown, soak, and closeout checkpoints H–L remain. The
|
sole terrain-atlas and sky-shader roots, one retained Host/runtime lease, an
|
||||||
Checkpoint G Release suite passes 7,553 tests / 5 intentional skips, and all
|
atomic update/render frame-root pair, prepared portal fallback/transfer, and
|
||||||
three corrected-diff reviews are clean.
|
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
|
This is a behavior-preserving structural program. Severe regressions still get
|
||||||
root-cause fixes in separate commits; ordinary feature work resumes with M4
|
root-cause fixes in separate commits; ordinary feature work resumes with M4
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized
|
||||||
nine-destination soak pass with graceful exits, and six stable PNG checkpoints
|
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
|
native callbacks and live-session composition now have explicit retryable
|
||||||
owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky,
|
owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky,
|
||||||
day group, weather, and AdminEnvirons state. Checkpoint E adds terminal,
|
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
|
reversible retained-root item-drop binding. Checkpoint G adds the sole
|
||||||
pre-window/runtime settings owner, one startup snapshot, ordered one-shot
|
pre-window/runtime settings owner, one startup snapshot, ordered one-shot
|
||||||
startup application, and inert late target binding. `GameWindow` is 3,663
|
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
|
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
|
threshold. Checkpoint H passes 61 focused ownership tests and 3,236 App tests /
|
||||||
three corrected-diff reviews are clean.
|
3 skips; the complete Release suite passes 7,606 tests / 5 skips and all three
|
||||||
|
final corrected-diff reviews are clean.
|
||||||
|
|
||||||
Carried:
|
Carried:
|
||||||
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus
|
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus
|
||||||
|
|
|
||||||
|
|
@ -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<T>` and `GameRenderResourceLifetime`
|
||||||
|
|
||||||
|
- `OwnedResourceSlot<T>` 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<T>`
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
|
@ -26,7 +26,7 @@ audit.
|
||||||
- [x] F — extract the sole gameplay input-action router plus focused combat and
|
- [x] F — extract the sole gameplay input-action router plus focused combat and
|
||||||
diagnostic command owners.
|
diagnostic command owners.
|
||||||
- [x] G — extract two-phase persisted settings/display/quality ownership.
|
- [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.
|
explicit single ownership and transfer seams.
|
||||||
- [ ] I — group `OnLoad` into small ordered, fakeable composition phases with
|
- [ ] I — group `OnLoad` into small ordered, fakeable composition phases with
|
||||||
transactional partial-acquisition rollback.
|
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
|
Inject GL compile/link/texture-allocation failure after every internal name
|
||||||
and prove exact rollback/residency release.
|
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
|
### I — ordered production composition
|
||||||
|
|
||||||
- Split the 2,289-line `OnLoad` body at §3.6 boundaries using the fixed typed
|
- Split the 2,289-line `OnLoad` body at §3.6 boundaries using the fixed typed
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
The behavior-preserving App ownership campaign is complete through Slice 7 and
|
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
|
Slice 8 checkpoints A–H. `GameWindow.cs` moved from the 2026-07-21 baseline of
|
||||||
15,723 lines / 278 fields / 205 methods to 3,663 lines / 162 fields / 37
|
15,723 lines / 278 fields / 205 methods to 3,689 lines / 162 fields / 37
|
||||||
methods: 12,060 lines (76.7%) were removed without changing accepted gameplay
|
methods: 12,034 lines (76.5%) were removed without changing accepted gameplay
|
||||||
or rendering behavior.
|
or rendering behavior.
|
||||||
|
|
||||||
| Slice | Ownership moved out | Closeout size |
|
| 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 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 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 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
|
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
|
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
|
SettingsVM gate. AP-121 records the intentional modern persistence added to
|
||||||
the named-retail `/framerate` live toggle/notice.
|
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`,
|
Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`,
|
||||||
`c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`.
|
`c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`.
|
||||||
Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`,
|
Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`,
|
||||||
|
|
@ -128,6 +141,12 @@ review rule live in `docs/architecture/code-structure.md`.
|
||||||
|
|
||||||
## Accepted verification
|
## 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
|
- 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
|
skips; App 3,183/3 skips, UI Abstractions 534/0, 35 focused App
|
||||||
settings/boundary tests, and all 43 `SettingsVMTests` pass. The production 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
|
## Next work
|
||||||
|
|
||||||
Slice 8 checkpoints H–L group the remaining resource ownership, ordered
|
Slice 8 checkpoints I–L group the remaining ordered startup composition,
|
||||||
startup composition, and shutdown wiring and reduce Silk callbacks to
|
shutdown wiring, canonical soak snapshots, and closeout and reduce Silk callbacks to
|
||||||
narrow calls into input, update, render, resize, focus, and shutdown owners.
|
narrow calls into input, update, render, resize, focus, and shutdown owners.
|
||||||
It removes leftover feature state only where Slices 1–7 already established a
|
It removes leftover feature state only where Slices 1–7 already established a
|
||||||
canonical owner; it does not redesign gameplay, rendering, or resource lifetime.
|
canonical owner; it does not redesign gameplay, rendering, or resource lifetime.
|
||||||
|
|
|
||||||
68
src/AcDream.App/Rendering/BindlessTextureMutationGuard.cs
Normal file
68
src/AcDream.App/Rendering/BindlessTextureMutationGuard.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Temporarily removes bindless residency around texture-state mutation while
|
||||||
|
/// retaining the obligation to restore the pair across failed mutation or
|
||||||
|
/// failed reacquisition attempts.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/AcDream.App/Rendering/BindlessTexturePair.cs
Normal file
94
src/AcDream.App/Rendering/BindlessTexturePair.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class BindlessTexturePair(
|
||||||
|
uint firstTexture,
|
||||||
|
uint secondTexture,
|
||||||
|
Func<uint, ulong> acquire,
|
||||||
|
Action<ulong> 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<Exception>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/AcDream.App/Rendering/GameFrameGraphSlot.cs
Normal file
67
src/AcDream.App/Rendering/GameFrameGraphSlot.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/AcDream.App/Rendering/GameRenderResourceLifetime.cs
Normal file
21
src/AcDream.App/Rendering/GameRenderResourceLifetime.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sole lifetime owner for render resources that are borrowed by, but not
|
||||||
|
/// owned by, their renderers.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GameRenderResourceLifetime
|
||||||
|
{
|
||||||
|
private readonly OwnedResourceSlot<TerrainAtlas> _terrainAtlas = new();
|
||||||
|
private readonly OwnedResourceSlot<Shader> _skyShader = new();
|
||||||
|
|
||||||
|
public TerrainAtlas AcquireTerrainAtlas(Func<TerrainAtlas> factory) =>
|
||||||
|
_terrainAtlas.Acquire(factory);
|
||||||
|
|
||||||
|
public Shader AcquireSkyShader(Func<Shader> factory) =>
|
||||||
|
_skyShader.Acquire(factory);
|
||||||
|
|
||||||
|
public void ReleaseTerrainAtlas() => _terrainAtlas.Release();
|
||||||
|
|
||||||
|
public void ReleaseSkyShader() => _skyShader.Release();
|
||||||
|
}
|
||||||
|
|
@ -92,8 +92,11 @@ public sealed class GameWindow : IDisposable
|
||||||
private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots;
|
private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots;
|
||||||
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
|
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
|
||||||
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
|
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
|
||||||
private AcDream.App.Rendering.RenderFrameOrchestrator?
|
private readonly AcDream.App.Rendering.GameFrameGraphSlot _frameGraphs = new();
|
||||||
_renderFrameOrchestrator;
|
private readonly AcDream.App.Rendering.GameRenderResourceLifetime
|
||||||
|
_renderResourceLifetime = new();
|
||||||
|
private readonly AcDream.App.Rendering.GlConstructionCleanupLedger
|
||||||
|
_glConstructionCleanup = new();
|
||||||
private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment =
|
private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment =
|
||||||
new(Console.WriteLine);
|
new(Console.WriteLine);
|
||||||
private ResourceShutdownTransaction? _shutdown;
|
private ResourceShutdownTransaction? _shutdown;
|
||||||
|
|
@ -148,7 +151,6 @@ public sealed class GameWindow : IDisposable
|
||||||
private readonly AcDream.App.Input.MovementTruthDiagnosticController
|
private readonly AcDream.App.Input.MovementTruthDiagnosticController
|
||||||
_movementTruthDiagnostics;
|
_movementTruthDiagnostics;
|
||||||
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
|
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
|
||||||
private AcDream.App.Update.UpdateFrameOrchestrator _updateFrameOrchestrator = null!;
|
|
||||||
private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new();
|
private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new();
|
||||||
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
|
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
|
||||||
// Step 7 projectile presentation. The controller owns no identity map;
|
// 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
|
// R1 (render redesign): portal-view draw owners are retained transitively
|
||||||
// by the frame orchestrator rather than duplicated as GameWindow roots.
|
// by the frame orchestrator rather than duplicated as GameWindow roots.
|
||||||
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
|
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
|
// 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
|
// 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.
|
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
|
||||||
private AcDream.App.UI.UiHost? _uiHost;
|
private AcDream.App.UI.UiHost? _uiHost;
|
||||||
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
|
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
|
||||||
|
private readonly AcDream.App.UI.RetailUiRuntimeLease _retailUiLease = new();
|
||||||
private readonly AcDream.App.Combat.CombatAttackOperationsSlot
|
private readonly AcDream.App.Combat.CombatAttackOperationsSlot
|
||||||
_combatAttackOperations = new();
|
_combatAttackOperations = new();
|
||||||
private readonly AcDream.App.Combat.CombatFeedbackSlot
|
private readonly AcDream.App.Combat.CombatFeedbackSlot
|
||||||
|
|
@ -638,7 +642,15 @@ public sealed class GameWindow : IDisposable
|
||||||
_displayFramePacing,
|
_displayFramePacing,
|
||||||
_hostQuiescence);
|
_hostQuiescence);
|
||||||
_windowCallbacks.Attach();
|
_windowCallbacks.Attach();
|
||||||
_window.Run();
|
try
|
||||||
|
{
|
||||||
|
_window.Run();
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
_glConstructionCleanup.RetainFrom(failure);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnLoad()
|
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
|
// Build the terrain atlas once from the Region dat. Phase N.5b: the
|
||||||
// atlas exposes bindless handles for the modern terrain path, so
|
// atlas exposes bindless handles for the modern terrain path, so
|
||||||
// BindlessSupport is threaded through.
|
// 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()
|
// A.5 T22.5: apply anisotropic level from quality preset. Build()
|
||||||
// hard-codes 16x; override here to match the resolved quality so Low
|
// hard-codes 16x; override here to match the resolved quality so Low
|
||||||
// (4x) and Medium (8x) actually take effect.
|
// (4x) and Medium (8x) actually take effect.
|
||||||
|
|
@ -1230,11 +1246,12 @@ public sealed class GameWindow : IDisposable
|
||||||
if (_options.RetailUi)
|
if (_options.RetailUi)
|
||||||
{
|
{
|
||||||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||||
_uiHost = new AcDream.App.UI.UiHost(
|
_uiHost = _retailUiLease.AcquireHost(
|
||||||
_gl,
|
() => new AcDream.App.UI.UiHost(
|
||||||
shadersDir,
|
_gl,
|
||||||
_debugFont,
|
shadersDir,
|
||||||
_hostQuiescence);
|
_debugFont,
|
||||||
|
_hostQuiescence));
|
||||||
_retainedInputCapture.Root = _uiHost.Root;
|
_retainedInputCapture.Root = _uiHost.Root;
|
||||||
_uiHost.Root.UiLocked = _runtimeSettings.Gameplay.LockUI;
|
_uiHost.Root.UiLocked = _runtimeSettings.Gameplay.LockUI;
|
||||||
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
||||||
|
|
@ -1360,8 +1377,7 @@ public sealed class GameWindow : IDisposable
|
||||||
UiProbeLog);
|
UiProbeLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(
|
var retailUiBindings = new AcDream.App.UI.RetailUiRuntimeBindings(
|
||||||
new AcDream.App.UI.RetailUiRuntimeBindings(
|
|
||||||
Host: _uiHost,
|
Host: _uiHost,
|
||||||
Assets: new AcDream.App.UI.RetailUiAssets(
|
Assets: new AcDream.App.UI.RetailUiAssets(
|
||||||
_dats!, _datLock, ResolveChrome, ResolveDatFont,
|
_dats!, _datLock, ResolveChrome, ResolveDatFont,
|
||||||
|
|
@ -1511,7 +1527,10 @@ public sealed class GameWindow : IDisposable
|
||||||
action => _inputDispatcher?.TryInvokeAutomationAction(action) == true,
|
action => _inputDispatcher?.TryInvokeAutomationAction(action) == true,
|
||||||
(action, held) =>
|
(action, held) =>
|
||||||
_inputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
|
_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
|
// T1: invisible portal depth writes (seal/punch) — retail
|
||||||
// DrawPortalPolyInternal (Ghidra 0x0059bc90).
|
// DrawPortalPolyInternal (Ghidra 0x0059bc90).
|
||||||
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
|
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
|
||||||
_portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired(
|
_portalTunnelFallback.AcquirePrepared(
|
||||||
_gl,
|
() => AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired(
|
||||||
_dats!,
|
_gl,
|
||||||
_animLoader!,
|
_dats!,
|
||||||
_hookRouter,
|
_animLoader!,
|
||||||
_wbDrawDispatcher,
|
_hookRouter,
|
||||||
_sceneLightingUbo!,
|
_wbDrawDispatcher,
|
||||||
_wbMeshAdapter!);
|
_sceneLightingUbo!,
|
||||||
|
_wbMeshAdapter!),
|
||||||
|
static portalTunnel => portalTunnel.PrepareResources());
|
||||||
// Publish the presentation before touching the mesh-reference
|
// Publish the presentation before touching the mesh-reference
|
||||||
// backend. Partial acquisition remains reachable by staged
|
// backend. Partial acquisition remains reachable by staged
|
||||||
// shutdown instead of becoming constructor-local leaked state.
|
// shutdown instead of becoming constructor-local leaked state.
|
||||||
_portalTunnel.PrepareResources();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
|
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
|
||||||
// with depth writes off + far plane 1e6 so celestial meshes
|
// with depth writes off + far plane 1e6 so celestial meshes
|
||||||
// never clip. Shares the TextureCache with the static pipeline.
|
// never clip. Shares the TextureCache with the static pipeline.
|
||||||
var skyShader = new Shader(_gl,
|
var skyShader = _renderResourceLifetime.AcquireSkyShader(
|
||||||
Path.Combine(shadersDir, "sky.vert"),
|
() => new Shader(
|
||||||
Path.Combine(shadersDir, "sky.frag"));
|
_gl,
|
||||||
|
Path.Combine(shadersDir, "sky.vert"),
|
||||||
|
Path.Combine(shadersDir, "sky.frag")));
|
||||||
_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(
|
_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(
|
||||||
_gl, _dats, skyShader, _textureCache!, _samplerCache);
|
_gl, _dats, skyShader, _textureCache!, _samplerCache);
|
||||||
|
|
||||||
|
|
@ -2422,13 +2444,8 @@ public sealed class GameWindow : IDisposable
|
||||||
_localPlayerMode,
|
_localPlayerMode,
|
||||||
_playerModeController));
|
_playerModeController));
|
||||||
_playerModeController.BindAutoEntry(_playerModeAutoEntry);
|
_playerModeController.BindAutoEntry(_playerModeAutoEntry);
|
||||||
var localTeleportPresentation =
|
_localPlayerTeleport = _portalTunnelFallback.Transfer(
|
||||||
new AcDream.App.Streaming.LocalPlayerTeleportPresentation(
|
portalTunnel => new AcDream.App.Streaming.LocalPlayerTeleportController(
|
||||||
_portalTunnel
|
|
||||||
?? throw new InvalidOperationException(
|
|
||||||
"Portal presentation was not composed before local teleport."));
|
|
||||||
_localPlayerTeleport =
|
|
||||||
new AcDream.App.Streaming.LocalPlayerTeleportController(
|
|
||||||
new AcDream.App.Streaming.LiveLocalPlayerTeleportAuthority(
|
new AcDream.App.Streaming.LiveLocalPlayerTeleportAuthority(
|
||||||
_liveEntities,
|
_liveEntities,
|
||||||
_localPlayerIdentity),
|
_localPlayerIdentity),
|
||||||
|
|
@ -2451,11 +2468,8 @@ public sealed class GameWindow : IDisposable
|
||||||
liveSpatialReconciler),
|
liveSpatialReconciler),
|
||||||
new AcDream.App.Streaming.LocalPlayerTeleportSession(
|
new AcDream.App.Streaming.LocalPlayerTeleportSession(
|
||||||
_liveSessionController),
|
_liveSessionController),
|
||||||
localTeleportPresentation);
|
new AcDream.App.Streaming.LocalPlayerTeleportPresentation(
|
||||||
// Ownership transferred to LocalPlayerTeleportController. This field
|
portalTunnel)));
|
||||||
// remains only as the staged-startup fallback used by shutdown when
|
|
||||||
// composition fails before the transfer.
|
|
||||||
_portalTunnel = null;
|
|
||||||
_localPlayerTeleportSink.Bind(_localPlayerTeleport);
|
_localPlayerTeleportSink.Bind(_localPlayerTeleport);
|
||||||
var teleportRenderState =
|
var teleportRenderState =
|
||||||
new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
|
new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
|
||||||
|
|
@ -2652,7 +2666,7 @@ public sealed class GameWindow : IDisposable
|
||||||
renderFrameResources,
|
renderFrameResources,
|
||||||
_devToolsFramePresenter,
|
_devToolsFramePresenter,
|
||||||
renderWeatherFrame);
|
renderWeatherFrame);
|
||||||
_renderFrameOrchestrator =
|
var renderFrameOrchestrator =
|
||||||
new AcDream.App.Rendering.RenderFrameOrchestrator(
|
new AcDream.App.Rendering.RenderFrameOrchestrator(
|
||||||
_gpuFrameFlights!,
|
_gpuFrameFlights!,
|
||||||
framePreparation,
|
framePreparation,
|
||||||
|
|
@ -2681,7 +2695,7 @@ public sealed class GameWindow : IDisposable
|
||||||
Combat,
|
Combat,
|
||||||
_selection,
|
_selection,
|
||||||
_worldSelectionQuery!));
|
_worldSelectionQuery!));
|
||||||
_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(
|
var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(
|
||||||
new AcDream.App.Update.LiveEntityTeardownFramePhase(
|
new AcDream.App.Update.LiveEntityTeardownFramePhase(
|
||||||
_liveEntities),
|
_liveEntities),
|
||||||
new AcDream.App.Update.ConsoleUpdateFrameFailureSink(),
|
new AcDream.App.Update.ConsoleUpdateFrameFailureSink(),
|
||||||
|
|
@ -2697,6 +2711,7 @@ public sealed class GameWindow : IDisposable
|
||||||
new AcDream.App.Update.PlayerModeAutoEntryFramePhase(
|
new AcDream.App.Update.PlayerModeAutoEntryFramePhase(
|
||||||
_playerModeAutoEntry),
|
_playerModeAutoEntry),
|
||||||
cameraFrame);
|
cameraFrame);
|
||||||
|
_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);
|
||||||
_liveSessionHost = CreateLiveSessionHost();
|
_liveSessionHost = CreateLiveSessionHost();
|
||||||
|
|
||||||
AcDream.UI.Abstractions.Panels.Debug.DebugVM? debugVm = _debugVm;
|
AcDream.UI.Abstractions.Panels.Debug.DebugVM? debugVm = _debugVm;
|
||||||
|
|
@ -3055,8 +3070,7 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
using var _updStage = _frameProfiler.BeginStage(
|
using var _updStage = _frameProfiler.BeginStage(
|
||||||
AcDream.App.Diagnostics.FrameStage.Update);
|
AcDream.App.Diagnostics.FrameStage.Update);
|
||||||
_updateFrameOrchestrator.Tick(
|
_frameGraphs.Tick(new AcDream.App.Update.UpdateFrameInput(dt));
|
||||||
new AcDream.App.Update.UpdateFrameInput(dt));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Performance overlay state — updated every ~0.5s and written to the
|
// 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)
|
private void OnRender(double deltaSeconds)
|
||||||
{
|
{
|
||||||
Vector2D<int> size = _window!.Size;
|
Vector2D<int> size = _window!.Size;
|
||||||
_renderFrameOrchestrator!.Render(
|
_frameGraphs.Render(
|
||||||
new AcDream.App.Rendering.RenderFrameInput(
|
new AcDream.App.Rendering.RenderFrameInput(
|
||||||
deltaSeconds,
|
deltaSeconds,
|
||||||
size.X,
|
size.X,
|
||||||
size.Y));
|
size.Y),
|
||||||
|
out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot
|
private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot
|
||||||
|
|
@ -3336,7 +3351,7 @@ public sealed class GameWindow : IDisposable
|
||||||
new("dispatcher", () => _inputDispatcher?.Deactivate()),
|
new("dispatcher", () => _inputDispatcher?.Deactivate()),
|
||||||
new("mouse source", () => _mouseSource?.Deactivate()),
|
new("mouse source", () => _mouseSource?.Deactivate()),
|
||||||
new("keyboard source", () => _kbSource?.Deactivate()),
|
new("keyboard source", () => _kbSource?.Deactivate()),
|
||||||
new("retained UI input", () => _uiHost?.QuiesceInput()),
|
new("retained UI input", _retailUiLease.QuiesceInput),
|
||||||
new("developer tools input", () => _devToolsInputContext?.Deactivate()),
|
new("developer tools input", () => _devToolsInputContext?.Deactivate()),
|
||||||
]),
|
]),
|
||||||
// Live-session reset retires the complete streaming window. Every
|
// Live-session reset retires the complete streaming window. Every
|
||||||
|
|
@ -3390,7 +3405,7 @@ public sealed class GameWindow : IDisposable
|
||||||
"Gameplay action callback removal remains pending.");
|
"Gameplay action callback removal remains pending.");
|
||||||
_gameplayInputActions = null;
|
_gameplayInputActions = null;
|
||||||
}),
|
}),
|
||||||
new("retained UI input", () => _uiHost?.DeactivateInput()),
|
new("retained UI input", _retailUiLease.DeactivateInput),
|
||||||
new("developer tools input", () => _devToolsInputContext?.Dispose()),
|
new("developer tools input", () => _devToolsInputContext?.Dispose()),
|
||||||
new("camera pointer", () =>
|
new("camera pointer", () =>
|
||||||
{
|
{
|
||||||
|
|
@ -3444,7 +3459,7 @@ public sealed class GameWindow : IDisposable
|
||||||
new("runtime settings targets", _runtimeSettings.UnbindRuntimeTargets),
|
new("runtime settings targets", _runtimeSettings.UnbindRuntimeTargets),
|
||||||
new("world frame composition", () =>
|
new("world frame composition", () =>
|
||||||
{
|
{
|
||||||
_renderFrameOrchestrator = null;
|
_frameGraphs.Withdraw();
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
new ResourceShutdownStage("session dependents",
|
new ResourceShutdownStage("session dependents",
|
||||||
|
|
@ -3461,7 +3476,10 @@ public sealed class GameWindow : IDisposable
|
||||||
}),
|
}),
|
||||||
new("retail UI", () =>
|
new("retail UI", () =>
|
||||||
{
|
{
|
||||||
_retailUiRuntime?.Dispose();
|
_retailUiLease.Dispose();
|
||||||
|
if (!_retailUiLease.IsDisposalComplete)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The retained UI ownership lease did not complete disposal.");
|
||||||
_retailUiRuntime = null;
|
_retailUiRuntime = null;
|
||||||
_retainedInputCapture.Root = null;
|
_retainedInputCapture.Root = null;
|
||||||
_uiHost = null;
|
_uiHost = null;
|
||||||
|
|
@ -3544,8 +3562,7 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
_localPlayerTeleport?.Dispose();
|
_localPlayerTeleport?.Dispose();
|
||||||
_localPlayerTeleport = null;
|
_localPlayerTeleport = null;
|
||||||
_portalTunnel?.Dispose();
|
_portalTunnelFallback.ReleaseFallback();
|
||||||
_portalTunnel = null;
|
|
||||||
}),
|
}),
|
||||||
new("paperdoll viewport", () =>
|
new("paperdoll viewport", () =>
|
||||||
{
|
{
|
||||||
|
|
@ -3589,6 +3606,15 @@ public sealed class GameWindow : IDisposable
|
||||||
new("frame pacing", _displayFramePacing.Dispose),
|
new("frame pacing", _displayFramePacing.Dispose),
|
||||||
new("frame profiler", _frameProfiler.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 ResourceShutdownStage("frame flight owner",
|
||||||
[
|
[
|
||||||
new("frame flights", () =>
|
new("frame flights", () =>
|
||||||
|
|
|
||||||
102
src/AcDream.App/Rendering/GlConstructionCleanupLedger.cs
Normal file
102
src/AcDream.App/Rendering/GlConstructionCleanupLedger.cs
Normal file
|
|
@ -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<Exception> failures)
|
||||||
|
: base(message, failures)
|
||||||
|
{
|
||||||
|
_cleanup = cleanup ?? throw new ArgumentNullException(nameof(cleanup));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsCleanupComplete => _cleanup.IsCleanupComplete;
|
||||||
|
|
||||||
|
public void RetryCleanup() => _cleanup.RetryCleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GlConstructionCleanupLedger : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<IRetryableResourceCleanup> _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<Exception>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/AcDream.App/Rendering/GlResourceCommand.cs
Normal file
152
src/AcDream.App/Rendering/GlResourceCommand.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
using AcDream.App.Rendering.Wb;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<T>(GL gl, string context, Func<T> 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<uint> create,
|
||||||
|
Action<uint> 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<uint> create,
|
||||||
|
Action postcondition,
|
||||||
|
Action<uint> 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<uint> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GlTextureConstructionTransaction(IGlTextureNameApi api)
|
||||||
|
: IRetryableResourceCleanup
|
||||||
|
{
|
||||||
|
private readonly List<uint> _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<Exception>? 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();
|
||||||
|
}
|
||||||
|
|
@ -95,7 +95,7 @@ internal sealed class NullRenderFrameFailureRecovery : IRenderFrameFailureRecove
|
||||||
/// and close failure are reported together, while either failure alone
|
/// and close failure are reported together, while either failure alone
|
||||||
/// propagates directly.
|
/// propagates directly.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class RenderFrameOrchestrator
|
internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
|
||||||
{
|
{
|
||||||
private readonly IRenderFrameLifetime _lifetime;
|
private readonly IRenderFrameLifetime _lifetime;
|
||||||
private readonly IRenderFrameResourcePhase _resources;
|
private readonly IRenderFrameResourcePhase _resources;
|
||||||
|
|
|
||||||
63
src/AcDream.App/Rendering/ResourceCleanupGroup.cs
Normal file
63
src/AcDream.App/Rendering/ResourceCleanupGroup.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reverse-order, all-attempted cleanup owner used while a composite resource
|
||||||
|
/// is still under construction and after it becomes the aggregate owner.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ResourceCleanupGroup : IRetryableResourceCleanup
|
||||||
|
{
|
||||||
|
private sealed record Entry(string Name, Action Release)
|
||||||
|
{
|
||||||
|
public bool Complete { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly List<Entry> _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<Exception>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/AcDream.App/Rendering/RestoredTextureBindingMutation.cs
Normal file
83
src/AcDream.App/Rendering/RestoredTextureBindingMutation.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class RestoredTextureBindingMutation
|
||||||
|
{
|
||||||
|
private uint? _pendingRestore;
|
||||||
|
private bool _operationActive;
|
||||||
|
|
||||||
|
internal bool HasPendingRestore => _pendingRestore.HasValue;
|
||||||
|
|
||||||
|
public void Execute(
|
||||||
|
Func<uint> readBinding,
|
||||||
|
Action<uint> 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<uint> readBinding,
|
||||||
|
Action<uint> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/AcDream.App/Rendering/RuntimeResourceSlots.cs
Normal file
171
src/AcDream.App/Rendering/RuntimeResourceSlots.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>Retryable one-owner slot for a disposable runtime resource.</summary>
|
||||||
|
internal sealed class OwnedResourceSlot<T>
|
||||||
|
where T : class, IDisposable
|
||||||
|
{
|
||||||
|
private T? _resource;
|
||||||
|
private bool _operationActive;
|
||||||
|
|
||||||
|
public bool HasResource => _resource is not null;
|
||||||
|
|
||||||
|
public T Acquire(Func<T> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns a fallback resource until a complete destination owner factory
|
||||||
|
/// returns. A throwing factory leaves fallback ownership unchanged.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class TransferableResourceSlot<T>
|
||||||
|
where T : class, IDisposable
|
||||||
|
{
|
||||||
|
private readonly OwnedResourceSlot<T> _fallback = new();
|
||||||
|
private bool _operationActive;
|
||||||
|
private bool _prepared;
|
||||||
|
|
||||||
|
public bool HasFallback => _fallback.HasResource;
|
||||||
|
|
||||||
|
public T Acquire(Func<T> factory) => Execute(() =>
|
||||||
|
{
|
||||||
|
T resource = _fallback.Acquire(factory);
|
||||||
|
_prepared = true;
|
||||||
|
return resource;
|
||||||
|
});
|
||||||
|
|
||||||
|
public T AcquirePrepared(Func<T> factory, Action<T> 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<TOwner>(Func<T, TOwner> 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<TResult>(Func<TResult> operation)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(operation);
|
||||||
|
if (_operationActive)
|
||||||
|
throw new InvalidOperationException("The transferable resource owner is changing state.");
|
||||||
|
|
||||||
|
_operationActive = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return operation();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_operationActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,36 +7,17 @@ public sealed class Shader : IDisposable
|
||||||
{
|
{
|
||||||
private readonly GL _gl;
|
private readonly GL _gl;
|
||||||
private readonly Dictionary<string, int> _uniformLocations = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, int> _uniformLocations = new(StringComparer.Ordinal);
|
||||||
public uint Program { get; }
|
public uint Program { get; private set; }
|
||||||
|
|
||||||
public Shader(GL gl, string vertexPath, string fragmentPath)
|
public Shader(GL gl, string vertexPath, string fragmentPath)
|
||||||
{
|
{
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
uint vert = Compile(File.ReadAllText(vertexPath), ShaderType.VertexShader);
|
string vertexSource = File.ReadAllText(vertexPath);
|
||||||
uint frag = Compile(File.ReadAllText(fragmentPath), ShaderType.FragmentShader);
|
string fragmentSource = File.ReadAllText(fragmentPath);
|
||||||
|
Program = ShaderProgramConstruction.Build(
|
||||||
Program = _gl.CreateProgram();
|
new GlShaderProgramBuildApi(gl),
|
||||||
_gl.AttachShader(Program, vert);
|
vertexSource,
|
||||||
_gl.AttachShader(Program, frag);
|
fragmentSource);
|
||||||
_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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Use() => _gl.UseProgram(Program);
|
public void Use() => _gl.UseProgram(Program);
|
||||||
|
|
@ -90,7 +71,12 @@ public sealed class Shader : IDisposable
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
uint program = Program;
|
||||||
|
if (program == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
_uniformLocations.Clear();
|
_uniformLocations.Clear();
|
||||||
_gl.DeleteProgram(Program);
|
GlResourceCommand.DeleteProgram(_gl, program, $"delete Shader program {program}");
|
||||||
|
Program = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
325
src/AcDream.App/Rendering/ShaderProgramConstruction.cs
Normal file
325
src/AcDream.App/Rendering/ShaderProgramConstruction.cs
Normal file
|
|
@ -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<Exception> Release(bool includeProgram)
|
||||||
|
{
|
||||||
|
var failures = new List<Exception>();
|
||||||
|
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<Exception> ReleaseProgramOnly()
|
||||||
|
{
|
||||||
|
var failures = new List<Exception>();
|
||||||
|
Try(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (Program != 0)
|
||||||
|
{
|
||||||
|
api.DeleteProgram(Program);
|
||||||
|
Program = 0;
|
||||||
|
VertexAttached = false;
|
||||||
|
FragmentAttached = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
failures);
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RetryCleanup()
|
||||||
|
{
|
||||||
|
List<Exception> 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<Exception> 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<Exception> 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<Exception>(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,12 +61,10 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
||||||
public IReadOnlyList<uint> RoadAlphaRCodes { get; }
|
public IReadOnlyList<uint> RoadAlphaRCodes { get; }
|
||||||
|
|
||||||
private readonly Wb.BindlessSupport? _bindless;
|
private readonly Wb.BindlessSupport? _bindless;
|
||||||
|
private readonly BindlessTexturePair? _bindlessHandles;
|
||||||
// Cached bindless handles. Generated lazily on first GetBindlessHandles() call;
|
private readonly BindlessTextureMutationGuard? _bindlessMutation;
|
||||||
// reused for the lifetime of the atlas.
|
private readonly RestoredTextureBindingMutation _anisotropyBindingMutation = new();
|
||||||
private ulong _terrainHandle;
|
private ResourceShutdownTransaction? _shutdown;
|
||||||
private ulong _alphaHandle;
|
|
||||||
private bool _handlesGenerated;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get 64-bit bindless handles for the terrain + alpha texture arrays.
|
/// 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)
|
if (_bindless is null)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"TerrainAtlas was constructed without BindlessSupport; cannot return bindless handles.");
|
"TerrainAtlas was constructed without BindlessSupport; cannot return bindless handles.");
|
||||||
if (!_handlesGenerated)
|
(ulong terrain, ulong alpha) = _bindlessHandles!.Acquire();
|
||||||
{
|
return (terrain, alpha);
|
||||||
_terrainHandle = _bindless.GetResidentHandle(GlTexture);
|
|
||||||
_alphaHandle = _bindless.GetResidentHandle(GlAlphaTexture);
|
|
||||||
_handlesGenerated = true;
|
|
||||||
}
|
|
||||||
return (_terrainHandle, _alphaHandle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private TerrainAtlas(
|
private TerrainAtlas(
|
||||||
|
|
@ -112,6 +105,15 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
||||||
CornerAlphaTCodes = cornerTCodes;
|
CornerAlphaTCodes = cornerTCodes;
|
||||||
SideAlphaTCodes = sideTCodes;
|
SideAlphaTCodes = sideTCodes;
|
||||||
RoadAlphaRCodes = roadRCodes;
|
RoadAlphaRCodes = roadRCodes;
|
||||||
|
if (bindless is not null)
|
||||||
|
{
|
||||||
|
_bindlessHandles = new BindlessTexturePair(
|
||||||
|
glTexture,
|
||||||
|
glAlphaTexture,
|
||||||
|
bindless.GetResidentHandle,
|
||||||
|
bindless.MakeNonResident);
|
||||||
|
_bindlessMutation = new BindlessTextureMutationGuard(_bindlessHandles);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -120,6 +122,37 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
||||||
/// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY.
|
/// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static TerrainAtlas Build(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
|
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<Region>(0x13000000u)
|
var region = dats.Get<Region>(0x13000000u)
|
||||||
?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
|
?? 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)
|
if (terrainDesc is null || terrainDesc.Count == 0)
|
||||||
{
|
{
|
||||||
Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer");
|
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) ----
|
// ---- Terrain atlas (unchanged Phase 2b logic) ----
|
||||||
|
|
@ -179,29 +212,45 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
int layerCount = decodedByType.Count;
|
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<uint, uint>();
|
var map = new Dictionary<uint, uint>();
|
||||||
int layerIdx = 0;
|
uint tex = TrackedTextureConstruction.Create(
|
||||||
foreach (var kvp in decodedByType)
|
textures,
|
||||||
|
gl,
|
||||||
|
"upload terrain atlas texture array",
|
||||||
|
texture =>
|
||||||
{
|
{
|
||||||
byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH);
|
gl.BindTexture(TextureTarget.Texture2DArray, texture);
|
||||||
fixed (byte* p = buffer)
|
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(
|
byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH);
|
||||||
TextureTarget.Texture2DArray, 0,
|
fixed (byte* p = buffer)
|
||||||
0, 0, layerIdx,
|
{
|
||||||
(uint)maxW, (uint)maxH, 1,
|
gl.TexSubImage3D(
|
||||||
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
|
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(
|
var tilingByLayer = TerrainTextureTilingTable.Build(
|
||||||
map.Select(entry =>
|
map.Select(entry =>
|
||||||
|
|
@ -209,22 +258,12 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
||||||
? repeatCount
|
? repeatCount
|
||||||
: 1u)));
|
: 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)");
|
Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} (mipmaps+aniso16x)");
|
||||||
|
|
||||||
// ---- Alpha atlas (new in Phase 3c.2) ----
|
// ---- Alpha atlas (new in Phase 3c.2) ----
|
||||||
// texMerge is guaranteed non-null here: the early return above exited
|
// texMerge is guaranteed non-null here: the early return above exited
|
||||||
// if texMerge?.TerrainDesc was null.
|
// if texMerge?.TerrainDesc was null.
|
||||||
var alphaBuild = BuildAlphaAtlas(gl, dats, texMerge!);
|
var alphaBuild = BuildAlphaAtlas(gl, dats, texMerge!, textures);
|
||||||
|
|
||||||
return new TerrainAtlas(
|
return new TerrainAtlas(
|
||||||
gl,
|
gl,
|
||||||
|
|
@ -252,7 +291,10 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
||||||
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes);
|
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes);
|
||||||
|
|
||||||
private static AlphaAtlasBuildResult BuildAlphaAtlas(
|
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<DecodedTexture>();
|
var decoded = new List<DecodedTexture>();
|
||||||
var cornerLayers = new List<byte>();
|
var cornerLayers = new List<byte>();
|
||||||
|
|
@ -305,15 +347,21 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
||||||
if (decoded.Count == 0)
|
if (decoded.Count == 0)
|
||||||
{
|
{
|
||||||
Console.WriteLine("WARN: no alpha maps loaded; alpha atlas will be a 1x1 white fallback");
|
Console.WriteLine("WARN: no alpha maps loaded; alpha atlas will be a 1x1 white fallback");
|
||||||
uint fallbackAlpha = gl.GenTexture();
|
uint fallbackAlpha = TrackedTextureConstruction.Create(
|
||||||
gl.BindTexture(TextureTarget.Texture2DArray, fallbackAlpha);
|
textures,
|
||||||
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0,
|
gl,
|
||||||
GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
|
"upload fallback terrain alpha texture array",
|
||||||
var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
|
texture =>
|
||||||
fixed (byte* p = white)
|
{
|
||||||
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1,
|
gl.BindTexture(TextureTarget.Texture2DArray, texture);
|
||||||
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
|
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0,
|
||||||
gl.BindTexture(TextureTarget.Texture2DArray, 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(
|
return new AlphaAtlasBuildResult(
|
||||||
fallbackAlpha, 1,
|
fallbackAlpha, 1,
|
||||||
cornerLayers, sideLayers, roadLayers,
|
cornerLayers, sideLayers, roadLayers,
|
||||||
|
|
@ -329,31 +377,37 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
||||||
if (d.Height > aMaxH) aMaxH = d.Height;
|
if (d.Height > aMaxH) aMaxH = d.Height;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint glAlpha = gl.GenTexture();
|
uint glAlpha = TrackedTextureConstruction.Create(
|
||||||
gl.BindTexture(TextureTarget.Texture2DArray, glAlpha);
|
textures,
|
||||||
gl.TexImage3D(
|
gl,
|
||||||
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
|
"upload terrain alpha texture array",
|
||||||
(uint)aMaxW, (uint)aMaxH, (uint)decoded.Count,
|
texture =>
|
||||||
0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
|
|
||||||
|
|
||||||
for (int i = 0; i < decoded.Count; i++)
|
|
||||||
{
|
{
|
||||||
var buffer = ResizeRgba8Nearest(decoded[i], aMaxW, aMaxH);
|
gl.BindTexture(TextureTarget.Texture2DArray, texture);
|
||||||
fixed (byte* p = buffer)
|
gl.TexImage3D(
|
||||||
{
|
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
|
||||||
gl.TexSubImage3D(
|
(uint)aMaxW, (uint)aMaxH, (uint)decoded.Count,
|
||||||
TextureTarget.Texture2DArray, 0,
|
0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
|
||||||
0, 0, i,
|
|
||||||
(uint)aMaxW, (uint)aMaxH, 1,
|
|
||||||
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
|
for (int i = 0; i < decoded.Count; i++)
|
||||||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
|
{
|
||||||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
|
var buffer = ResizeRgba8Nearest(decoded[i], aMaxW, aMaxH);
|
||||||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge);
|
fixed (byte* p = buffer)
|
||||||
gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
{
|
||||||
|
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(
|
Console.WriteLine(
|
||||||
$"AlphaAtlas: {decoded.Count} layers at {aMaxW}x{aMaxH} "
|
$"AlphaAtlas: {decoded.Count} layers at {aMaxW}x{aMaxH} "
|
||||||
|
|
@ -412,25 +466,40 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
||||||
return dst;
|
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 };
|
var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
|
||||||
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
|
uint tex = TrackedTextureConstruction.Create(
|
||||||
fixed (byte* p = white)
|
textures,
|
||||||
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
|
gl,
|
||||||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
|
"upload fallback terrain texture array",
|
||||||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
|
texture =>
|
||||||
gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
{
|
||||||
|
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
|
// Fallback alpha atlas: 1x1 white, no layers tracked
|
||||||
uint alphaTex = gl.GenTexture();
|
uint alphaTex = TrackedTextureConstruction.Create(
|
||||||
gl.BindTexture(TextureTarget.Texture2DArray, alphaTex);
|
textures,
|
||||||
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
|
gl,
|
||||||
fixed (byte* p = white)
|
"upload fallback alpha texture array",
|
||||||
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
|
texture =>
|
||||||
gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
{
|
||||||
|
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(
|
return new TerrainAtlas(
|
||||||
gl,
|
gl,
|
||||||
|
|
@ -453,46 +522,65 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SetAnisotropic(int level)
|
public void SetAnisotropic(int level)
|
||||||
{
|
{
|
||||||
// If bindless handles are live we must make them non-resident before
|
void Mutate()
|
||||||
// mutating texture state, then re-resident after.
|
|
||||||
bool wasResident = _handlesGenerated && _bindless is not null;
|
|
||||||
if (wasResident)
|
|
||||||
{
|
{
|
||||||
_bindless!.MakeNonResident(_terrainHandle);
|
_anisotropyBindingMutation.Execute(
|
||||||
// Alpha texture is not affected by anisotropic but we must keep
|
() => unchecked((uint)GlResourceCommand.Execute(
|
||||||
// residency symmetric — re-generate both handles after.
|
_gl,
|
||||||
_bindless.MakeNonResident(_alphaHandle);
|
"read terrain-array binding before anisotropy mutation",
|
||||||
_handlesGenerated = false;
|
() => _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);
|
if (_bindlessMutation is not null)
|
||||||
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE
|
_bindlessMutation.Execute(Mutate);
|
||||||
_gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, (float)level);
|
else
|
||||||
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
Mutate();
|
||||||
|
|
||||||
// Re-generate bindless handles if they were live before.
|
|
||||||
if (wasResident)
|
|
||||||
{
|
|
||||||
// GetBindlessHandles regenerates and makes resident.
|
|
||||||
_ = GetBindlessHandles();
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x");
|
Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
// Phase 1: release bindless residency BEFORE deleting textures.
|
_shutdown ??= new ResourceShutdownTransaction(
|
||||||
// ARB_bindless_texture requires this ordering; interleaving is UB.
|
new ResourceShutdownStage(
|
||||||
if (_handlesGenerated && _bindless is not null)
|
"terrain atlas bindless residency",
|
||||||
{
|
[
|
||||||
_bindless.MakeNonResident(_terrainHandle);
|
new ResourceShutdownOperation(
|
||||||
_bindless.MakeNonResident(_alphaHandle);
|
"release terrain and alpha handles",
|
||||||
_handlesGenerated = false;
|
() => _bindlessHandles?.Release()),
|
||||||
}
|
]),
|
||||||
|
new ResourceShutdownStage(
|
||||||
// Phase 2: delete the underlying GL textures.
|
"terrain atlas textures",
|
||||||
_gl.DeleteTexture(GlTexture);
|
[
|
||||||
_gl.DeleteTexture(GlAlphaTexture);
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ public sealed unsafe class TextRenderer : IDisposable
|
||||||
private readonly GL _gl;
|
private readonly GL _gl;
|
||||||
private readonly ITextRenderGlStateApi _glState;
|
private readonly ITextRenderGlStateApi _glState;
|
||||||
private readonly Shader _shader;
|
private readonly Shader _shader;
|
||||||
|
private readonly ResourceCleanupGroup _resources;
|
||||||
private uint _vao;
|
private uint _vao;
|
||||||
private uint _vbo;
|
private uint _vbo;
|
||||||
private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket
|
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;
|
public int UsedBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly FrameBufferSet[] _frameBuffers = new FrameBufferSet[3];
|
private readonly FrameBufferSet[] _frameBuffers;
|
||||||
private FrameBufferSet? _activeFrameBuffer;
|
private FrameBufferSet? _activeFrameBuffer;
|
||||||
|
|
||||||
internal long DynamicBufferCapacityBytes =>
|
internal long DynamicBufferCapacityBytes =>
|
||||||
|
|
@ -80,26 +81,70 @@ public sealed unsafe class TextRenderer : IDisposable
|
||||||
{
|
{
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_glState = new SilkTextRenderGlStateApi(gl);
|
_glState = new SilkTextRenderGlStateApi(gl);
|
||||||
_shader = new Shader(gl,
|
var resources = new ResourceCleanupGroup();
|
||||||
Path.Combine(shaderDir, "ui_text.vert"),
|
Shader? shader = null;
|
||||||
Path.Combine(shaderDir, "ui_text.frag"));
|
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++)
|
for (int i = 0; i < frameBuffers.Length; i++)
|
||||||
_frameBuffers[i] = CreateFrameBufferSet();
|
frameBuffers[i] = CreateFrameBufferSet(resources);
|
||||||
|
|
||||||
// 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE
|
// 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
|
// bucket (the shader multiplies texel×color → white×color = color). Lets a panel
|
||||||
// background draw UNDER its text in painter order, which DrawRect's separate
|
// background draw UNDER its text in painter order, which DrawRect's separate
|
||||||
// bucket cannot (it always composites after all sprites).
|
// bucket cannot (it always composites after all sprites).
|
||||||
_whiteTex = _gl.GenTexture();
|
whiteTexture = GlResourceCommand.CreateTexture(
|
||||||
_gl.BindTexture(TextureTarget.Texture2D, _whiteTex);
|
_gl,
|
||||||
Span<byte> whitePixel = stackalloc byte[] { 255, 255, 255, 255 };
|
"TextRenderer white texture");
|
||||||
fixed (byte* wp = whitePixel)
|
uint ownedWhiteTexture = whiteTexture;
|
||||||
_gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0,
|
resources.Add(
|
||||||
PixelFormat.Rgba, PixelType.UnsignedByte, wp);
|
"white texture",
|
||||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
|
() => GlResourceCommand.DeleteTexture(
|
||||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest);
|
_gl,
|
||||||
_gl.BindTexture(TextureTarget.Texture2D, 0);
|
ownedWhiteTexture,
|
||||||
|
$"delete TextRenderer white texture {ownedWhiteTexture}"));
|
||||||
|
GlResourceCommand.Execute(
|
||||||
|
_gl,
|
||||||
|
"initialize TextRenderer white texture",
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
_gl.BindTexture(TextureTarget.Texture2D, whiteTexture);
|
||||||
|
Span<byte> 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -120,43 +165,54 @@ public sealed unsafe class TextRenderer : IDisposable
|
||||||
_vboCapacityBytes = set.CapacityBytes;
|
_vboCapacityBytes = set.CapacityBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private FrameBufferSet CreateFrameBufferSet()
|
private FrameBufferSet CreateFrameBufferSet(ResourceCleanupGroup resources)
|
||||||
{
|
{
|
||||||
uint vao = 0;
|
uint vao = TrackedGlResource.CreateVertexArray(
|
||||||
uint vbo = 0;
|
_gl,
|
||||||
try
|
"TextRenderer frame VAO creation");
|
||||||
{
|
RetryableGpuResourceRelease vaoRelease =
|
||||||
vao = TrackedGlResource.CreateVertexArray(
|
TrackedGlResource.CreateRetryableVertexArrayDeletion(
|
||||||
_gl,
|
_gl,
|
||||||
"TextRenderer frame VAO creation");
|
vao,
|
||||||
vbo = TrackedGlResource.CreateBuffer(
|
"TextRenderer frame VAO disposal");
|
||||||
_gl,
|
resources.Add("frame VAO", vaoRelease.Run);
|
||||||
"TextRenderer frame VBO creation");
|
var set = new FrameBufferSet { Vao = vao };
|
||||||
var set = new FrameBufferSet { Vao = vao, Vbo = vbo };
|
|
||||||
|
|
||||||
_gl.BindVertexArray(set.Vao);
|
uint vbo = TrackedGlResource.CreateBuffer(
|
||||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo);
|
_gl,
|
||||||
uint stride = FloatsPerVertex * sizeof(float);
|
"TextRenderer frame VBO creation");
|
||||||
_gl.EnableVertexAttribArray(0);
|
set.Vbo = vbo;
|
||||||
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0);
|
RetryableGpuResourceRelease? vboRelease = null;
|
||||||
_gl.EnableVertexAttribArray(1);
|
resources.Add(
|
||||||
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float)));
|
"frame VBO",
|
||||||
_gl.EnableVertexAttribArray(2);
|
() =>
|
||||||
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float)));
|
{
|
||||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
vboRelease ??= TrackedGlResource.CreateRetryableBufferDeletion(
|
||||||
_gl.BindVertexArray(0);
|
_gl,
|
||||||
return set;
|
vbo,
|
||||||
}
|
set.CapacityBytes,
|
||||||
catch
|
"TextRenderer frame VBO disposal");
|
||||||
{
|
vboRelease.Run();
|
||||||
if (vbo != 0)
|
});
|
||||||
TrackedGlResource.DeleteBuffer(
|
|
||||||
_gl, vbo, 0, "TextRenderer frame VBO rollback");
|
GlResourceCommand.Execute(
|
||||||
if (vao != 0)
|
_gl,
|
||||||
TrackedGlResource.DeleteVertexArray(
|
"initialize TextRenderer frame VAO and VBO",
|
||||||
_gl, vao, "TextRenderer frame VAO rollback");
|
() =>
|
||||||
throw;
|
{
|
||||||
}
|
_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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
|
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
|
||||||
|
|
@ -471,19 +527,6 @@ public sealed unsafe class TextRenderer : IDisposable
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_gl.DeleteTexture(_whiteTex);
|
_resources.RetryCleanup();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
src/AcDream.App/Rendering/TrackedTextureConstruction.cs
Normal file
31
src/AcDream.App/Rendering/TrackedTextureConstruction.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
internal static class TrackedTextureConstruction
|
||||||
|
{
|
||||||
|
public static uint Create(
|
||||||
|
GlTextureConstructionTransaction transaction,
|
||||||
|
Action<uint> 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<uint> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Silk.NET.OpenGL;
|
using Silk.NET.OpenGL;
|
||||||
using Silk.NET.OpenGL.Extensions.ARB;
|
using Silk.NET.OpenGL.Extensions.ARB;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering.Wb;
|
namespace AcDream.App.Rendering.Wb;
|
||||||
|
|
||||||
|
|
@ -10,10 +11,12 @@ namespace AcDream.App.Rendering.Wb;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BindlessSupport
|
public sealed class BindlessSupport
|
||||||
{
|
{
|
||||||
|
private readonly GL _gl;
|
||||||
private readonly ArbBindlessTexture _ext;
|
private readonly ArbBindlessTexture _ext;
|
||||||
|
|
||||||
private BindlessSupport(ArbBindlessTexture extension)
|
private BindlessSupport(GL gl, ArbBindlessTexture extension)
|
||||||
{
|
{
|
||||||
|
_gl = gl;
|
||||||
_ext = extension;
|
_ext = extension;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,7 +24,7 @@ public sealed class BindlessSupport
|
||||||
{
|
{
|
||||||
if (gl.TryGetExtension<ArbBindlessTexture>(out var ext))
|
if (gl.TryGetExtension<ArbBindlessTexture>(out var ext))
|
||||||
{
|
{
|
||||||
support = new BindlessSupport(ext);
|
support = new BindlessSupport(gl, ext);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
support = null;
|
support = null;
|
||||||
|
|
@ -32,17 +35,42 @@ public sealed class BindlessSupport
|
||||||
/// Idempotent: handle is the same for a given texture name.</summary>
|
/// Idempotent: handle is the same for a given texture name.</summary>
|
||||||
public ulong GetResidentHandle(uint textureName)
|
public ulong GetResidentHandle(uint textureName)
|
||||||
{
|
{
|
||||||
ulong h = _ext.GetTextureHandle(textureName);
|
ulong h = GlResourceCommand.Execute(
|
||||||
if (!_ext.IsTextureHandleResident(h))
|
_gl,
|
||||||
_ext.MakeTextureHandleResident(h);
|
$"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;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Release residency for a handle. Call before deleting the underlying texture.</summary>
|
/// <summary>Release residency for a handle. Call before deleting the underlying texture.</summary>
|
||||||
public void MakeNonResident(ulong handle)
|
public void MakeNonResident(ulong handle)
|
||||||
{
|
{
|
||||||
if (_ext.IsTextureHandleResident(handle))
|
bool resident = GlResourceCommand.Execute(
|
||||||
_ext.MakeTextureHandleNonResident(handle);
|
_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
|
// Phase N.5b note: a `SetSamplerHandleUniform` wrapper was added in T6
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Silk.NET.OpenGL;
|
using Silk.NET.OpenGL;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering.Wb;
|
namespace AcDream.App.Rendering.Wb;
|
||||||
|
|
||||||
|
|
@ -96,41 +97,68 @@ internal static unsafe class TrackedGlResource
|
||||||
|
|
||||||
public static uint CreateBuffer(GL gl, string context)
|
public static uint CreateBuffer(GL gl, string context)
|
||||||
{
|
{
|
||||||
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
|
return CreateTrackedName(
|
||||||
uint name = gl.GenBuffer();
|
gl,
|
||||||
try
|
"buffer",
|
||||||
{
|
context,
|
||||||
if (name == 0)
|
gl.GenBuffer,
|
||||||
throw new InvalidOperationException($"OpenGL returned buffer name zero. Context: {context}");
|
name => GlResourceCommand.DeleteBuffer(
|
||||||
GLHelpers.ThrowOnResourceError(gl, context);
|
gl,
|
||||||
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
|
name,
|
||||||
return name;
|
$"rollback buffer {name} after failed {context}"),
|
||||||
}
|
() => GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer));
|
||||||
catch
|
|
||||||
{
|
|
||||||
if (name != 0)
|
|
||||||
gl.DeleteBuffer(name);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static uint CreateVertexArray(GL gl, string context)
|
public static uint CreateVertexArray(GL gl, string context)
|
||||||
{
|
{
|
||||||
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
|
return CreateTrackedName(
|
||||||
uint name = gl.GenVertexArray();
|
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<uint> create,
|
||||||
|
Action<uint> 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
|
try
|
||||||
{
|
{
|
||||||
if (name == 0)
|
publishAccounting();
|
||||||
throw new InvalidOperationException($"OpenGL returned vertex-array name zero. Context: {context}");
|
|
||||||
GLHelpers.ThrowOnResourceError(gl, context);
|
|
||||||
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO);
|
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception publicationFailure)
|
||||||
{
|
{
|
||||||
if (name != 0)
|
try
|
||||||
gl.DeleteVertexArray(name);
|
{
|
||||||
throw;
|
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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,8 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
private ResourceShutdownTransaction? _shutdown;
|
private ResourceShutdownTransaction? _shutdown;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal bool IsDisposalComplete => _disposed;
|
||||||
|
|
||||||
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
|
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
|
||||||
{
|
{
|
||||||
_bindings = bindings;
|
_bindings = bindings;
|
||||||
|
|
@ -211,6 +213,15 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
bindings.Host.HideWindow);
|
bindings.Host.HideWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static RetailUiRuntime CreateUninitialized(
|
||||||
|
RetailUiRuntimeBindings bindings)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bindings);
|
||||||
|
return new RetailUiRuntime(bindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void InitializeForLease() => Initialize();
|
||||||
|
|
||||||
private void Initialize()
|
private void Initialize()
|
||||||
{
|
{
|
||||||
RetailUiRuntimeBindings bindings = _bindings;
|
RetailUiRuntimeBindings bindings = _bindings;
|
||||||
|
|
@ -298,7 +309,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(bindings);
|
ArgumentNullException.ThrowIfNull(bindings);
|
||||||
var runtime = new RetailUiRuntime(bindings);
|
var runtime = CreateUninitialized(bindings);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
runtime.Initialize();
|
runtime.Initialize();
|
||||||
|
|
|
||||||
263
src/AcDream.App/UI/RetailUiRuntimeLease.cs
Normal file
263
src/AcDream.App/UI/RetailUiRuntimeLease.cs
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retains one UiHost/runtime ownership chain across partial construction,
|
||||||
|
/// initialization cleanup, lifetime retry, and explicit terminal abandonment.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class RetailUiRuntimeLease : IDisposable
|
||||||
|
{
|
||||||
|
private object? _host;
|
||||||
|
private Action? _quiesceInput;
|
||||||
|
private Action? _deactivateInput;
|
||||||
|
private Action? _disposeHost;
|
||||||
|
private Func<bool>? _hostDisposalComplete;
|
||||||
|
|
||||||
|
private object? _runtime;
|
||||||
|
private Action? _disposeRuntime;
|
||||||
|
private Func<bool>? _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<UiHost> factory) => AcquireHostCore(
|
||||||
|
factory,
|
||||||
|
static host => host.QuiesceInput(),
|
||||||
|
static host => host.DeactivateInput(),
|
||||||
|
static host => host.Dispose(),
|
||||||
|
static host => host.IsDisposalComplete);
|
||||||
|
|
||||||
|
internal T AcquireHostCore<T>(
|
||||||
|
Func<T> factory,
|
||||||
|
Action<T> quiesceInput,
|
||||||
|
Action<T> deactivateInput,
|
||||||
|
Action<T> dispose,
|
||||||
|
Func<T, bool> 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<RetailUiRuntime> factory) => MountCore(
|
||||||
|
factory,
|
||||||
|
static runtime => runtime.InitializeForLease(),
|
||||||
|
static runtime => runtime.Dispose(),
|
||||||
|
static runtime => runtime.IsDisposalComplete);
|
||||||
|
|
||||||
|
internal T MountCore<T>(
|
||||||
|
Func<T> factory,
|
||||||
|
Action<T> initialize,
|
||||||
|
Action<T> dispose,
|
||||||
|
Func<T, bool> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,8 @@ public sealed class UiHost : System.IDisposable
|
||||||
private bool _disposeRequested;
|
private bool _disposeRequested;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal bool IsDisposalComplete => _disposed;
|
||||||
|
|
||||||
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
|
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
|
||||||
: this(gl, shaderDir, defaultFont, new HostQuiescenceGate())
|
: this(gl, shaderDir, defaultFont, new HostQuiescenceGate())
|
||||||
{
|
{
|
||||||
|
|
@ -67,9 +69,9 @@ public sealed class UiHost : System.IDisposable
|
||||||
BitmapFont? defaultFont,
|
BitmapFont? defaultFont,
|
||||||
HostQuiescenceGate quiescence)
|
HostQuiescenceGate quiescence)
|
||||||
{
|
{
|
||||||
|
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||||
TextRenderer = new TextRenderer(gl, shaderDir);
|
TextRenderer = new TextRenderer(gl, shaderDir);
|
||||||
DefaultFont = defaultFont;
|
DefaultFont = defaultFont;
|
||||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-frame ──────────────────────────────────────────────────────
|
// ── Per-frame ──────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ internal interface ICameraFramePhase
|
||||||
/// <see cref="IRetailLiveFramePhase"/>. This host order is the accepted TS-53
|
/// <see cref="IRetailLiveFramePhase"/>. This host order is the accepted TS-53
|
||||||
/// adaptation and is not claimed to be the exact retail Client::UseTime order.
|
/// adaptation and is not claimed to be the exact retail Client::UseTime order.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class UpdateFrameOrchestrator
|
internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdateFrameRoot
|
||||||
{
|
{
|
||||||
private readonly IUpdateFrameTeardownPhase _teardown;
|
private readonly IUpdateFrameTeardownPhase _teardown;
|
||||||
private readonly IUpdateFrameFailureSink _failureSink;
|
private readonly IUpdateFrameFailureSink _failureSink;
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,9 @@ public sealed class GameWindowRenderLeafCompositionTests
|
||||||
|
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
source,
|
source,
|
||||||
|
"_portalTunnelFallback.Transfer(",
|
||||||
"new AcDream.App.Streaming.LocalPlayerTeleportController(",
|
"new AcDream.App.Streaming.LocalPlayerTeleportController(",
|
||||||
"_portalTunnel = null;",
|
"new AcDream.App.Streaming.LocalPlayerTeleportPresentation(",
|
||||||
"_localPlayerTeleportSink.Bind(_localPlayerTeleport);",
|
"_localPlayerTeleportSink.Bind(_localPlayerTeleport);",
|
||||||
"new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(",
|
"new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(",
|
||||||
"new AcDream.App.Rendering.RenderFrameResourceController(",
|
"new AcDream.App.Rendering.RenderFrameResourceController(",
|
||||||
|
|
@ -125,7 +126,7 @@ public sealed class GameWindowRenderLeafCompositionTests
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
source,
|
source,
|
||||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||||
"_renderFrameOrchestrator = null;",
|
"_frameGraphs.Withdraw();",
|
||||||
"new ResourceShutdownStage(\"session dependents\"");
|
"new ResourceShutdownStage(\"session dependents\"");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
source,
|
source,
|
||||||
|
|
@ -133,7 +134,7 @@ public sealed class GameWindowRenderLeafCompositionTests
|
||||||
"new(\"frame profiler\", _frameProfiler.Dispose)");
|
"new(\"frame profiler\", _frameProfiler.Dispose)");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
source,
|
source,
|
||||||
"_renderFrameOrchestrator = null;",
|
"_frameGraphs.Withdraw();",
|
||||||
"_devToolsBackend?.Dispose()",
|
"_devToolsBackend?.Dispose()",
|
||||||
"new ResourceShutdownStage(\"input\"",
|
"new ResourceShutdownStage(\"input\"",
|
||||||
"_input?.Dispose();",
|
"_input?.Dispose();",
|
||||||
|
|
@ -175,7 +176,7 @@ public sealed class GameWindowRenderLeafCompositionTests
|
||||||
Assert.True(start >= 0 && end > start);
|
Assert.True(start >= 0 && end > start);
|
||||||
string body = source[start..end];
|
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("_gpuFrameFlights", body);
|
||||||
Assert.DoesNotContain("_worldScene", body);
|
Assert.DoesNotContain("_worldScene", body);
|
||||||
Assert.DoesNotContain("_devToolsFramePresenter", body);
|
Assert.DoesNotContain("_devToolsFramePresenter", body);
|
||||||
|
|
|
||||||
|
|
@ -67,17 +67,18 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
|
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
|
||||||
"_runtimeSettings.ApplyStartup(",
|
"_runtimeSettings.ApplyStartup(",
|
||||||
"new RuntimeSettingsStartupTargets(",
|
"new RuntimeSettingsStartupTargets(",
|
||||||
"_uiHost = new AcDream.App.UI.UiHost(",
|
"_uiHost = _retailUiLease.AcquireHost(",
|
||||||
"_uiHost.WireMouse(m)",
|
"_uiHost.WireMouse(m)",
|
||||||
"_uiHost.WireKeyboard(kb)",
|
"_uiHost.WireKeyboard(kb)",
|
||||||
"_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(",
|
"_retailUiRuntime = _retailUiLease.Mount(",
|
||||||
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
|
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
|
||||||
"_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(",
|
"_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(",
|
||||||
"_retainedUiGameplayBinding =",
|
"_retainedUiGameplayBinding =",
|
||||||
"AcDream.App.Input.RetainedUiGameplayBinding.Create(",
|
"AcDream.App.Input.RetainedUiGameplayBinding.Create(",
|
||||||
"_retainedUiGameplayBinding.Attach();",
|
"_retainedUiGameplayBinding.Attach();",
|
||||||
"_renderFrameOrchestrator =",
|
"var renderFrameOrchestrator =",
|
||||||
"_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
|
"var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
|
||||||
|
"_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);",
|
||||||
"_liveSessionHost = CreateLiveSessionHost();",
|
"_liveSessionHost = CreateLiveSessionHost();",
|
||||||
"_liveCombatModeCommands.Bind(combatCommand);",
|
"_liveCombatModeCommands.Bind(combatCommand);",
|
||||||
"_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);",
|
"_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);",
|
||||||
|
|
@ -265,7 +266,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
shutdown,
|
shutdown,
|
||||||
"_runtimeSettings.UnbindViewModel()",
|
"_runtimeSettings.UnbindViewModel()",
|
||||||
"_runtimeSettings.UnbindRuntimeTargets",
|
"_runtimeSettings.UnbindRuntimeTargets",
|
||||||
"_retailUiRuntime?.Dispose()",
|
"_retailUiLease.Dispose()",
|
||||||
"_streamer?.Dispose()",
|
"_streamer?.Dispose()",
|
||||||
"_wbDrawDispatcher?.Dispose()",
|
"_wbDrawDispatcher?.Dispose()",
|
||||||
"_terrain?.Dispose()");
|
"_terrain?.Dispose()");
|
||||||
|
|
@ -292,12 +293,12 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"private void OnClosing()",
|
"private void OnClosing()",
|
||||||
"private ResourceShutdownTransaction CreateShutdownTransaction()");
|
"private ResourceShutdownTransaction CreateShutdownTransaction()");
|
||||||
|
|
||||||
Assert.Equal(1, CountOccurrences(update, "_updateFrameOrchestrator.Tick("));
|
Assert.Equal(1, CountOccurrences(update, "_frameGraphs.Tick("));
|
||||||
Assert.Equal(1, CountOccurrences(render, "_renderFrameOrchestrator!.Render("));
|
Assert.Equal(1, CountOccurrences(render, "_frameGraphs.Render("));
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
render,
|
render,
|
||||||
"Vector2D<int> size = _window!.Size;",
|
"Vector2D<int> size = _window!.Size;",
|
||||||
"_renderFrameOrchestrator!.Render(",
|
"_frameGraphs.Render(",
|
||||||
"new AcDream.App.Rendering.RenderFrameInput(");
|
"new AcDream.App.Rendering.RenderFrameInput(");
|
||||||
Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal);
|
Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal);
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
|
|
@ -338,6 +339,8 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"new ResourceShutdownStage(\"shared texture owners\"",
|
"new ResourceShutdownStage(\"shared texture owners\"",
|
||||||
"new ResourceShutdownStage(\"mesh adapter\"",
|
"new ResourceShutdownStage(\"mesh adapter\"",
|
||||||
"new ResourceShutdownStage(\"remaining render owners\"",
|
"new ResourceShutdownStage(\"remaining render owners\"",
|
||||||
|
"new ResourceShutdownStage(\"dedicated render resources\"",
|
||||||
|
"new ResourceShutdownStage(\"failed render construction cleanup\"",
|
||||||
"new ResourceShutdownStage(\"frame flight owner\"",
|
"new ResourceShutdownStage(\"frame flight owner\"",
|
||||||
"new ResourceShutdownStage(\"content mappings\"",
|
"new ResourceShutdownStage(\"content mappings\"",
|
||||||
"new ResourceShutdownStage(\"input\"",
|
"new ResourceShutdownStage(\"input\"",
|
||||||
|
|
@ -390,6 +393,66 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
character => Assert.True(char.IsWhiteSpace(character) || character == '}'));
|
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) =>
|
private static string MethodBody(string start, string end) =>
|
||||||
Slice(GameWindowSource(), start, end);
|
Slice(GameWindowSource(), start, end);
|
||||||
|
|
||||||
|
|
|
||||||
396
tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs
Normal file
396
tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs
Normal file
|
|
@ -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<AggregateException>(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<InvalidOperationException>(() => transaction.Allocate());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TrackedTextureUploadFailureLeavesEveryActualBranchNameInTransaction()
|
||||||
|
{
|
||||||
|
var api = new FakeTextureNameApi();
|
||||||
|
var transaction = new GlTextureConstructionTransaction(api);
|
||||||
|
uint first = TrackedTextureConstruction.Create(transaction, _ => { });
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
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<InvalidOperationException>(() => 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<AggregateException>(() => 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<AggregateException>(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<InvalidOperationException>(() =>
|
||||||
|
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<InvalidOperationException>(() => 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<AggregateException>(() => guard.Execute(() => mutations++));
|
||||||
|
|
||||||
|
Assert.Equal(0, mutations);
|
||||||
|
Assert.True(pair.IsFullyResident);
|
||||||
|
Assert.False(guard.RestoreRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FailedTextureMutationRestoresTheExactPriorBinding()
|
||||||
|
{
|
||||||
|
var bindings = new List<uint>();
|
||||||
|
var owner = new RestoredTextureBindingMutation();
|
||||||
|
|
||||||
|
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
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<AggregateException>(() =>
|
||||||
|
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<uint>();
|
||||||
|
int restoreFailures = 1;
|
||||||
|
int mutations = 0;
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
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<AggregateException>(
|
||||||
|
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<AggregateException>(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<uint> DeleteAttempts { get; } = [];
|
||||||
|
public List<uint> 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<uint> AcquireAttempts { get; } = [];
|
||||||
|
public List<ulong> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs
Normal file
122
tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs
Normal file
|
|
@ -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<string>();
|
||||||
|
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<AggregateException>(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<GlResourceConstructionException>(() =>
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
271
tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs
Normal file
271
tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs
Normal file
|
|
@ -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<RetryableResource>();
|
||||||
|
var resource = new RetryableResource(remainingFailures: 1);
|
||||||
|
|
||||||
|
Assert.Same(resource, slot.Acquire(() => resource));
|
||||||
|
Assert.Same(resource, slot.Borrow());
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
slot.Acquire(() => new RetryableResource()));
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(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<RetryableResource>();
|
||||||
|
var resource = slot.Acquire(() => new RetryableResource());
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
slot.Transfer<object>(_ => 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>();
|
||||||
|
RetryableResource fallbackResource = fallback.Acquire(
|
||||||
|
() => new RetryableResource());
|
||||||
|
|
||||||
|
fallback.ReleaseFallback();
|
||||||
|
fallback.ReleaseFallback();
|
||||||
|
|
||||||
|
Assert.Equal(1, fallbackResource.DisposeCalls);
|
||||||
|
|
||||||
|
var transferred = new TransferableResourceSlot<RetryableResource>();
|
||||||
|
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<RetryableResource>();
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
acquisition.Acquire(() => throw new InvalidOperationException("create failed")));
|
||||||
|
Assert.False(acquisition.HasFallback);
|
||||||
|
|
||||||
|
var preparation = new TransferableResourceSlot<RetryableResource>();
|
||||||
|
var prepared = new RetryableResource(remainingPrepareFailures: 1);
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
preparation.AcquirePrepared(() => prepared, static value => value.Prepare()));
|
||||||
|
Assert.True(preparation.HasFallback);
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
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>();
|
||||||
|
RetryableResource transferred = binding.Acquire(() => new RetryableResource());
|
||||||
|
ResourceOwner? owner = null;
|
||||||
|
Assert.Throws<InvalidOperationException>((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<string>();
|
||||||
|
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<InvalidOperationException>(() =>
|
||||||
|
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<string>();
|
||||||
|
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<RetryableResource>();
|
||||||
|
var outer = new RetryableResource();
|
||||||
|
int innerFactories = 0;
|
||||||
|
|
||||||
|
Assert.Same(outer, slot.Acquire(() =>
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidOperationException>(() => 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<CallbackResource>();
|
||||||
|
var resource = new CallbackResource(() =>
|
||||||
|
Assert.Throws<InvalidOperationException>(slot.Release));
|
||||||
|
slot.Acquire(() => resource);
|
||||||
|
|
||||||
|
slot.Release();
|
||||||
|
|
||||||
|
Assert.Equal(1, resource.DisposeCalls);
|
||||||
|
Assert.False(slot.HasResource);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TransferSlotRejectsNestedTransferAndPreservesOuterTransfer()
|
||||||
|
{
|
||||||
|
var slot = new TransferableResourceSlot<RetryableResource>();
|
||||||
|
var resource = slot.Acquire(() => new RetryableResource());
|
||||||
|
int nestedOwners = 0;
|
||||||
|
|
||||||
|
ResourceOwner owner = slot.Transfer(value =>
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
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<string> calls) : IGameUpdateFrameRoot
|
||||||
|
{
|
||||||
|
public void Tick(UpdateFrameInput input) => calls.Add($"update:{input.HostDeltaSeconds}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingRenderRoot(List<string> calls) : IGameRenderFrameRoot
|
||||||
|
{
|
||||||
|
public RenderFrameOutcome Render(RenderFrameInput input)
|
||||||
|
{
|
||||||
|
calls.Add(
|
||||||
|
$"render:{input.DeltaSeconds}:{input.ViewportWidth}:{input.ViewportHeight}");
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<InvalidOperationException>(() =>
|
||||||
|
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<InvalidOperationException>(() =>
|
||||||
|
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<InvalidOperationException>(() =>
|
||||||
|
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<AggregateException>(() =>
|
||||||
|
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<GlResourceConstructionException>(() =>
|
||||||
|
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<uint, ShaderType> _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<uint> CreatedShaders { get; } = [];
|
||||||
|
public List<uint> CreatedPrograms { get; } = [];
|
||||||
|
public List<uint> DeleteShaderAttempts { get; } = [];
|
||||||
|
public List<uint> DeletedShaders { get; } = [];
|
||||||
|
public List<uint> DeletedPrograms { get; } = [];
|
||||||
|
public List<string> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -361,7 +361,7 @@ public sealed class WorldRenderFrameBuilderTests
|
||||||
"var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(",
|
"var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(",
|
||||||
"var worldRenderFrameBuilder =",
|
"var worldRenderFrameBuilder =",
|
||||||
"new AcDream.App.Rendering.RenderFrameOrchestrator(",
|
"new AcDream.App.Rendering.RenderFrameOrchestrator(",
|
||||||
"_renderFrameOrchestrator = null;",
|
"_frameGraphs.Withdraw();",
|
||||||
"new(\"equipped children\"",
|
"new(\"equipped children\"",
|
||||||
"new(\"effect network state\"",
|
"new(\"effect network state\"",
|
||||||
"new(\"audio\"",
|
"new(\"audio\"",
|
||||||
|
|
|
||||||
352
tests/AcDream.App.Tests/UI/RetailUiRuntimeLeaseTests.cs
Normal file
352
tests/AcDream.App.Tests/UI/RetailUiRuntimeLeaseTests.cs
Normal file
|
|
@ -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<InvalidOperationException>(() =>
|
||||||
|
lease.MountCore<FakeRuntime>(
|
||||||
|
() => 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<InvalidOperationException>(() =>
|
||||||
|
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<AggregateException>(() =>
|
||||||
|
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<AggregateException>(() => Mount(lease, runtime));
|
||||||
|
Assert.Throws<InvalidOperationException>(lease.Dispose);
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
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<InvalidOperationException>(() => AcquireHost(lease));
|
||||||
|
Assert.Same(runtime, Mount(lease, runtime));
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
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<InvalidOperationException>(() =>
|
||||||
|
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<InvalidOperationException>(() =>
|
||||||
|
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<InvalidOperationException>(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<InvalidOperationException>(lease.QuiesceInput);
|
||||||
|
Assert.Throws<InvalidOperationException>(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<InvalidOperationException>(() =>
|
||||||
|
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<InvalidOperationException>(lease.Dispose);
|
||||||
|
if (deactivateAttempts == 1)
|
||||||
|
throw new InvalidOperationException("deactivate failed");
|
||||||
|
host.DeactivateInput();
|
||||||
|
},
|
||||||
|
static value => value.Dispose(),
|
||||||
|
static value => value.IsDisposalComplete);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(lease.QuiesceInput);
|
||||||
|
lease.QuiesceInput();
|
||||||
|
Assert.Throws<InvalidOperationException>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -362,7 +362,7 @@ public sealed class UpdateFrameOrchestratorTests
|
||||||
Assert.DoesNotContain("_streamingFrame", source, StringComparison.Ordinal);
|
Assert.DoesNotContain("_streamingFrame", source, StringComparison.Ordinal);
|
||||||
Assert.Equal(1, CountOccurrences(
|
Assert.Equal(1, CountOccurrences(
|
||||||
source,
|
source,
|
||||||
"_updateFrameOrchestrator.Tick("));
|
"_frameGraphs.Tick("));
|
||||||
Assert.DoesNotContain("_streamingController.Tick(observerCx", source,
|
Assert.DoesNotContain("_streamingController.Tick(observerCx", source,
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("DungeonStreamingGate.Compute", source,
|
Assert.DoesNotContain("DungeonStreamingGate.Compute", source,
|
||||||
|
|
@ -650,7 +650,7 @@ public sealed class UpdateFrameOrchestratorTests
|
||||||
"GameWindow.cs"));
|
"GameWindow.cs"));
|
||||||
Assert.Equal(1, CountOccurrences(
|
Assert.Equal(1, CountOccurrences(
|
||||||
source,
|
source,
|
||||||
"_updateFrameOrchestrator.Tick("));
|
"_frameGraphs.Tick("));
|
||||||
Assert.DoesNotContain("CanAdvanceLocalPlayer", source, StringComparison.Ordinal);
|
Assert.DoesNotContain("CanAdvanceLocalPlayer", source, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("GetCombatCameraTargetPoint()", source,
|
Assert.DoesNotContain("GetCombatCameraTargetPoint()", source,
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
|
|
@ -686,7 +686,7 @@ public sealed class UpdateFrameOrchestratorTests
|
||||||
|
|
||||||
Assert.Equal(1, CountOccurrences(
|
Assert.Equal(1, CountOccurrences(
|
||||||
source,
|
source,
|
||||||
"_updateFrameOrchestrator.Tick("));
|
"_frameGraphs.Tick("));
|
||||||
Assert.DoesNotContain("_updateFrameClock.Advance(", source,
|
Assert.DoesNotContain("_updateFrameClock.Advance(", source,
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("_liveFrameCoordinator", source,
|
Assert.DoesNotContain("_liveFrameCoordinator", source,
|
||||||
|
|
@ -699,7 +699,7 @@ public sealed class UpdateFrameOrchestratorTests
|
||||||
source,
|
source,
|
||||||
"private void OnUpdate(double dt)",
|
"private void OnUpdate(double dt)",
|
||||||
"_frameProfiler.BeginStage(",
|
"_frameProfiler.BeginStage(",
|
||||||
"_updateFrameOrchestrator.Tick(",
|
"_frameGraphs.Tick(",
|
||||||
"private void OnRender(double deltaSeconds)");
|
"private void OnRender(double deltaSeconds)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue