refactor(app): extract focused window lifetime
Move the exact retryable shutdown manifest, typed root snapshot, terminal reporting, and native-window-last release out of GameWindow. Keep session and GPU convergence as hard barriers while reporting persistent physical callback cleanup without stranding dependent owners. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
5a55d08106
commit
31e6e192b3
21 changed files with 1297 additions and 572 deletions
11
AGENTS.md
11
AGENTS.md
|
|
@ -155,9 +155,14 @@ pipeline as its failure oracle, carries exact platform/settings results through
|
||||||
typed phase inputs, atomically publishes frame roots, and makes session start
|
typed phase inputs, atomically publishes frame roots, and makes session start
|
||||||
terminal. `GameWindow` is 1,945 raw lines, down 13,778 lines (87.6%) from
|
terminal. `GameWindow` is 1,945 raw lines, down 13,778 lines (87.6%) from
|
||||||
baseline. App Release passes 3,431 tests / 3 skips and the complete Release
|
baseline. App Release passes 3,431 tests / 3 skips and the complete Release
|
||||||
suite passes 7,803 / 5 skips. Checkpoint I is complete; checkpoints J–L—focused
|
suite passes 7,803 / 5 skips. Checkpoint J moves the exact shutdown dependency
|
||||||
retryable shutdown, canonical soak snapshots, and final connected gates—remain
|
graph into `GameWindowLifetime`: immutable typed roots, retry/no-replay state,
|
||||||
active.
|
hard session/GPU barriers, reportable physical callback cleanup, structured
|
||||||
|
terminal reporting, and native-window-last release now live outside the shell.
|
||||||
|
`GameWindow` is 1,625 raw lines, down 14,098 lines (89.7%) from baseline. App
|
||||||
|
Release passes 3,441 tests / 3 skips and the complete Release suite passes
|
||||||
|
7,813 / 5 skips. Checkpoints K–L—canonical soak snapshots and final connected
|
||||||
|
closeout—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
|
||||||
|
|
|
||||||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -153,9 +153,14 @@ pipeline as its failure oracle, carries exact platform/settings results through
|
||||||
typed phase inputs, atomically publishes frame roots, and makes session start
|
typed phase inputs, atomically publishes frame roots, and makes session start
|
||||||
terminal. `GameWindow` is 1,945 raw lines, down 13,778 lines (87.6%) from
|
terminal. `GameWindow` is 1,945 raw lines, down 13,778 lines (87.6%) from
|
||||||
baseline. App Release passes 3,431 tests / 3 skips and the complete Release
|
baseline. App Release passes 3,431 tests / 3 skips and the complete Release
|
||||||
suite passes 7,803 / 5 skips. Checkpoint I is complete; checkpoints J–L—focused
|
suite passes 7,803 / 5 skips. Checkpoint J moves the exact shutdown dependency
|
||||||
retryable shutdown, canonical soak snapshots, and final connected gates—remain
|
graph into `GameWindowLifetime`: immutable typed roots, retry/no-replay state,
|
||||||
active.
|
hard session/GPU barriers, reportable physical callback cleanup, structured
|
||||||
|
terminal reporting, and native-window-last release now live outside the shell.
|
||||||
|
`GameWindow` is 1,625 raw lines, down 14,098 lines (89.7%) from baseline. App
|
||||||
|
Release passes 3,441 tests / 3 skips and the complete Release suite passes
|
||||||
|
7,813 / 5 skips. Checkpoints K–L—canonical soak snapshots and final connected
|
||||||
|
closeout—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
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,12 @@ What does NOT go here:
|
||||||
publication, and lifecycle resource snapshot source. I.8c makes session
|
publication, and lifecycle resource snapshot source. I.8c makes session
|
||||||
start the terminal `OnLoad` operation. I.9 corrected the production root to
|
start the terminal `OnLoad` operation. I.9 corrected the production root to
|
||||||
invoke the exact tested pipeline and made every consumed platform/settings
|
invoke the exact tested pipeline and made every consumed platform/settings
|
||||||
result explicit. `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed
|
result explicit. Checkpoint J moves the exact retryable shutdown graph into
|
||||||
orchestration handoffs and the class is 1,945 raw lines. Checkpoint I is
|
`GameWindowLifetime`, including typed roots, hard session/GPU barriers,
|
||||||
complete; the shutdown/lifetime cutover and closeout remain active in
|
reportable physical detach, structured terminal state, and native-window-last
|
||||||
|
release. `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed
|
||||||
|
orchestration handoffs and the class is 1,625 raw lines. Checkpoints K–L—the
|
||||||
|
canonical soak snapshots and final closeout—remain active in
|
||||||
[`docs/architecture/code-structure.md`](architecture/code-structure.md).
|
[`docs/architecture/code-structure.md`](architecture/code-structure.md).
|
||||||
This is the behavior-preserving prerequisite before new M4 feature bodies.
|
This is the behavior-preserving prerequisite before new M4 feature bodies.
|
||||||
- **Connected-gate fidelity:** `#232` adds canonical managed/GPU/cache-owner
|
- **Connected-gate fidelity:** `#232` adds canonical managed/GPU/cache-owner
|
||||||
|
|
|
||||||
|
|
@ -839,8 +839,13 @@ Current movement/collision ownership:
|
||||||
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. Checked GL construction/state boundaries
|
runtime targets without replay. Checked GL construction/state boundaries
|
||||||
retain exact cleanup obligations across failure. Ordered production
|
retain exact cleanup obligations across failure. The executable nine-phase
|
||||||
composition is the next checkpoint. The reconciled ledger and sequence live in
|
production pipeline and terminal session start are complete. Checkpoint J
|
||||||
|
moves the exact shutdown dependency graph into `GameWindowLifetime`, whose
|
||||||
|
typed roots preserve hard session/GPU barriers, reportable physical detach,
|
||||||
|
retry/no-replay progress, and native-window-last release. `GameWindow` is
|
||||||
|
1,625 raw lines; only the canonical soak instrumentation and final connected
|
||||||
|
closeout checkpoints remain. 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ after Slice 8 checkpoint D 4,330 lines / 192 fields / 67 method
|
||||||
after Slice 8 checkpoint E 4,266 lines / 194 fields / 65 methods
|
after Slice 8 checkpoint E 4,266 lines / 194 fields / 65 methods
|
||||||
after Slice 8 checkpoint F 4,057 lines / 198 fields / 54 methods
|
after Slice 8 checkpoint F 4,057 lines / 198 fields / 54 methods
|
||||||
after Slice 8 checkpoint G 3,663 lines / 162 fields / 37 methods
|
after Slice 8 checkpoint G 3,663 lines / 162 fields / 37 methods
|
||||||
|
after Slice 8 checkpoint I 1,945 lines / startup composition shell
|
||||||
|
after Slice 8 checkpoint J 1,625 lines / focused lifetime shell
|
||||||
```
|
```
|
||||||
|
|
||||||
`GameWindow` is the single object that:
|
`GameWindow` is the single object that:
|
||||||
|
|
@ -683,7 +685,7 @@ Issue #232 records the coarse process-residency gate's run-to-run variance so
|
||||||
future diagnostics can distinguish canonical owner growth from OS/driver/GC
|
future diagnostics can distinguish canonical owner growth from OS/driver/GC
|
||||||
residency without weakening leak detection.
|
residency without weakening leak detection.
|
||||||
|
|
||||||
#### Slice 8 — composition and shutdown cleanup — ACTIVE
|
#### Slice 8 — composition and shutdown cleanup — ACTIVE (K–L remain)
|
||||||
|
|
||||||
Detailed execution ledger:
|
Detailed execution ledger:
|
||||||
[`docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md`](../plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md).
|
[`docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md`](../plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md).
|
||||||
|
|
@ -741,12 +743,17 @@ body into `FrameRootCompositionPhase`, publishes the pair through an exact
|
||||||
lease, and gives lifecycle resource sampling a focused source. `GameWindow` is
|
lease, and gives lifecycle resource sampling a focused source. `GameWindow` is
|
||||||
1,945 raw lines after I.9 corrects production to invoke the same executable
|
1,945 raw lines after I.9 corrects production to invoke the same executable
|
||||||
pipeline as the failure oracle, carries exact platform/settings results through
|
pipeline as the failure oracle, carries exact platform/settings results through
|
||||||
the typed contract, and keeps live-session start terminal. Checkpoint I is
|
the typed contract, and keeps live-session start terminal. Checkpoint J then
|
||||||
complete; J–L remain active with lifetime/shutdown extraction next. The App gate
|
moves the complete shutdown manifest into `GameWindowLifetime`: typed root
|
||||||
passes 3,431 tests / 3 intentional skips and the complete Release suite passes
|
groups, retry/no-replay progress, structured reportable physical-detach
|
||||||
7,803 tests / 5 intentional skips. The clean solution build retains only the 17
|
failures, hard session/GPU barriers, and native-window-last release now live
|
||||||
test-project warnings tracked by #228; the complete Checkpoint-I behavior,
|
outside the host. The shell captures roots once and makes one lifetime call
|
||||||
architecture, and adversarial corrected-diff passes are clean.
|
from Closing/Dispose; no shutdown stage remains in it. `GameWindow` is 1,625
|
||||||
|
raw lines after J. The App gate passes 3,441 tests / 3 intentional skips and
|
||||||
|
the complete Release suite passes 7,813 tests / 5 intentional skips. The clean
|
||||||
|
solution build retains only the 17 test-project warnings tracked by #228; the
|
||||||
|
complete Checkpoint-J behavior, architecture, and adversarial corrected-diff
|
||||||
|
passes are clean. Checkpoints K–L remain active.
|
||||||
|
|
||||||
### 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–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.
|
**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–J are complete, with only canonical soak instrumentation and final closeout checkpoints K–L 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -105,10 +105,13 @@ resources have transactional phase ownership and exact-owner late bindings.
|
||||||
I.7 adds the complete streaming/session/hydration/local-player/teleport phase,
|
I.7 adds the complete streaming/session/hydration/local-player/teleport phase,
|
||||||
including explicit reversible ownership for every late edge. `GameWindow` is
|
including explicit reversible ownership for every late edge. `GameWindow` is
|
||||||
now 1,945 raw lines after I.9 makes production invoke the same typed pipeline
|
now 1,945 raw lines after I.9 makes production invoke the same typed pipeline
|
||||||
as the failure oracle and keeps session start terminal. Checkpoint I is
|
as the failure oracle and keeps session start terminal. Checkpoint J extracts
|
||||||
complete; shutdown, canonical soak snapshots, and closeout checkpoints J–L
|
the exact shutdown dependency graph into `GameWindowLifetime`, with typed
|
||||||
remain. The App gate passes 3,431 tests / 3 intentional skips, the complete
|
immutable roots, hard session/GPU barriers, reportable physical cleanup,
|
||||||
Release suite passes 7,803 tests / 5 skips, and the complete Checkpoint-I
|
retry/no-replay state, and native-window-last terminal reporting. The host is
|
||||||
|
now 1,625 raw lines. Only canonical soak snapshots and closeout checkpoints
|
||||||
|
K–L remain. The App gate passes 3,441 tests / 3 intentional skips, the complete
|
||||||
|
Release suite passes 7,813 tests / 5 skips, and the complete Checkpoint-J
|
||||||
corrected-diff review is clean.
|
corrected-diff review is clean.
|
||||||
|
|
||||||
This is a behavior-preserving structural program. Severe regressions still get
|
This is a behavior-preserving structural program. Severe regressions still get
|
||||||
|
|
|
||||||
|
|
@ -64,12 +64,14 @@ and attaches command/input owners before frame publication. I.8b composes and
|
||||||
atomically publishes the complete frame pair and extracts lifecycle resource
|
atomically publishes the complete frame pair and extracts lifecycle resource
|
||||||
sampling. I.8c makes session start the terminal `OnLoad` operation.
|
sampling. I.8c makes session start the terminal `OnLoad` operation.
|
||||||
`GameWindow` is 1,945 raw lines after I.9 routes production through the exact
|
`GameWindow` is 1,945 raw lines after I.9 routes production through the exact
|
||||||
tested pipeline with explicit prior-result dependencies. Checkpoint I is
|
tested pipeline with explicit prior-result dependencies. Checkpoint J adds the
|
||||||
complete and checkpoints J–L remain active. Issue #232 tracks
|
focused `GameWindowLifetime`, immutable typed shutdown roots, hard dependency
|
||||||
process-residency variance in the soak without loosening its leak threshold.
|
barriers, structured report-and-continue physical cleanup, retry/no-replay
|
||||||
The App gate passes 3,431 tests / 3 skips, the complete Release suite passes
|
state, and native-window-last terminal reporting. The host is now 1,625 raw
|
||||||
7,803 tests / 5 skips, and the complete Checkpoint-I corrected-diff review is
|
lines. Checkpoints K–L remain active; Issue #232 tracks process-residency
|
||||||
clean.
|
variance in the soak without loosening its leak threshold. The App gate passes
|
||||||
|
3,441 tests / 3 skips, the complete Release suite passes 7,813 tests / 5 skips,
|
||||||
|
and the complete Checkpoint-J corrected-diff review is 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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# GameWindow Slice 8 Checkpoint J — lifetime and shutdown
|
# GameWindow Slice 8 Checkpoint J — lifetime and shutdown
|
||||||
|
|
||||||
**Status:** Active 2026-07-22.
|
**Status:** Complete 2026-07-22.
|
||||||
**Parent:**
|
**Parent:**
|
||||||
[`2026-07-22-gamewindow-slice-8-composition-lifecycle.md`](2026-07-22-gamewindow-slice-8-composition-lifecycle.md),
|
[`2026-07-22-gamewindow-slice-8-composition-lifecycle.md`](2026-07-22-gamewindow-slice-8-composition-lifecycle.md),
|
||||||
Checkpoint J.
|
Checkpoint J.
|
||||||
|
|
@ -12,6 +12,17 @@ passes 7,803 tests / 5 skips.
|
||||||
frame withdrawal, GL dependency, and native-window-last order. This checkpoint
|
frame withdrawal, GL dependency, and native-window-last order. This checkpoint
|
||||||
changes lifetime ownership and failure reporting, not gameplay behavior.
|
changes lifetime ownership and failure reporting, not gameplay behavior.
|
||||||
|
|
||||||
|
**Result:** `GameWindowLifetime` now owns the immutable typed root snapshot,
|
||||||
|
retryable staged transaction, structured terminal report, and native window.
|
||||||
|
Persistent physical-detach failures are retried and reported without stranding
|
||||||
|
later owners; session convergence and GPU drain remain hard barriers. Clean
|
||||||
|
native release drops the completed transaction/root graph, while terminal
|
||||||
|
abandonment retains it. `GameWindow.cs` is 1,625 raw lines, down 14,098 lines
|
||||||
|
(89.7%) from the campaign baseline. The App Release gate passes 3,441 tests / 3
|
||||||
|
skips; the complete Release gate passes 7,813 / 5 skips; the solution build has
|
||||||
|
only #228's 17 existing test warnings. Three corrected-diff review passes are
|
||||||
|
clean and no retail-divergence row changed.
|
||||||
|
|
||||||
## 1. Outcome
|
## 1. Outcome
|
||||||
|
|
||||||
Replace `GameWindow.CreateShutdownTransaction` and the window-owned transaction
|
Replace `GameWindow.CreateShutdownTransaction` and the window-owned transaction
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ audit.
|
||||||
transactional partial-acquisition rollback. Production and failure tests now
|
transactional partial-acquisition rollback. Production and failure tests now
|
||||||
invoke the same pipeline and carry exact typed prior results. Detailed plan:
|
invoke the same pipeline and carry exact typed prior results. Detailed plan:
|
||||||
[`2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md`](2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md).
|
[`2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md`](2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md).
|
||||||
- [ ] J — move the exact retryable shutdown manifest to a focused lifetime
|
- [x] J — move the exact retryable shutdown manifest to a focused lifetime
|
||||||
owner and prove all partial-load/reentrant/retry paths. Detailed plan:
|
owner and prove all partial-load/reentrant/retry paths. Detailed plan:
|
||||||
[`2026-07-22-gamewindow-slice-8-checkpoint-j-lifetime-shutdown.md`](2026-07-22-gamewindow-slice-8-checkpoint-j-lifetime-shutdown.md).
|
[`2026-07-22-gamewindow-slice-8-checkpoint-j-lifetime-shutdown.md`](2026-07-22-gamewindow-slice-8-checkpoint-j-lifetime-shutdown.md).
|
||||||
- [ ] K — in a separate #232 commit, add canonical owner snapshots to every soak
|
- [ ] K — in a separate #232 commit, add canonical owner snapshots to every soak
|
||||||
|
|
@ -41,6 +41,12 @@ audit.
|
||||||
lifecycle/soak, framebuffer comparison, documentation, memory, and final
|
lifecycle/soak, framebuffer comparison, documentation, memory, and final
|
||||||
visual handoff.
|
visual handoff.
|
||||||
|
|
||||||
|
Checkpoint J leaves `GameWindow.cs` at 1,625 raw lines. The focused lifetime
|
||||||
|
owner now holds the typed shutdown roots, exact 18-stage dependency manifest,
|
||||||
|
retry/no-replay state, structured soft-cleanup failures, hard-barrier fallback,
|
||||||
|
and native-window-last release. App Release passes 3,441 tests / 3 skips and
|
||||||
|
the complete Release suite passes 7,813 / 5 skips. Checkpoint K is next.
|
||||||
|
|
||||||
Each checked checkpoint lands as a bisectable commit. A checkpoint is not done
|
Each checked checkpoint lands as a bisectable commit. A checkpoint is not done
|
||||||
while a new class delegates a substantial body back into `GameWindow`, stores
|
while a new class delegates a substantial body back into `GameWindow`, stores
|
||||||
`GameWindow`, or replaces 196 fields with one runtime/service-locator bag.
|
`GameWindow`, or replaces 196 fields with one runtime/service-locator bag.
|
||||||
|
|
@ -656,6 +662,15 @@ correction; Checkpoint L retains the connected lifecycle and soak gates.
|
||||||
after native fallback the terminal abandoned state makes repeated Dispose
|
after native fallback the terminal abandoned state makes repeated Dispose
|
||||||
inert.
|
inert.
|
||||||
|
|
||||||
|
Result: `GameWindowLifetime` owns the typed root snapshot, 18-stage manifest,
|
||||||
|
transaction progress, terminal report, and native window. Clean, soft-failure,
|
||||||
|
hard-failure, native-failure, retry, reentrant, and never-run paths are pinned.
|
||||||
|
The UI host remains explicitly rooted through failed physical detach until the
|
||||||
|
native edge is gone; successful finalization releases the completed lifetime
|
||||||
|
graph, while abandonment retains it. No shutdown stage remains in `GameWindow`,
|
||||||
|
which is now 1,625 raw lines. App Release passes 3,441 tests / 3 skips and the
|
||||||
|
complete Release suite passes 7,813 / 5 skips.
|
||||||
|
|
||||||
### K — canonical soak checkpoints (#232, separate commit)
|
### K — canonical soak checkpoints (#232, separate commit)
|
||||||
|
|
||||||
- Extract `WorldLifecycleResourceSnapshotSource` from the window and reuse the
|
- Extract `WorldLifecycleResourceSnapshotSource` from the window and reuse the
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,8 @@ internal sealed class DevToolsCompositionOwner : IDisposable
|
||||||
public bool IsDisposalComplete =>
|
public bool IsDisposalComplete =>
|
||||||
_frontendShutdown.IsComplete && !_ownsInput;
|
_frontendShutdown.IsComplete && !_ownsInput;
|
||||||
|
|
||||||
|
internal bool IsFrontendDisposalComplete => _frontendShutdown.IsComplete;
|
||||||
|
|
||||||
public void AdoptInput() => _ownsInput = true;
|
public void AdoptInput() => _ownsInput = true;
|
||||||
public void AdoptBackend() => _ownsBackend = true;
|
public void AdoptBackend() => _ownsBackend = true;
|
||||||
public void AdoptChat() => _ownsChat = true;
|
public void AdoptChat() => _ownsChat = true;
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ public sealed class GameWindow :
|
||||||
_glConstructionCleanup = new();
|
_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 readonly GameWindowLifetime _lifetime = new();
|
||||||
private readonly DisplayFramePacingController _displayFramePacing;
|
private readonly DisplayFramePacingController _displayFramePacing;
|
||||||
private readonly RuntimeSettingsController _runtimeSettings;
|
private readonly RuntimeSettingsController _runtimeSettings;
|
||||||
|
|
||||||
|
|
@ -640,6 +640,7 @@ public sealed class GameWindow :
|
||||||
};
|
};
|
||||||
|
|
||||||
_window = Window.Create(options);
|
_window = Window.Create(options);
|
||||||
|
_lifetime.PublishNativeWindow(_window);
|
||||||
_displayFramePacing.BindSurface(
|
_displayFramePacing.BindSurface(
|
||||||
new SilkDisplayFramePacingSurface(_window));
|
new SilkDisplayFramePacingSurface(_window));
|
||||||
// The fixed binding preserves main Render before post-render pacing,
|
// The fixed binding preserves main Render before post-render pacing,
|
||||||
|
|
@ -1507,426 +1508,106 @@ public sealed class GameWindow :
|
||||||
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
|
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
|
||||||
=> _framebufferResize.Resize(newSize);
|
=> _framebufferResize.Resize(newSize);
|
||||||
|
|
||||||
private void OnClosing()
|
private void OnClosing() => CompleteShutdown(releaseNativeWindow: false);
|
||||||
{
|
|
||||||
_hostQuiescence.StopAccepting();
|
|
||||||
CompleteShutdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CompleteShutdown()
|
private void CompleteShutdown(bool releaseNativeWindow)
|
||||||
{
|
{
|
||||||
_hostQuiescence.StopAccepting();
|
if (!_lifetime.HasShutdownRoots)
|
||||||
_shutdown ??= CreateShutdownTransaction();
|
_lifetime.PublishShutdownRoots(CaptureShutdownRoots());
|
||||||
try
|
|
||||||
{
|
|
||||||
_shutdown.CompleteOrThrow();
|
|
||||||
}
|
|
||||||
catch (Exception error)
|
|
||||||
{
|
|
||||||
// The session/network stage runs first, so ACE is disconnected
|
|
||||||
// gracefully even if a persistent driver failure prevents full GL
|
|
||||||
// convergence. Later dependencies remain intact and the native
|
|
||||||
// context/process teardown is the final safety net.
|
|
||||||
Console.Error.WriteLine($"[shutdown] {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private ResourceShutdownTransaction CreateShutdownTransaction() => new(
|
GameWindowLifetimeReport report = releaseNativeWindow
|
||||||
// Logical cutoff precedes the live session's potentially long graceful
|
? _lifetime.CompleteAndReleaseNativeWindow()
|
||||||
// close. Physical event removal follows session retirement, so a bad
|
: _lifetime.TryComplete();
|
||||||
// Silk accessor cannot prevent F653/transport teardown. The input
|
if (report.Status == GameWindowLifetimeStatus.Complete)
|
||||||
// context and UI owners remain alive for reset, while copied callbacks
|
|
||||||
// are already inert.
|
|
||||||
new ResourceShutdownStage("input callback deactivation",
|
|
||||||
[
|
|
||||||
new("combat command slot", _liveCombatModeCommands.Deactivate),
|
|
||||||
new("diagnostic command slot", _runtimeDiagnosticCommands.Deactivate),
|
|
||||||
new("retained gameplay", () => _retainedUiGameplayBinding?.Deactivate()),
|
|
||||||
new("gameplay actions", () => _gameplayInputActions?.Deactivate()),
|
|
||||||
new("camera pointer", () => _cameraPointerInput?.Deactivate()),
|
|
||||||
new("dispatcher", () => _inputDispatcher?.Deactivate()),
|
|
||||||
new("mouse source", () => _mouseSource?.Deactivate()),
|
|
||||||
new("keyboard source", () => _kbSource?.Deactivate()),
|
|
||||||
new("retained UI input", _retailUiLease.QuiesceInput),
|
|
||||||
new("developer tools input", () => _devToolsComposition?.DeactivateInput()),
|
|
||||||
]),
|
|
||||||
// Live-session reset retires the complete streaming window. Every
|
|
||||||
// reset callback owner, especially LandblockStreamer, must remain live
|
|
||||||
// until that transaction converges.
|
|
||||||
new ResourceShutdownStage("session lifetime",
|
|
||||||
[
|
|
||||||
new("live session", () =>
|
|
||||||
{
|
|
||||||
AcDream.App.Net.LiveSessionController? controller =
|
|
||||||
_liveSessionController;
|
|
||||||
if (controller is null)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
controller.Dispose();
|
Console.Error.WriteLine(
|
||||||
if (!controller.IsDisposalComplete)
|
$"[shutdown] status={report.Status}, blocked={report.BlockedStage ?? "none"}");
|
||||||
|
foreach (ResourceShutdownCleanupFailure cleanup in report.CleanupFailures)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
Console.Error.WriteLine(
|
||||||
"Live-session disposal was deferred by a reentrant lifecycle callback.");
|
$"[shutdown] cleanup '{cleanup.Operation}' in '{cleanup.Stage}': " +
|
||||||
|
cleanup.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ReferenceEquals(_liveSessionController, controller))
|
if (report.Error is not null)
|
||||||
{
|
Console.Error.WriteLine($"[shutdown] {report.Error}");
|
||||||
_liveSessionHost = null;
|
|
||||||
_liveSessionController = null;
|
|
||||||
}
|
}
|
||||||
}),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("input callback detach",
|
|
||||||
[
|
|
||||||
new("settings view model", () => _runtimeSettings.UnbindViewModel()),
|
|
||||||
new("retained gameplay", () =>
|
|
||||||
{
|
|
||||||
AcDream.App.Input.RetainedUiGameplayBinding? binding =
|
|
||||||
_retainedUiGameplayBinding;
|
|
||||||
if (binding is null) return;
|
|
||||||
binding.Dispose();
|
|
||||||
if (!binding.IsDisposalComplete)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Retained gameplay callback removal remains pending.");
|
|
||||||
_retainedUiGameplayBinding = null;
|
|
||||||
}),
|
|
||||||
new("gameplay actions", () =>
|
|
||||||
{
|
|
||||||
AcDream.App.Input.GameplayInputActionRouter? actions =
|
|
||||||
_gameplayInputActions;
|
|
||||||
if (actions is null) return;
|
|
||||||
actions.Dispose();
|
|
||||||
if (!actions.IsDisposalComplete)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Gameplay action callback removal remains pending.");
|
|
||||||
_gameplayInputActions = null;
|
|
||||||
}),
|
|
||||||
new("retained UI input", _retailUiLease.DeactivateInput),
|
|
||||||
new("developer tools input", () => _devToolsComposition?.DetachInput()),
|
|
||||||
new("camera pointer", () =>
|
|
||||||
{
|
|
||||||
AcDream.App.Input.CameraPointerInputController? pointer =
|
|
||||||
_cameraPointerInput;
|
|
||||||
if (pointer is null) return;
|
|
||||||
pointer.Dispose();
|
|
||||||
if (!pointer.IsDisposalComplete)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Camera pointer callback removal remains pending.");
|
|
||||||
}),
|
|
||||||
new("dispatcher", () =>
|
|
||||||
{
|
|
||||||
AcDream.UI.Abstractions.Input.InputDispatcher? dispatcher =
|
|
||||||
_inputDispatcher;
|
|
||||||
if (dispatcher is null) return;
|
|
||||||
_movementInput.Unbind(dispatcher);
|
|
||||||
_cameraInput.Unbind(dispatcher);
|
|
||||||
dispatcher.Dispose();
|
|
||||||
if (!dispatcher.IsDisposalComplete)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Input dispatcher source removal remains pending.");
|
|
||||||
_inputDispatcher = null;
|
|
||||||
}),
|
|
||||||
new("mouse source", () =>
|
|
||||||
{
|
|
||||||
AcDream.App.Input.SilkMouseSource? source = _mouseSource;
|
|
||||||
if (source is null) return;
|
|
||||||
source.Dispose();
|
|
||||||
if (!source.IsDisposalComplete)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Mouse source callback removal remains pending.");
|
|
||||||
_mouseSource = null;
|
|
||||||
}),
|
|
||||||
new("keyboard source", () =>
|
|
||||||
{
|
|
||||||
AcDream.App.Input.SilkKeyboardSource? source = _kbSource;
|
|
||||||
if (source is null) return;
|
|
||||||
source.Dispose();
|
|
||||||
if (!source.IsDisposalComplete)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Keyboard source callback removal remains pending.");
|
|
||||||
_kbSource = null;
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
// Frame composition borrows equipped, effect, audio, and render
|
|
||||||
// owners. Withdraw the graph before the first borrowed owner closes;
|
|
||||||
// the later render-frontend stage still disposes the GL owners.
|
|
||||||
new ResourceShutdownStage("frame borrowers",
|
|
||||||
[
|
|
||||||
new("world frame composition", () =>
|
|
||||||
{
|
|
||||||
IDisposable? publication = _frameGraphPublication;
|
|
||||||
if (publication is null)
|
|
||||||
return;
|
|
||||||
publication.Dispose();
|
|
||||||
_frameGraphPublication = null;
|
|
||||||
}),
|
|
||||||
new("frame-root bindings", () =>
|
|
||||||
{
|
|
||||||
FrameRootRuntimeBindings? bindings = _frameRootBindings;
|
|
||||||
if (bindings is null)
|
|
||||||
return;
|
|
||||||
bindings.Dispose();
|
|
||||||
_frameRootBindings = null;
|
|
||||||
_worldLifecycleAutomation = null;
|
|
||||||
}),
|
|
||||||
new("session/player bindings", () =>
|
|
||||||
{
|
|
||||||
SessionPlayerRuntimeBindings? bindings = _sessionPlayerBindings;
|
|
||||||
if (bindings is null)
|
|
||||||
return;
|
|
||||||
bindings.Dispose();
|
|
||||||
_sessionPlayerBindings = null;
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("session dependents",
|
|
||||||
[
|
|
||||||
new("interaction/UI late bindings", () =>
|
|
||||||
{
|
|
||||||
InteractionUiLateBindings? bindings = _interactionUiLateBindings;
|
|
||||||
if (bindings is null)
|
|
||||||
return;
|
|
||||||
bindings.Dispose();
|
|
||||||
_interactionUiLateBindings = null;
|
|
||||||
}),
|
|
||||||
new("mouse capture", () =>
|
|
||||||
{
|
|
||||||
AcDream.App.Input.CameraPointerInputController? pointer =
|
|
||||||
_cameraPointerInput;
|
|
||||||
if (pointer is null) return;
|
|
||||||
pointer.ReleaseMouseLookAfterSessionRetirement();
|
|
||||||
_cameraPointerInput = null;
|
|
||||||
}),
|
|
||||||
new("retail UI", () =>
|
|
||||||
{
|
|
||||||
_retailUiLease.Dispose();
|
|
||||||
if (!_retailUiLease.IsDisposalComplete)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"The retained UI ownership lease did not complete disposal.");
|
|
||||||
_retailUiRuntime = null;
|
|
||||||
_uiHost = null;
|
|
||||||
}),
|
|
||||||
new("combat target", () =>
|
|
||||||
{
|
|
||||||
_combatTargetController?.Dispose();
|
|
||||||
_combatTargetController = null;
|
|
||||||
}),
|
|
||||||
new("combat attack", () =>
|
|
||||||
{
|
|
||||||
_combatAttackController?.Dispose();
|
|
||||||
_combatAttackController = null;
|
|
||||||
}),
|
|
||||||
new("item interaction", () =>
|
|
||||||
{
|
|
||||||
_itemInteractionController?.Dispose();
|
|
||||||
_itemInteractionController = null;
|
|
||||||
}),
|
|
||||||
new("external containers", () =>
|
|
||||||
{
|
|
||||||
_externalContainerLifecycle?.Dispose();
|
|
||||||
_externalContainerLifecycle = null;
|
|
||||||
}),
|
|
||||||
new("magic runtime", () =>
|
|
||||||
{
|
|
||||||
_magicRuntime = null;
|
|
||||||
_magicCatalog = null;
|
|
||||||
}),
|
|
||||||
new("streamer", () => _streamer?.Dispose()),
|
|
||||||
new("equipped children", () => _equippedChildRenderer?.Dispose()),
|
|
||||||
]),
|
|
||||||
// Retained tombstones are retried while every callback owner is alive.
|
|
||||||
new ResourceShutdownStage("live entities",
|
|
||||||
[
|
|
||||||
new("live entity runtime", () => _liveEntities?.Clear()),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("effect dispatch edges",
|
|
||||||
[
|
|
||||||
new("live-presentation bindings", () =>
|
|
||||||
{
|
|
||||||
LivePresentationRuntimeBindings? bindings =
|
|
||||||
_livePresentationBindings;
|
|
||||||
if (bindings is null)
|
|
||||||
return;
|
|
||||||
bindings.Dispose();
|
|
||||||
_livePresentationBindings = null;
|
|
||||||
}),
|
|
||||||
new("entity-effect advance source", () =>
|
|
||||||
{
|
|
||||||
if (_entityEffects is { } effects)
|
|
||||||
_entityEffectAdvance.Unbind(effects);
|
|
||||||
_entityEffectAdvance.Deactivate();
|
|
||||||
}),
|
|
||||||
new("animation-hook registrations", () =>
|
|
||||||
{
|
|
||||||
AnimationHookRegistrationSet? registrations = _hookRegistrations;
|
|
||||||
if (registrations is null)
|
|
||||||
return;
|
|
||||||
registrations.Dispose();
|
|
||||||
if (!registrations.IsCleanupComplete)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Animation-hook registration cleanup remains pending.");
|
|
||||||
}
|
|
||||||
_hookRegistrations = null;
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("live entity dependents",
|
|
||||||
[
|
|
||||||
new("live lights", () =>
|
|
||||||
{
|
|
||||||
_liveEntityLights?.Dispose();
|
|
||||||
_liveEntityLights = null;
|
|
||||||
}),
|
|
||||||
new("live presentation", () => _liveEntityPresentation?.Dispose()),
|
|
||||||
new("remote teleport", () =>
|
|
||||||
{
|
|
||||||
_remoteTeleportController?.Dispose();
|
|
||||||
_remoteTeleportController = null;
|
|
||||||
}),
|
|
||||||
new("effect network state", () =>
|
|
||||||
{
|
|
||||||
_entityEffects?.ClearNetworkState();
|
|
||||||
_animationHookFrames?.Clear();
|
|
||||||
_effectPoses.Clear();
|
|
||||||
}),
|
|
||||||
new("audio", () =>
|
|
||||||
{
|
|
||||||
AcDream.App.Audio.OpenAlAudioEngine? engine = _audioEngine;
|
|
||||||
if (engine is null)
|
|
||||||
return;
|
|
||||||
engine.Dispose();
|
|
||||||
if (!engine.IsDisposalComplete)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"OpenAL native-resource cleanup remains pending.");
|
|
||||||
}
|
|
||||||
_audioEngine = null;
|
|
||||||
_audioSink = null;
|
|
||||||
_soundCache = null;
|
|
||||||
_entitySoundTables = null;
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("submitted GPU work",
|
|
||||||
[
|
|
||||||
new("frame flight drain", () => _gpuFrameFlights?.WaitForSubmittedWork()),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("render frontends",
|
|
||||||
[
|
|
||||||
new("developer tools", () =>
|
|
||||||
{
|
|
||||||
DevToolsCompositionOwner? owner = _devToolsComposition;
|
|
||||||
if (owner is null)
|
|
||||||
return;
|
|
||||||
owner.DisposeFrontend();
|
|
||||||
if (!owner.IsDisposalComplete)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Developer-tools cleanup remains incomplete.");
|
|
||||||
_devToolsComposition = null;
|
|
||||||
_devToolsFramePresenter = null;
|
|
||||||
_devToolsCommandBus = null;
|
|
||||||
_debugVm = null;
|
|
||||||
}),
|
|
||||||
new("portal tunnel", () =>
|
|
||||||
{
|
|
||||||
_localPlayerTeleport?.Dispose();
|
|
||||||
_localPlayerTeleport = null;
|
|
||||||
_portalTunnelFallback.ReleaseFallback();
|
|
||||||
}),
|
|
||||||
new("paperdoll viewport", () =>
|
|
||||||
{
|
|
||||||
_paperdollViewportRenderer?.Dispose();
|
|
||||||
_paperdollViewportRenderer = null;
|
|
||||||
_paperdollFramePresenter = null;
|
|
||||||
}),
|
|
||||||
new("mesh draw dispatcher", () => _wbDrawDispatcher?.Dispose()),
|
|
||||||
new("environment cells", () => _envCellRenderer?.Dispose()),
|
|
||||||
new("portal depth mask", () => _portalDepthMask?.Dispose()),
|
|
||||||
new("clip frame", () => _clipFrame?.Dispose()),
|
|
||||||
new("sky", () => _skyRenderer?.Dispose()),
|
|
||||||
new("particles", () => _particleRenderer?.Dispose()),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("shared texture owners",
|
|
||||||
[
|
|
||||||
new("sampler cache", () => _samplerCache?.Dispose()),
|
|
||||||
new("texture cache", () => _textureCache?.Dispose()),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("mesh adapter",
|
|
||||||
[
|
|
||||||
new("WB mesh adapter", () =>
|
|
||||||
{
|
|
||||||
_wbMeshAdapter?.Dispose();
|
|
||||||
_wbMeshAdapter = null;
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("remaining render owners",
|
|
||||||
[
|
|
||||||
new("mesh shader", () => _meshShader?.Dispose()),
|
|
||||||
new("terrain", () =>
|
|
||||||
{
|
|
||||||
_terrain?.Dispose();
|
|
||||||
_terrain = null;
|
|
||||||
}),
|
|
||||||
new("terrain shader", () => _terrainModernShader?.Dispose()),
|
|
||||||
new("scene lighting", () => _sceneLightingUbo?.Dispose()),
|
|
||||||
new("debug lines", () => _debugLines?.Dispose()),
|
|
||||||
new("text renderer", () => _textRenderer?.Dispose()),
|
|
||||||
new("debug font", () => _debugFont?.Dispose()),
|
|
||||||
new("frame pacing", _displayFramePacing.Dispose),
|
|
||||||
new("frame profiler", _frameProfiler.Dispose),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("dedicated render resources",
|
|
||||||
[
|
|
||||||
new("sky shader", _renderResourceLifetime.ReleaseSkyShader),
|
|
||||||
new("terrain atlas", _renderResourceLifetime.ReleaseTerrainAtlas),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("failed render construction cleanup",
|
|
||||||
[
|
|
||||||
new("GL construction ledger", _glConstructionCleanup.Dispose),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("frame flight owner",
|
|
||||||
[
|
|
||||||
new("frame flights", () =>
|
|
||||||
{
|
|
||||||
_gpuFrameFlights?.Dispose();
|
|
||||||
_gpuFrameFlights = null;
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("content mappings",
|
|
||||||
[
|
|
||||||
new("DAT collection", () =>
|
|
||||||
{
|
|
||||||
_dats?.Dispose();
|
|
||||||
_dats = null;
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("input",
|
|
||||||
[
|
|
||||||
new("native window callbacks", () =>
|
|
||||||
{
|
|
||||||
SilkWindowCallbackBinding? binding = _windowCallbacks;
|
|
||||||
if (binding is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
binding.Dispose();
|
|
||||||
if (!binding.IsDisposalComplete)
|
|
||||||
throw new NativeWindowCallbackCleanupDeferredException();
|
|
||||||
if (ReferenceEquals(_windowCallbacks, binding))
|
|
||||||
_windowCallbacks = null;
|
|
||||||
}),
|
|
||||||
new("input context", () =>
|
|
||||||
{
|
|
||||||
_input?.Dispose();
|
|
||||||
_input = null;
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
new ResourceShutdownStage("OpenGL context",
|
|
||||||
[
|
|
||||||
new("OpenGL", () =>
|
|
||||||
{
|
|
||||||
_gl?.Dispose();
|
|
||||||
_gl = null;
|
|
||||||
}),
|
|
||||||
]));
|
|
||||||
|
|
||||||
|
private GameWindowShutdownRoots CaptureShutdownRoots() => new(
|
||||||
|
new IngressShutdownRoots(
|
||||||
|
_hostQuiescence,
|
||||||
|
_liveCombatModeCommands,
|
||||||
|
_runtimeDiagnosticCommands,
|
||||||
|
_retainedUiGameplayBinding,
|
||||||
|
_gameplayInputActions,
|
||||||
|
_cameraPointerInput,
|
||||||
|
_inputDispatcher,
|
||||||
|
_mouseSource,
|
||||||
|
_kbSource,
|
||||||
|
_retailUiLease,
|
||||||
|
_uiHost,
|
||||||
|
_devToolsComposition,
|
||||||
|
_liveSessionController,
|
||||||
|
_runtimeSettings,
|
||||||
|
_movementInput,
|
||||||
|
_cameraInput,
|
||||||
|
_windowCallbacks),
|
||||||
|
new FrameShutdownRoots(
|
||||||
|
_frameGraphPublication,
|
||||||
|
_frameRootBindings,
|
||||||
|
_sessionPlayerBindings,
|
||||||
|
_interactionUiLateBindings),
|
||||||
|
new LiveShutdownRoots(
|
||||||
|
_cameraPointerInput,
|
||||||
|
_retailUiLease,
|
||||||
|
_combatTargetController,
|
||||||
|
_combatAttackController,
|
||||||
|
_itemInteractionController,
|
||||||
|
_externalContainerLifecycle,
|
||||||
|
_streamer,
|
||||||
|
_equippedChildRenderer,
|
||||||
|
_liveEntities,
|
||||||
|
_livePresentationBindings,
|
||||||
|
_entityEffectAdvance,
|
||||||
|
_entityEffects,
|
||||||
|
_hookRegistrations,
|
||||||
|
_liveEntityLights,
|
||||||
|
_liveEntityPresentation,
|
||||||
|
_remoteTeleportController,
|
||||||
|
_animationHookFrames,
|
||||||
|
_effectPoses,
|
||||||
|
_audioEngine),
|
||||||
|
new RenderShutdownRoots(
|
||||||
|
_gpuFrameFlights,
|
||||||
|
_devToolsComposition,
|
||||||
|
_localPlayerTeleport,
|
||||||
|
_portalTunnelFallback,
|
||||||
|
_paperdollViewportRenderer,
|
||||||
|
_wbDrawDispatcher,
|
||||||
|
_envCellRenderer,
|
||||||
|
_portalDepthMask,
|
||||||
|
_clipFrame,
|
||||||
|
_skyRenderer,
|
||||||
|
_particleRenderer,
|
||||||
|
_samplerCache,
|
||||||
|
_textureCache,
|
||||||
|
_wbMeshAdapter,
|
||||||
|
_meshShader,
|
||||||
|
_terrain,
|
||||||
|
_terrainModernShader,
|
||||||
|
_sceneLightingUbo,
|
||||||
|
_debugLines,
|
||||||
|
_textRenderer,
|
||||||
|
_debugFont,
|
||||||
|
_displayFramePacing,
|
||||||
|
_frameProfiler,
|
||||||
|
_renderResourceLifetime,
|
||||||
|
_glConstructionCleanup),
|
||||||
|
new PlatformShutdownRoots(
|
||||||
|
_dats,
|
||||||
|
_input,
|
||||||
|
_gl));
|
||||||
private void OnFocusChanged(bool focused)
|
private void OnFocusChanged(bool focused)
|
||||||
=> _cameraPointerInput?.HandleFocusChanged(focused);
|
=> _cameraPointerInput?.HandleFocusChanged(focused);
|
||||||
|
|
||||||
|
|
@ -1937,8 +1618,7 @@ public sealed class GameWindow :
|
||||||
// window and exceptions during Window.Create/Run, so owned kernel
|
// window and exceptions during Window.Create/Run, so owned kernel
|
||||||
// handles (including the frame timer) never depend on a native window
|
// handles (including the frame timer) never depend on a native window
|
||||||
// event for disposal.
|
// event for disposal.
|
||||||
CompleteShutdown();
|
CompleteShutdown(releaseNativeWindow: true);
|
||||||
_window?.Dispose();
|
|
||||||
_window = null;
|
_window = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
588
src/AcDream.App/Rendering/GameWindowLifetime.cs
Normal file
588
src/AcDream.App/Rendering/GameWindowLifetime.cs
Normal file
|
|
@ -0,0 +1,588 @@
|
||||||
|
using AcDream.App.Audio;
|
||||||
|
using AcDream.App.Combat;
|
||||||
|
using AcDream.App.Composition;
|
||||||
|
using AcDream.App.Diagnostics;
|
||||||
|
using AcDream.App.Input;
|
||||||
|
using AcDream.App.Net;
|
||||||
|
using AcDream.App.Physics;
|
||||||
|
using AcDream.App.Rendering.Sky;
|
||||||
|
using AcDream.App.Rendering.Vfx;
|
||||||
|
using AcDream.App.Rendering.Wb;
|
||||||
|
using AcDream.App.Settings;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.World;
|
||||||
|
using AcDream.Content;
|
||||||
|
using AcDream.Core.Audio;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.UI.Abstractions.Input;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using Silk.NET.Input;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
internal enum GameWindowLifetimeStatus
|
||||||
|
{
|
||||||
|
Active,
|
||||||
|
RetryableIncomplete,
|
||||||
|
Complete,
|
||||||
|
CompleteWithCleanupFailures,
|
||||||
|
AbandonedIncomplete,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record GameWindowLifetimeReport(
|
||||||
|
GameWindowLifetimeStatus Status,
|
||||||
|
string? BlockedStage,
|
||||||
|
IReadOnlyList<ResourceShutdownCleanupFailure> CleanupFailures,
|
||||||
|
Exception? Error)
|
||||||
|
{
|
||||||
|
public bool IsTerminal => Status is
|
||||||
|
GameWindowLifetimeStatus.Complete
|
||||||
|
or GameWindowLifetimeStatus.CompleteWithCleanupFailures
|
||||||
|
or GameWindowLifetimeStatus.AbandonedIncomplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record IngressShutdownRoots(
|
||||||
|
HostQuiescenceGate HostQuiescence,
|
||||||
|
LiveCombatModeCommandSlot CombatCommands,
|
||||||
|
RuntimeDiagnosticCommandSlot DiagnosticCommands,
|
||||||
|
RetainedUiGameplayBinding? RetainedGameplay,
|
||||||
|
GameplayInputActionRouter? GameplayActions,
|
||||||
|
CameraPointerInputController? CameraPointer,
|
||||||
|
InputDispatcher? Dispatcher,
|
||||||
|
SilkMouseSource? MouseSource,
|
||||||
|
SilkKeyboardSource? KeyboardSource,
|
||||||
|
RetailUiRuntimeLease RetailUi,
|
||||||
|
// Keeps failed physical UI bindings alive through native-window release.
|
||||||
|
UiHost? RetainedUiHost,
|
||||||
|
DevToolsCompositionOwner? DevTools,
|
||||||
|
LiveSessionController? LiveSession,
|
||||||
|
RuntimeSettingsController Settings,
|
||||||
|
DispatcherMovementInputSource MovementInput,
|
||||||
|
DispatcherCameraInputSource CameraInput,
|
||||||
|
SilkWindowCallbackBinding? WindowCallbacks);
|
||||||
|
|
||||||
|
internal sealed record FrameShutdownRoots(
|
||||||
|
IDisposable? FrameGraphPublication,
|
||||||
|
FrameRootRuntimeBindings? FrameBindings,
|
||||||
|
SessionPlayerRuntimeBindings? SessionBindings,
|
||||||
|
InteractionUiLateBindings? InteractionBindings);
|
||||||
|
|
||||||
|
internal sealed record LiveShutdownRoots(
|
||||||
|
CameraPointerInputController? CameraPointer,
|
||||||
|
RetailUiRuntimeLease RetailUi,
|
||||||
|
CombatTargetController? CombatTarget,
|
||||||
|
CombatAttackController? CombatAttack,
|
||||||
|
ItemInteractionController? ItemInteraction,
|
||||||
|
ExternalContainerLifecycleController? ExternalContainers,
|
||||||
|
LandblockStreamer? Streamer,
|
||||||
|
EquippedChildRenderController? EquippedChildren,
|
||||||
|
LiveEntityRuntime? LiveEntities,
|
||||||
|
LivePresentationRuntimeBindings? PresentationBindings,
|
||||||
|
DeferredEntityEffectAdvanceSource EffectAdvance,
|
||||||
|
EntityEffectController? EntityEffects,
|
||||||
|
AnimationHookRegistrationSet? HookRegistrations,
|
||||||
|
LiveEntityLightController? LiveLights,
|
||||||
|
LiveEntityPresentationController? LivePresentation,
|
||||||
|
RemoteTeleportController? RemoteTeleport,
|
||||||
|
AnimationHookFrameQueue? AnimationHookFrames,
|
||||||
|
EntityEffectPoseRegistry EffectPoses,
|
||||||
|
OpenAlAudioEngine? Audio);
|
||||||
|
|
||||||
|
internal sealed record RenderShutdownRoots(
|
||||||
|
GpuFrameFlightController? FrameFlights,
|
||||||
|
DevToolsCompositionOwner? DevTools,
|
||||||
|
LocalPlayerTeleportController? LocalTeleport,
|
||||||
|
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
||||||
|
PaperdollViewportRenderer? Paperdoll,
|
||||||
|
WbDrawDispatcher? DrawDispatcher,
|
||||||
|
EnvCellRenderer? EnvironmentCells,
|
||||||
|
PortalDepthMaskRenderer? PortalDepthMask,
|
||||||
|
ClipFrame? ClipFrame,
|
||||||
|
SkyRenderer? Sky,
|
||||||
|
ParticleRenderer? Particles,
|
||||||
|
SamplerCache? Samplers,
|
||||||
|
TextureCache? Textures,
|
||||||
|
WbMeshAdapter? MeshAdapter,
|
||||||
|
Shader? MeshShader,
|
||||||
|
TerrainModernRenderer? Terrain,
|
||||||
|
Shader? TerrainShader,
|
||||||
|
SceneLightingUboBinding? SceneLighting,
|
||||||
|
DebugLineRenderer? DebugLines,
|
||||||
|
TextRenderer? TextRenderer,
|
||||||
|
BitmapFont? DebugFont,
|
||||||
|
DisplayFramePacingController FramePacing,
|
||||||
|
FrameProfiler FrameProfiler,
|
||||||
|
GameRenderResourceLifetime DedicatedResources,
|
||||||
|
GlConstructionCleanupLedger ConstructionCleanup);
|
||||||
|
|
||||||
|
internal sealed record PlatformShutdownRoots(
|
||||||
|
IDatReaderWriter? Dats,
|
||||||
|
IInputContext? Input,
|
||||||
|
GL? Gl);
|
||||||
|
|
||||||
|
internal sealed record GameWindowShutdownRoots(
|
||||||
|
IngressShutdownRoots Ingress,
|
||||||
|
FrameShutdownRoots Frame,
|
||||||
|
LiveShutdownRoots Live,
|
||||||
|
RenderShutdownRoots Render,
|
||||||
|
PlatformShutdownRoots Platform);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sole process-window lifetime owner. Runtime code receives no service lookup
|
||||||
|
/// through this type; it retains only teardown roots and the immutable terminal
|
||||||
|
/// report.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GameWindowLifetime
|
||||||
|
{
|
||||||
|
private readonly Func<ResourceShutdownTransaction>? _injectedTransactionFactory;
|
||||||
|
private GameWindowShutdownRoots? _roots;
|
||||||
|
private ResourceShutdownTransaction? _transaction;
|
||||||
|
private IDisposable? _nativeWindow;
|
||||||
|
private bool _shutdownRootsPublished;
|
||||||
|
private bool _nativeReleaseAttempted;
|
||||||
|
private bool _completing;
|
||||||
|
private GameWindowLifetimeReport _report = new(
|
||||||
|
GameWindowLifetimeStatus.Active,
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
null);
|
||||||
|
|
||||||
|
public GameWindowLifetime()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal GameWindowLifetime(Func<ResourceShutdownTransaction> transactionFactory)
|
||||||
|
{
|
||||||
|
_injectedTransactionFactory = transactionFactory
|
||||||
|
?? throw new ArgumentNullException(nameof(transactionFactory));
|
||||||
|
}
|
||||||
|
|
||||||
|
public GameWindowLifetimeReport Report => _report;
|
||||||
|
public bool HasShutdownRoots =>
|
||||||
|
_shutdownRootsPublished || _injectedTransactionFactory is not null;
|
||||||
|
internal bool RetainsShutdownGraph => _roots is not null || _transaction is not null;
|
||||||
|
|
||||||
|
public void PublishNativeWindow(IDisposable nativeWindow)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(nativeWindow);
|
||||||
|
if (_nativeReleaseAttempted || _nativeWindow is not null)
|
||||||
|
throw new InvalidOperationException("A native window is already lifetime-owned.");
|
||||||
|
_nativeWindow = nativeWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PublishShutdownRoots(GameWindowShutdownRoots roots)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(roots);
|
||||||
|
if (_transaction is not null || _report.Status != GameWindowLifetimeStatus.Active)
|
||||||
|
throw new InvalidOperationException("Shutdown roots cannot change after completion starts.");
|
||||||
|
if (_roots is not null && !ReferenceEquals(_roots, roots))
|
||||||
|
throw new InvalidOperationException("Shutdown roots are already published.");
|
||||||
|
_roots = roots;
|
||||||
|
_shutdownRootsPublished = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GameWindowLifetimeReport TryComplete()
|
||||||
|
{
|
||||||
|
if (_report.IsTerminal || _completing)
|
||||||
|
return _report;
|
||||||
|
EnsureTransaction();
|
||||||
|
|
||||||
|
_completing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_transaction!.CompleteOrThrow();
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
_report = new GameWindowLifetimeReport(
|
||||||
|
GameWindowLifetimeStatus.RetryableIncomplete,
|
||||||
|
_transaction!.CurrentStageName,
|
||||||
|
_transaction.CleanupFailures,
|
||||||
|
error);
|
||||||
|
return _report;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameWindowLifetimeStatus status = _transaction!.CleanupFailures.Count == 0
|
||||||
|
? GameWindowLifetimeStatus.Complete
|
||||||
|
: GameWindowLifetimeStatus.CompleteWithCleanupFailures;
|
||||||
|
_report = new GameWindowLifetimeReport(
|
||||||
|
status,
|
||||||
|
null,
|
||||||
|
_transaction.CleanupFailures,
|
||||||
|
null);
|
||||||
|
return _report;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_completing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public GameWindowLifetimeReport CompleteAndReleaseNativeWindow()
|
||||||
|
{
|
||||||
|
if (_completing)
|
||||||
|
return _report;
|
||||||
|
if (!_report.IsTerminal)
|
||||||
|
TryComplete();
|
||||||
|
|
||||||
|
if (_report.Status == GameWindowLifetimeStatus.RetryableIncomplete)
|
||||||
|
{
|
||||||
|
AbandonRetainedRootsAfterTerminalFailure();
|
||||||
|
Exception? nativeFailure = ReleaseNativeWindow();
|
||||||
|
Exception terminalError = nativeFailure is null
|
||||||
|
? _report.Error ?? new InvalidOperationException(
|
||||||
|
"Shutdown did not converge before native fallback.")
|
||||||
|
: new AggregateException(
|
||||||
|
"Shutdown and native fallback both failed.",
|
||||||
|
_report.Error ?? new InvalidOperationException(
|
||||||
|
"Shutdown did not converge before native fallback."),
|
||||||
|
nativeFailure);
|
||||||
|
_report = new GameWindowLifetimeReport(
|
||||||
|
GameWindowLifetimeStatus.AbandonedIncomplete,
|
||||||
|
_report.BlockedStage,
|
||||||
|
_report.CleanupFailures,
|
||||||
|
terminalError);
|
||||||
|
return _report;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_report.IsTerminal && !_nativeReleaseAttempted)
|
||||||
|
{
|
||||||
|
Exception? nativeFailure = ReleaseNativeWindow();
|
||||||
|
if (nativeFailure is not null)
|
||||||
|
{
|
||||||
|
_report = new GameWindowLifetimeReport(
|
||||||
|
GameWindowLifetimeStatus.AbandonedIncomplete,
|
||||||
|
"native window",
|
||||||
|
_report.CleanupFailures,
|
||||||
|
nativeFailure);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ReleaseCompletedRoots();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _report;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureTransaction()
|
||||||
|
{
|
||||||
|
if (_transaction is not null)
|
||||||
|
return;
|
||||||
|
_transaction = _injectedTransactionFactory?.Invoke()
|
||||||
|
?? GameWindowShutdownManifest.Create(
|
||||||
|
_roots ?? throw new InvalidOperationException(
|
||||||
|
"Shutdown roots must publish before completion starts."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Exception? ReleaseNativeWindow()
|
||||||
|
{
|
||||||
|
if (_nativeReleaseAttempted)
|
||||||
|
return null;
|
||||||
|
_nativeReleaseAttempted = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_nativeWindow?.Dispose();
|
||||||
|
_nativeWindow = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseCompletedRoots()
|
||||||
|
{
|
||||||
|
// The native event source is now gone, so even reportable physical-edge
|
||||||
|
// failures can no longer call back. Terminal failures deliberately do
|
||||||
|
// not take this path: their exact owner graph stays rooted for safety
|
||||||
|
// and diagnosis.
|
||||||
|
_transaction = null;
|
||||||
|
_roots = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AbandonRetainedRootsAfterTerminalFailure()
|
||||||
|
{
|
||||||
|
RetailUiRuntimeLease? retainedUi = _roots?.Live.RetailUi;
|
||||||
|
if (retainedUi?.HasDisposalFailure == true)
|
||||||
|
retainedUi.AbandonAfterTerminalFailure();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class GameWindowShutdownManifest
|
||||||
|
{
|
||||||
|
public static ResourceShutdownTransaction Create(GameWindowShutdownRoots roots)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(roots);
|
||||||
|
IngressShutdownRoots ingress = roots.Ingress;
|
||||||
|
FrameShutdownRoots frame = roots.Frame;
|
||||||
|
LiveShutdownRoots live = roots.Live;
|
||||||
|
RenderShutdownRoots render = roots.Render;
|
||||||
|
PlatformShutdownRoots platform = roots.Platform;
|
||||||
|
|
||||||
|
return new ResourceShutdownTransaction(
|
||||||
|
new ResourceShutdownStage("host and session barriers",
|
||||||
|
[
|
||||||
|
Hard("host quiescence", ingress.HostQuiescence.StopAccepting),
|
||||||
|
Hard("combat command slot", ingress.CombatCommands.Deactivate),
|
||||||
|
Hard("diagnostic command slot", ingress.DiagnosticCommands.Deactivate),
|
||||||
|
Hard("retained gameplay", () => ingress.RetainedGameplay?.Deactivate()),
|
||||||
|
Hard("gameplay actions", () => ingress.GameplayActions?.Deactivate()),
|
||||||
|
Hard("camera pointer", () => ingress.CameraPointer?.Deactivate()),
|
||||||
|
Hard("dispatcher", () => ingress.Dispatcher?.Deactivate()),
|
||||||
|
Hard("mouse source", () => ingress.MouseSource?.Deactivate()),
|
||||||
|
Hard("keyboard source", () => ingress.KeyboardSource?.Deactivate()),
|
||||||
|
Hard("retained UI input", ingress.RetailUi.QuiesceInput),
|
||||||
|
Hard("developer tools input", () => ingress.DevTools?.DeactivateInput()),
|
||||||
|
Hard("live session", () => DisposeLiveSession(ingress.LiveSession)),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("physical ingress cleanup",
|
||||||
|
[
|
||||||
|
Soft("settings view model", () => ingress.Settings.UnbindViewModel()),
|
||||||
|
Soft("retained gameplay", () => DisposeRetainedGameplay(ingress.RetainedGameplay)),
|
||||||
|
Soft("gameplay actions", () => DisposeGameplayActions(ingress.GameplayActions)),
|
||||||
|
Soft("retained UI input", ingress.RetailUi.DeactivateInput),
|
||||||
|
Soft("developer tools input", () => ingress.DevTools?.DetachInput()),
|
||||||
|
Soft("camera pointer", () => DisposeCameraPointer(ingress.CameraPointer)),
|
||||||
|
Soft("dispatcher", () => DisposeDispatcher(ingress)),
|
||||||
|
Soft("mouse source", () => DisposeMouseSource(ingress.MouseSource)),
|
||||||
|
Soft("keyboard source", () => DisposeKeyboardSource(ingress.KeyboardSource)),
|
||||||
|
Soft("native window callbacks", () => DisposeWindowCallbacks(ingress.WindowCallbacks)),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("frame borrowers",
|
||||||
|
[
|
||||||
|
Hard("world frame composition", () => frame.FrameGraphPublication?.Dispose()),
|
||||||
|
Hard("frame-root bindings", () => frame.FrameBindings?.Dispose()),
|
||||||
|
Hard("session/player bindings", () => frame.SessionBindings?.Dispose()),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("session dependents",
|
||||||
|
[
|
||||||
|
Hard("interaction/UI late bindings", () => frame.InteractionBindings?.Dispose()),
|
||||||
|
Hard("mouse capture", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement()),
|
||||||
|
Hard("retail UI", () => DisposeRetailUi(live.RetailUi)),
|
||||||
|
Hard("combat target", () => live.CombatTarget?.Dispose()),
|
||||||
|
Hard("combat attack", () => live.CombatAttack?.Dispose()),
|
||||||
|
Hard("item interaction", () => live.ItemInteraction?.Dispose()),
|
||||||
|
Hard("external containers", () => live.ExternalContainers?.Dispose()),
|
||||||
|
Hard("streamer", () => live.Streamer?.Dispose()),
|
||||||
|
Hard("equipped children", () => live.EquippedChildren?.Dispose()),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("live entities",
|
||||||
|
[
|
||||||
|
Hard("live entity runtime", () => live.LiveEntities?.Clear()),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("effect dispatch edges",
|
||||||
|
[
|
||||||
|
Hard("live-presentation bindings", () => live.PresentationBindings?.Dispose()),
|
||||||
|
Hard("entity-effect advance source", () =>
|
||||||
|
{
|
||||||
|
if (live.EntityEffects is { } effects)
|
||||||
|
live.EffectAdvance.Unbind(effects);
|
||||||
|
live.EffectAdvance.Deactivate();
|
||||||
|
}),
|
||||||
|
Hard("animation-hook registrations", () => DisposeHookRegistrations(live.HookRegistrations)),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("live entity dependents",
|
||||||
|
[
|
||||||
|
Hard("live lights", () => live.LiveLights?.Dispose()),
|
||||||
|
Hard("live presentation", () => live.LivePresentation?.Dispose()),
|
||||||
|
Hard("remote teleport", () => live.RemoteTeleport?.Dispose()),
|
||||||
|
Hard("effect network state", () =>
|
||||||
|
{
|
||||||
|
live.EntityEffects?.ClearNetworkState();
|
||||||
|
live.AnimationHookFrames?.Clear();
|
||||||
|
live.EffectPoses.Clear();
|
||||||
|
}),
|
||||||
|
Hard("audio", () => DisposeAudio(live.Audio)),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("submitted GPU work",
|
||||||
|
[
|
||||||
|
Hard("frame flight drain", () => render.FrameFlights?.WaitForSubmittedWork()),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("render frontends",
|
||||||
|
[
|
||||||
|
Hard("developer tools", () => DisposeDevToolsFrontend(render.DevTools)),
|
||||||
|
Hard("portal tunnel", () =>
|
||||||
|
{
|
||||||
|
render.LocalTeleport?.Dispose();
|
||||||
|
render.PortalTunnelFallback.ReleaseFallback();
|
||||||
|
}),
|
||||||
|
Hard("paperdoll viewport", () => render.Paperdoll?.Dispose()),
|
||||||
|
Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()),
|
||||||
|
Hard("environment cells", () => render.EnvironmentCells?.Dispose()),
|
||||||
|
Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()),
|
||||||
|
Hard("clip frame", () => render.ClipFrame?.Dispose()),
|
||||||
|
Hard("sky", () => render.Sky?.Dispose()),
|
||||||
|
Hard("particles", () => render.Particles?.Dispose()),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("shared texture owners",
|
||||||
|
[
|
||||||
|
Hard("sampler cache", () => render.Samplers?.Dispose()),
|
||||||
|
Hard("texture cache", () => render.Textures?.Dispose()),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("mesh adapter",
|
||||||
|
[
|
||||||
|
Hard("WB mesh adapter", () => render.MeshAdapter?.Dispose()),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("remaining render owners",
|
||||||
|
[
|
||||||
|
Hard("mesh shader", () => render.MeshShader?.Dispose()),
|
||||||
|
Hard("terrain", () => render.Terrain?.Dispose()),
|
||||||
|
Hard("terrain shader", () => render.TerrainShader?.Dispose()),
|
||||||
|
Hard("scene lighting", () => render.SceneLighting?.Dispose()),
|
||||||
|
Hard("debug lines", () => render.DebugLines?.Dispose()),
|
||||||
|
Hard("text renderer", () => render.TextRenderer?.Dispose()),
|
||||||
|
Hard("debug font", () => render.DebugFont?.Dispose()),
|
||||||
|
Hard("frame pacing", render.FramePacing.Dispose),
|
||||||
|
Hard("frame profiler", render.FrameProfiler.Dispose),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("dedicated render resources",
|
||||||
|
[
|
||||||
|
Hard("sky shader", render.DedicatedResources.ReleaseSkyShader),
|
||||||
|
Hard("terrain atlas", render.DedicatedResources.ReleaseTerrainAtlas),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("failed render construction cleanup",
|
||||||
|
[
|
||||||
|
Hard("GL construction ledger", render.ConstructionCleanup.Dispose),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("frame flight owner",
|
||||||
|
[
|
||||||
|
Hard("frame flights", () => render.FrameFlights?.Dispose()),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("content mappings",
|
||||||
|
[
|
||||||
|
Hard("DAT collection", () => platform.Dats?.Dispose()),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("input context",
|
||||||
|
[
|
||||||
|
Hard("input context", () => platform.Input?.Dispose()),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("OpenGL context",
|
||||||
|
[
|
||||||
|
Hard("OpenGL", () => platform.Gl?.Dispose()),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResourceShutdownOperation Hard(string name, Action action) =>
|
||||||
|
new(name, action);
|
||||||
|
|
||||||
|
private static ResourceShutdownOperation Soft(string name, Action action) =>
|
||||||
|
new(name, action, ResourceShutdownOperationPolicy.ReportAndContinue);
|
||||||
|
|
||||||
|
private static void DisposeLiveSession(LiveSessionController? controller)
|
||||||
|
{
|
||||||
|
if (controller is null)
|
||||||
|
return;
|
||||||
|
controller.Dispose();
|
||||||
|
if (!controller.IsDisposalComplete)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Live-session disposal was deferred by a reentrant lifecycle callback.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeRetainedGameplay(RetainedUiGameplayBinding? binding)
|
||||||
|
{
|
||||||
|
if (binding is null)
|
||||||
|
return;
|
||||||
|
binding.Dispose();
|
||||||
|
if (!binding.IsDisposalComplete)
|
||||||
|
throw new InvalidOperationException("Retained gameplay callback removal remains pending.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeGameplayActions(GameplayInputActionRouter? actions)
|
||||||
|
{
|
||||||
|
if (actions is null)
|
||||||
|
return;
|
||||||
|
actions.Dispose();
|
||||||
|
if (!actions.IsDisposalComplete)
|
||||||
|
throw new InvalidOperationException("Gameplay action callback removal remains pending.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeCameraPointer(CameraPointerInputController? pointer)
|
||||||
|
{
|
||||||
|
if (pointer is null)
|
||||||
|
return;
|
||||||
|
pointer.Dispose();
|
||||||
|
if (!pointer.IsDisposalComplete)
|
||||||
|
throw new InvalidOperationException("Camera pointer callback removal remains pending.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeDispatcher(IngressShutdownRoots roots)
|
||||||
|
{
|
||||||
|
InputDispatcher? dispatcher = roots.Dispatcher;
|
||||||
|
if (dispatcher is null)
|
||||||
|
return;
|
||||||
|
roots.MovementInput.Unbind(dispatcher);
|
||||||
|
roots.CameraInput.Unbind(dispatcher);
|
||||||
|
dispatcher.Dispose();
|
||||||
|
if (!dispatcher.IsDisposalComplete)
|
||||||
|
throw new InvalidOperationException("Input dispatcher source removal remains pending.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeMouseSource(SilkMouseSource? source)
|
||||||
|
{
|
||||||
|
if (source is null)
|
||||||
|
return;
|
||||||
|
source.Dispose();
|
||||||
|
if (!source.IsDisposalComplete)
|
||||||
|
throw new InvalidOperationException("Mouse source callback removal remains pending.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeKeyboardSource(SilkKeyboardSource? source)
|
||||||
|
{
|
||||||
|
if (source is null)
|
||||||
|
return;
|
||||||
|
source.Dispose();
|
||||||
|
if (!source.IsDisposalComplete)
|
||||||
|
throw new InvalidOperationException("Keyboard source callback removal remains pending.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeWindowCallbacks(SilkWindowCallbackBinding? binding)
|
||||||
|
{
|
||||||
|
if (binding is null)
|
||||||
|
return;
|
||||||
|
binding.Dispose();
|
||||||
|
if (!binding.IsDisposalComplete)
|
||||||
|
throw new NativeWindowCallbackCleanupDeferredException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeRetailUi(RetailUiRuntimeLease lease)
|
||||||
|
{
|
||||||
|
lease.Dispose();
|
||||||
|
if (!lease.IsDisposalComplete)
|
||||||
|
throw new InvalidOperationException("The retained UI ownership lease did not complete disposal.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeHookRegistrations(AnimationHookRegistrationSet? registrations)
|
||||||
|
{
|
||||||
|
if (registrations is null)
|
||||||
|
return;
|
||||||
|
registrations.Dispose();
|
||||||
|
if (!registrations.IsCleanupComplete)
|
||||||
|
throw new InvalidOperationException("Animation-hook registration cleanup remains pending.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeAudio(OpenAlAudioEngine? engine)
|
||||||
|
{
|
||||||
|
if (engine is null)
|
||||||
|
return;
|
||||||
|
engine.Dispose();
|
||||||
|
if (!engine.IsDisposalComplete)
|
||||||
|
throw new InvalidOperationException("OpenAL native-resource cleanup remains pending.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeDevToolsFrontend(DevToolsCompositionOwner? owner)
|
||||||
|
{
|
||||||
|
if (owner is null)
|
||||||
|
return;
|
||||||
|
owner.DisposeFrontend();
|
||||||
|
if (!owner.IsFrontendDisposalComplete)
|
||||||
|
throw new InvalidOperationException("Developer-tools frontend cleanup remains incomplete.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,21 @@
|
||||||
namespace AcDream.App.Rendering;
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
internal readonly record struct ResourceShutdownOperation(string Name, Action Execute);
|
internal enum ResourceShutdownOperationPolicy
|
||||||
|
{
|
||||||
|
HardBarrier,
|
||||||
|
ReportAndContinue,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct ResourceShutdownOperation(
|
||||||
|
string Name,
|
||||||
|
Action Execute,
|
||||||
|
ResourceShutdownOperationPolicy Policy =
|
||||||
|
ResourceShutdownOperationPolicy.HardBarrier);
|
||||||
|
|
||||||
|
internal sealed record ResourceShutdownCleanupFailure(
|
||||||
|
string Stage,
|
||||||
|
string Operation,
|
||||||
|
Exception Error);
|
||||||
|
|
||||||
internal sealed record ResourceShutdownStage(
|
internal sealed record ResourceShutdownStage(
|
||||||
string Name,
|
string Name,
|
||||||
|
|
@ -16,6 +31,7 @@ internal sealed class ResourceShutdownTransaction(params ResourceShutdownStage[]
|
||||||
private sealed class StageState(int operationCount)
|
private sealed class StageState(int operationCount)
|
||||||
{
|
{
|
||||||
public bool[] Complete { get; } = new bool[operationCount];
|
public bool[] Complete { get; } = new bool[operationCount];
|
||||||
|
public int[] Attempts { get; } = new int[operationCount];
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int MaximumConsecutiveStalledPasses = 2;
|
private const int MaximumConsecutiveStalledPasses = 2;
|
||||||
|
|
@ -23,11 +39,15 @@ internal sealed class ResourceShutdownTransaction(params ResourceShutdownStage[]
|
||||||
stages ?? throw new ArgumentNullException(nameof(stages));
|
stages ?? throw new ArgumentNullException(nameof(stages));
|
||||||
private readonly StageState[] _states =
|
private readonly StageState[] _states =
|
||||||
stages.Select(stage => new StageState(stage.Operations.Length)).ToArray();
|
stages.Select(stage => new StageState(stage.Operations.Length)).ToArray();
|
||||||
|
private readonly List<ResourceShutdownCleanupFailure> _cleanupFailures = [];
|
||||||
private int _currentStage;
|
private int _currentStage;
|
||||||
private bool _completing;
|
private bool _completing;
|
||||||
|
|
||||||
public bool IsComplete => _currentStage == _stages.Length;
|
public bool IsComplete => _currentStage == _stages.Length;
|
||||||
internal int CurrentStage => _currentStage;
|
internal int CurrentStage => _currentStage;
|
||||||
|
internal string? CurrentStageName => IsComplete ? null : _stages[_currentStage].Name;
|
||||||
|
internal IReadOnlyList<ResourceShutdownCleanupFailure> CleanupFailures =>
|
||||||
|
_cleanupFailures.ToArray();
|
||||||
|
|
||||||
public void CompleteOrThrow()
|
public void CompleteOrThrow()
|
||||||
{
|
{
|
||||||
|
|
@ -87,10 +107,26 @@ internal sealed class ResourceShutdownTransaction(params ResourceShutdownStage[]
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
(failures ??= []).Add(new InvalidOperationException(
|
var wrapped = new InvalidOperationException(
|
||||||
$"Shutdown operation '{operation.Name}' failed in stage '{stage.Name}'.",
|
$"Shutdown operation '{operation.Name}' failed in stage '{stage.Name}'.",
|
||||||
|
error);
|
||||||
|
state.Attempts[i]++;
|
||||||
|
if (operation.Policy ==
|
||||||
|
ResourceShutdownOperationPolicy.ReportAndContinue
|
||||||
|
&& state.Attempts[i] >= MaximumConsecutiveStalledPasses)
|
||||||
|
{
|
||||||
|
state.Complete[i] = true;
|
||||||
|
progressed = true;
|
||||||
|
_cleanupFailures.Add(new ResourceShutdownCleanupFailure(
|
||||||
|
stage.Name,
|
||||||
|
operation.Name,
|
||||||
error));
|
error));
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
(failures ??= []).Add(wrapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.Complete.All(static complete => complete))
|
if (state.Complete.All(static complete => complete))
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ internal sealed class RetailUiRuntimeLease : IDisposable
|
||||||
|
|
||||||
public bool IsAbandoned => _abandoned;
|
public bool IsAbandoned => _abandoned;
|
||||||
|
|
||||||
|
internal bool HasDisposalFailure => _disposalFailed;
|
||||||
|
|
||||||
internal bool RetainsResources => _host is not null || _runtime is not null;
|
internal bool RetainsResources => _host is not null || _runtime is not null;
|
||||||
|
|
||||||
public UiHost AcquireHost(Func<UiHost> factory) => AcquireHostCore(
|
public UiHost AcquireHost(Func<UiHost> factory) => AcquireHostCore(
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,9 @@ public sealed class UiHost : System.IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Physically removes every retained input edge after quiescence.</summary>
|
/// <summary>Physically removes every retained input edge after quiescence.</summary>
|
||||||
public void DeactivateInput()
|
public void DeactivateInput() => CompleteInputShutdown(reportFailures: true);
|
||||||
|
|
||||||
|
private void CompleteInputShutdown(bool reportFailures)
|
||||||
{
|
{
|
||||||
QuiesceInput();
|
QuiesceInput();
|
||||||
|
|
||||||
|
|
@ -164,11 +166,21 @@ public sealed class UiHost : System.IDisposable
|
||||||
.Reverse()
|
.Reverse()
|
||||||
.Select((binding, index) => new ResourceShutdownOperation(
|
.Select((binding, index) => new ResourceShutdownOperation(
|
||||||
$"input binding {index}",
|
$"input binding {index}",
|
||||||
binding.Dispose))
|
binding.Dispose,
|
||||||
|
ResourceShutdownOperationPolicy.ReportAndContinue))
|
||||||
.ToArray()));
|
.ToArray()));
|
||||||
_inputShutdown.CompleteOrThrow();
|
_inputShutdown.CompleteOrThrow();
|
||||||
if (_inputShutdown.IsComplete)
|
if (_inputShutdown.IsComplete && _inputShutdown.CleanupFailures.Count == 0)
|
||||||
_inputBindings.Clear();
|
_inputBindings.Clear();
|
||||||
|
if (reportFailures && _inputShutdown.CleanupFailures.Count != 0)
|
||||||
|
{
|
||||||
|
throw new AggregateException(
|
||||||
|
"Retained UI input callback cleanup completed with failures.",
|
||||||
|
_inputShutdown.CleanupFailures.Select(static failure =>
|
||||||
|
new InvalidOperationException(
|
||||||
|
$"Retained UI operation '{failure.Operation}' failed in stage '{failure.Stage}'.",
|
||||||
|
failure.Error)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Window manager forwarders (delegate to UiRoot) ─────────────────
|
// ── Window manager forwarders (delegate to UiRoot) ─────────────────
|
||||||
|
|
@ -205,7 +217,7 @@ public sealed class UiHost : System.IDisposable
|
||||||
|
|
||||||
_disposeRequested = true;
|
_disposeRequested = true;
|
||||||
_shutdown ??= CreateShutdownTransaction(
|
_shutdown ??= CreateShutdownTransaction(
|
||||||
[DeactivateInput],
|
[() => CompleteInputShutdown(reportFailures: false)],
|
||||||
() => { },
|
() => { },
|
||||||
WindowManager.Dispose,
|
WindowManager.Dispose,
|
||||||
TextRenderer.Dispose);
|
TextRenderer.Dispose);
|
||||||
|
|
|
||||||
212
tests/AcDream.App.Tests/Rendering/GameWindowLifetimeTests.cs
Normal file
212
tests/AcDream.App.Tests/Rendering/GameWindowLifetimeTests.cs
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class GameWindowLifetimeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CleanCompletionReleasesNativeWindowLastAndTerminalCallsAreInert()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var transaction = new ResourceShutdownTransaction(
|
||||||
|
new ResourceShutdownStage("owners",
|
||||||
|
[
|
||||||
|
new("owner", () => calls.Add("owner")),
|
||||||
|
]));
|
||||||
|
var lifetime = new GameWindowLifetime(() => transaction);
|
||||||
|
var native = new RecordingDisposable("native", calls);
|
||||||
|
lifetime.PublishNativeWindow(native);
|
||||||
|
|
||||||
|
GameWindowLifetimeReport closing = lifetime.TryComplete();
|
||||||
|
Assert.Equal(GameWindowLifetimeStatus.Complete, closing.Status);
|
||||||
|
Assert.Equal(["owner"], calls);
|
||||||
|
|
||||||
|
GameWindowLifetimeReport disposed = lifetime.CompleteAndReleaseNativeWindow();
|
||||||
|
GameWindowLifetimeReport repeated = lifetime.CompleteAndReleaseNativeWindow();
|
||||||
|
|
||||||
|
Assert.Same(closing, disposed);
|
||||||
|
Assert.Same(disposed, repeated);
|
||||||
|
Assert.Equal(["owner", "native"], calls);
|
||||||
|
Assert.Equal(1, native.DisposeCalls);
|
||||||
|
Assert.True(lifetime.HasShutdownRoots);
|
||||||
|
Assert.False(lifetime.RetainsShutdownGraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HardBarrierFailureRetriesWithoutReplayThenCompletes()
|
||||||
|
{
|
||||||
|
int stableCalls = 0;
|
||||||
|
int pendingCalls = 0;
|
||||||
|
bool allowPending = false;
|
||||||
|
var transaction = new ResourceShutdownTransaction(
|
||||||
|
new ResourceShutdownStage("session barrier",
|
||||||
|
[
|
||||||
|
new("stable", () => stableCalls++),
|
||||||
|
new("pending", () =>
|
||||||
|
{
|
||||||
|
pendingCalls++;
|
||||||
|
if (!allowPending)
|
||||||
|
throw new InvalidOperationException("pending");
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
var lifetime = new GameWindowLifetime(() => transaction);
|
||||||
|
|
||||||
|
GameWindowLifetimeReport first = lifetime.TryComplete();
|
||||||
|
Assert.Equal(GameWindowLifetimeStatus.RetryableIncomplete, first.Status);
|
||||||
|
Assert.Equal("session barrier", first.BlockedStage);
|
||||||
|
int failedAttempts = pendingCalls;
|
||||||
|
allowPending = true;
|
||||||
|
|
||||||
|
GameWindowLifetimeReport second = lifetime.CompleteAndReleaseNativeWindow();
|
||||||
|
|
||||||
|
Assert.Equal(GameWindowLifetimeStatus.Complete, second.Status);
|
||||||
|
Assert.Equal(1, stableCalls);
|
||||||
|
Assert.Equal(failedAttempts + 1, pendingCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PersistentSoftFailureCompletesAndRetainsStructuredReport()
|
||||||
|
{
|
||||||
|
int dependentCalls = 0;
|
||||||
|
var physicalFailure = new InvalidOperationException("remove failed");
|
||||||
|
var transaction = new ResourceShutdownTransaction(
|
||||||
|
new ResourceShutdownStage("physical ingress cleanup",
|
||||||
|
[
|
||||||
|
new(
|
||||||
|
"mouse",
|
||||||
|
() => throw physicalFailure,
|
||||||
|
ResourceShutdownOperationPolicy.ReportAndContinue),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("dependents",
|
||||||
|
[
|
||||||
|
new("dependent", () => dependentCalls++),
|
||||||
|
]));
|
||||||
|
var lifetime = new GameWindowLifetime(() => transaction);
|
||||||
|
|
||||||
|
GameWindowLifetimeReport report = lifetime.CompleteAndReleaseNativeWindow();
|
||||||
|
|
||||||
|
Assert.Equal(GameWindowLifetimeStatus.CompleteWithCleanupFailures, report.Status);
|
||||||
|
Assert.Equal(1, dependentCalls);
|
||||||
|
ResourceShutdownCleanupFailure cleanup = Assert.Single(report.CleanupFailures);
|
||||||
|
Assert.Equal("physical ingress cleanup", cleanup.Stage);
|
||||||
|
Assert.Equal("mouse", cleanup.Operation);
|
||||||
|
Assert.Same(physicalFailure, cleanup.Error);
|
||||||
|
Assert.Same(report, lifetime.CompleteAndReleaseNativeWindow());
|
||||||
|
Assert.False(lifetime.RetainsShutdownGraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PersistentHardFailureUsesNativeFallbackAndBecomesTerminal()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
int attempts = 0;
|
||||||
|
var transaction = new ResourceShutdownTransaction(
|
||||||
|
new ResourceShutdownStage("GPU barrier",
|
||||||
|
[
|
||||||
|
new("drain", () =>
|
||||||
|
{
|
||||||
|
attempts++;
|
||||||
|
throw new InvalidOperationException("driver wedged");
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("must stay protected",
|
||||||
|
[
|
||||||
|
new("dependent", () => calls.Add("dependent")),
|
||||||
|
]));
|
||||||
|
var lifetime = new GameWindowLifetime(() => transaction);
|
||||||
|
var native = new RecordingDisposable("native", calls);
|
||||||
|
lifetime.PublishNativeWindow(native);
|
||||||
|
|
||||||
|
GameWindowLifetimeReport report = lifetime.CompleteAndReleaseNativeWindow();
|
||||||
|
int terminalAttempts = attempts;
|
||||||
|
|
||||||
|
Assert.Equal(GameWindowLifetimeStatus.AbandonedIncomplete, report.Status);
|
||||||
|
Assert.Equal("GPU barrier", report.BlockedStage);
|
||||||
|
Assert.NotNull(report.Error);
|
||||||
|
Assert.Equal(["native"], calls);
|
||||||
|
Assert.Same(report, lifetime.TryComplete());
|
||||||
|
Assert.Same(report, lifetime.CompleteAndReleaseNativeWindow());
|
||||||
|
Assert.Equal(terminalAttempts, attempts);
|
||||||
|
Assert.Equal(1, native.DisposeCalls);
|
||||||
|
Assert.True(lifetime.RetainsShutdownGraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NativeReleaseFailureIsTerminalAndNeverRetried()
|
||||||
|
{
|
||||||
|
var transaction = new ResourceShutdownTransaction();
|
||||||
|
var lifetime = new GameWindowLifetime(() => transaction);
|
||||||
|
var native = new ThrowingDisposable();
|
||||||
|
lifetime.PublishNativeWindow(native);
|
||||||
|
|
||||||
|
GameWindowLifetimeReport report = lifetime.CompleteAndReleaseNativeWindow();
|
||||||
|
|
||||||
|
Assert.Equal(GameWindowLifetimeStatus.AbandonedIncomplete, report.Status);
|
||||||
|
Assert.Equal("native window", report.BlockedStage);
|
||||||
|
Assert.Equal(1, native.DisposeCalls);
|
||||||
|
Assert.Same(report, lifetime.CompleteAndReleaseNativeWindow());
|
||||||
|
Assert.Equal(1, native.DisposeCalls);
|
||||||
|
Assert.True(lifetime.RetainsShutdownGraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReentrantCompletionDoesNotAbandonOrReplayOuterProgress()
|
||||||
|
{
|
||||||
|
GameWindowLifetime? lifetime = null;
|
||||||
|
GameWindowLifetimeReport? nested = null;
|
||||||
|
int calls = 0;
|
||||||
|
var transaction = new ResourceShutdownTransaction(
|
||||||
|
new ResourceShutdownStage("owner",
|
||||||
|
[
|
||||||
|
new("reentrant", () =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
nested = lifetime!.CompleteAndReleaseNativeWindow();
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
lifetime = new GameWindowLifetime(() => transaction);
|
||||||
|
|
||||||
|
GameWindowLifetimeReport outer = lifetime.TryComplete();
|
||||||
|
|
||||||
|
Assert.NotNull(nested);
|
||||||
|
Assert.Equal(GameWindowLifetimeStatus.Active, nested.Status);
|
||||||
|
Assert.Equal(GameWindowLifetimeStatus.Complete, outer.Status);
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConstructedNeverRunCompletesWithoutNativeWindow()
|
||||||
|
{
|
||||||
|
var lifetime = new GameWindowLifetime(
|
||||||
|
static () => new ResourceShutdownTransaction());
|
||||||
|
|
||||||
|
GameWindowLifetimeReport report = lifetime.CompleteAndReleaseNativeWindow();
|
||||||
|
|
||||||
|
Assert.Equal(GameWindowLifetimeStatus.Complete, report.Status);
|
||||||
|
Assert.Same(report, lifetime.CompleteAndReleaseNativeWindow());
|
||||||
|
Assert.False(lifetime.RetainsShutdownGraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingDisposable(string name, List<string> calls)
|
||||||
|
: IDisposable
|
||||||
|
{
|
||||||
|
public int DisposeCalls { get; private set; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
DisposeCalls++;
|
||||||
|
calls.Add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingDisposable : IDisposable
|
||||||
|
{
|
||||||
|
public int DisposeCalls { get; private set; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
DisposeCalls++;
|
||||||
|
throw new InvalidOperationException("native failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -133,33 +133,37 @@ public sealed class GameWindowRenderLeafCompositionTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Shutdown_PreservesBorrowedDevtoolsLifetimeAndDrainsGpuBeforeFrontends()
|
public void Shutdown_PreservesBorrowedDevtoolsLifetimeAndDrainsGpuBeforeFrontends()
|
||||||
{
|
{
|
||||||
string source = GameWindowSource();
|
string source = GameWindowLifetimeSource();
|
||||||
|
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
source,
|
source,
|
||||||
"new ResourceShutdownStage(\"submitted GPU work\"",
|
"new ResourceShutdownStage(\"submitted GPU work\"",
|
||||||
"new ResourceShutdownStage(\"render frontends\"",
|
"new ResourceShutdownStage(\"render frontends\"",
|
||||||
"new(\"developer tools\"",
|
"Hard(\"developer tools\", () => DisposeDevToolsFrontend(render.DevTools))",
|
||||||
"owner.DisposeFrontend()",
|
"Hard(\"portal tunnel\"",
|
||||||
"new(\"portal tunnel\"",
|
"Hard(\"paperdoll viewport\"",
|
||||||
"new(\"paperdoll viewport\"",
|
|
||||||
"new ResourceShutdownStage(\"OpenGL context\"");
|
"new ResourceShutdownStage(\"OpenGL context\"");
|
||||||
|
AssertAppearsInOrder(
|
||||||
|
source,
|
||||||
|
"private static void DisposeDevToolsFrontend(DevToolsCompositionOwner? owner)",
|
||||||
|
"owner.DisposeFrontend()",
|
||||||
|
"if (!owner.IsFrontendDisposalComplete)");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
source,
|
source,
|
||||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||||
"publication.Dispose();",
|
"frame.FrameGraphPublication?.Dispose()",
|
||||||
"bindings.Dispose();",
|
"frame.FrameBindings?.Dispose()",
|
||||||
"new ResourceShutdownStage(\"session dependents\"");
|
"new ResourceShutdownStage(\"session dependents\"");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
source,
|
source,
|
||||||
"new(\"frame pacing\", _displayFramePacing.Dispose)",
|
"Hard(\"frame pacing\", render.FramePacing.Dispose)",
|
||||||
"new(\"frame profiler\", _frameProfiler.Dispose)");
|
"Hard(\"frame profiler\", render.FrameProfiler.Dispose)");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
source,
|
source,
|
||||||
"publication.Dispose();",
|
"frame.FrameGraphPublication?.Dispose()",
|
||||||
"owner.DisposeFrontend()",
|
"Hard(\"developer tools\", () => DisposeDevToolsFrontend(render.DevTools))",
|
||||||
"new ResourceShutdownStage(\"input\"",
|
"new ResourceShutdownStage(\"input context\"",
|
||||||
"_input?.Dispose();",
|
"platform.Input?.Dispose()",
|
||||||
"new ResourceShutdownStage(\"OpenGL context\"");
|
"new ResourceShutdownStage(\"OpenGL context\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,6 +243,13 @@ public sealed class GameWindowRenderLeafCompositionTests
|
||||||
"Rendering",
|
"Rendering",
|
||||||
"GameWindow.cs"));
|
"GameWindow.cs"));
|
||||||
|
|
||||||
|
private static string GameWindowLifetimeSource() => File.ReadAllText(Path.Combine(
|
||||||
|
FindRepoRoot(),
|
||||||
|
"src",
|
||||||
|
"AcDream.App",
|
||||||
|
"Rendering",
|
||||||
|
"GameWindowLifetime.cs"));
|
||||||
|
|
||||||
private static string LivePresentationSource() => File.ReadAllText(Path.Combine(
|
private static string LivePresentationSource() => File.ReadAllText(Path.Combine(
|
||||||
FindRepoRoot(),
|
FindRepoRoot(),
|
||||||
"src",
|
"src",
|
||||||
|
|
|
||||||
|
|
@ -233,9 +233,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"AcDream.App",
|
"AcDream.App",
|
||||||
"Composition",
|
"Composition",
|
||||||
"SessionPlayerComposition.cs"));
|
"SessionPlayerComposition.cs"));
|
||||||
string shutdown = MethodBody(
|
string shutdown = GameWindowLifetimeSource();
|
||||||
"private ResourceShutdownTransaction CreateShutdownTransaction()",
|
|
||||||
"private void OnFocusChanged(bool focused)");
|
|
||||||
|
|
||||||
Assert.DoesNotContain("private void OnInputAction(", source, StringComparison.Ordinal);
|
Assert.DoesNotContain("private void OnInputAction(", source, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("private void SetInputCombatScope(", source, StringComparison.Ordinal);
|
Assert.DoesNotContain("private void SetInputCombatScope(", source, StringComparison.Ordinal);
|
||||||
|
|
@ -256,15 +254,17 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"retainedGameplayLease.Resource.Attach();"));
|
"retainedGameplayLease.Resource.Attach();"));
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
shutdown,
|
shutdown,
|
||||||
"_liveCombatModeCommands.Deactivate",
|
"Hard(\"combat command slot\", ingress.CombatCommands.Deactivate)",
|
||||||
"_runtimeDiagnosticCommands.Deactivate",
|
"Hard(\"diagnostic command slot\", ingress.DiagnosticCommands.Deactivate)",
|
||||||
"_retainedUiGameplayBinding?.Deactivate()",
|
"Hard(\"retained gameplay\", () => ingress.RetainedGameplay?.Deactivate())",
|
||||||
"_gameplayInputActions?.Deactivate()",
|
"Hard(\"gameplay actions\", () => ingress.GameplayActions?.Deactivate())",
|
||||||
"new ResourceShutdownStage(\"session lifetime\"",
|
"Hard(\"live session\", () => DisposeLiveSession(ingress.LiveSession))",
|
||||||
"binding.Dispose();",
|
"new ResourceShutdownStage(\"physical ingress cleanup\"",
|
||||||
"_retainedUiGameplayBinding = null;",
|
"Soft(\"retained gameplay\", () => DisposeRetainedGameplay(ingress.RetainedGameplay))",
|
||||||
"actions.Dispose();",
|
"Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))",
|
||||||
"_gameplayInputActions = null;");
|
"Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))",
|
||||||
|
"new ResourceShutdownStage(\"session dependents\"",
|
||||||
|
"Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -310,9 +310,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
string load = MethodBody(
|
string load = MethodBody(
|
||||||
"private void OnLoad()",
|
"private void OnLoad()",
|
||||||
"private void OnUpdate(double dt)");
|
"private void OnUpdate(double dt)");
|
||||||
string shutdown = MethodBody(
|
string shutdown = GameWindowLifetimeSource();
|
||||||
"private ResourceShutdownTransaction CreateShutdownTransaction()",
|
|
||||||
"private void OnFocusChanged(bool focused)");
|
|
||||||
|
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"private readonly RuntimeSettingsController _runtimeSettings;",
|
"private readonly RuntimeSettingsController _runtimeSettings;",
|
||||||
|
|
@ -390,13 +388,13 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"_factory.CreateTerrain(");
|
"_factory.CreateTerrain(");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
shutdown,
|
shutdown,
|
||||||
"_runtimeSettings.UnbindViewModel()",
|
"Soft(\"settings view model\", () => ingress.Settings.UnbindViewModel())",
|
||||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||||
"bindings.Dispose();",
|
"Hard(\"frame-root bindings\", () => frame.FrameBindings?.Dispose())",
|
||||||
"_retailUiLease.Dispose()",
|
"Hard(\"retail UI\", () => DisposeRetailUi(live.RetailUi))",
|
||||||
"_streamer?.Dispose()",
|
"Hard(\"streamer\", () => live.Streamer?.Dispose())",
|
||||||
"_wbDrawDispatcher?.Dispose()",
|
"Hard(\"mesh draw dispatcher\", () => render.DrawDispatcher?.Dispose())",
|
||||||
"_terrain?.Dispose()");
|
"Hard(\"terrain\", () => render.Terrain?.Dispose())");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -418,7 +416,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
string close = Slice(
|
string close = Slice(
|
||||||
source,
|
source,
|
||||||
"private void OnClosing()",
|
"private void OnClosing()",
|
||||||
"private ResourceShutdownTransaction CreateShutdownTransaction()");
|
"private void OnFocusChanged(bool focused)");
|
||||||
|
|
||||||
Assert.Equal(1, CountOccurrences(update, "_frameGraphs.Tick("));
|
Assert.Equal(1, CountOccurrences(update, "_frameGraphs.Tick("));
|
||||||
Assert.Equal(1, CountOccurrences(render, "_frameGraphs.Render("));
|
Assert.Equal(1, CountOccurrences(render, "_frameGraphs.Render("));
|
||||||
|
|
@ -432,30 +430,42 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"=> _cameraPointerInput?.HandleFocusChanged(focused);",
|
"=> _cameraPointerInput?.HandleFocusChanged(focused);",
|
||||||
focus,
|
focus,
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"private void OnClosing() => CompleteShutdown(releaseNativeWindow: false);",
|
||||||
|
close,
|
||||||
|
StringComparison.Ordinal);
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
close,
|
close,
|
||||||
"_hostQuiescence.StopAccepting();",
|
"private void CompleteShutdown(bool releaseNativeWindow)",
|
||||||
"CompleteShutdown();",
|
"if (!_lifetime.HasShutdownRoots)",
|
||||||
"_shutdown.CompleteOrThrow();");
|
"_lifetime.PublishShutdownRoots(CaptureShutdownRoots());",
|
||||||
|
"? _lifetime.CompleteAndReleaseNativeWindow()",
|
||||||
|
": _lifetime.TryComplete();");
|
||||||
|
Assert.DoesNotContain("new ResourceShutdownStage(", close, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("CompleteOrThrow", close, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Shutdown_PreservesDependencyStagesAndNativeWindowLast()
|
public void Shutdown_PreservesDependencyStagesAndNativeWindowLast()
|
||||||
{
|
{
|
||||||
string source = GameWindowSource();
|
string source = GameWindowSource();
|
||||||
|
string lifetime = GameWindowLifetimeSource();
|
||||||
string manifest = Slice(
|
string manifest = Slice(
|
||||||
source,
|
lifetime,
|
||||||
"private ResourceShutdownTransaction CreateShutdownTransaction()",
|
"internal static class GameWindowShutdownManifest",
|
||||||
"private void OnFocusChanged(bool focused)");
|
"private static ResourceShutdownOperation Hard");
|
||||||
|
string nativeRelease = Slice(
|
||||||
|
lifetime,
|
||||||
|
"public GameWindowLifetimeReport CompleteAndReleaseNativeWindow()",
|
||||||
|
"private void EnsureTransaction()");
|
||||||
int disposeStart = source.IndexOf("public void Dispose()", StringComparison.Ordinal);
|
int disposeStart = source.IndexOf("public void Dispose()", StringComparison.Ordinal);
|
||||||
Assert.True(disposeStart >= 0);
|
Assert.True(disposeStart >= 0);
|
||||||
string dispose = source[disposeStart..];
|
string dispose = source[disposeStart..];
|
||||||
|
|
||||||
string[] stages =
|
string[] stages =
|
||||||
[
|
[
|
||||||
"new ResourceShutdownStage(\"input callback deactivation\"",
|
"new ResourceShutdownStage(\"host and session barriers\"",
|
||||||
"new ResourceShutdownStage(\"session lifetime\"",
|
"new ResourceShutdownStage(\"physical ingress cleanup\"",
|
||||||
"new ResourceShutdownStage(\"input callback detach\"",
|
|
||||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||||
"new ResourceShutdownStage(\"session dependents\"",
|
"new ResourceShutdownStage(\"session dependents\"",
|
||||||
"new ResourceShutdownStage(\"live entities\"",
|
"new ResourceShutdownStage(\"live entities\"",
|
||||||
|
|
@ -470,7 +480,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"new ResourceShutdownStage(\"failed render construction cleanup\"",
|
"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 context\"",
|
||||||
"new ResourceShutdownStage(\"OpenGL context\"",
|
"new ResourceShutdownStage(\"OpenGL context\"",
|
||||||
];
|
];
|
||||||
AssertAppearsInOrder(manifest, stages);
|
AssertAppearsInOrder(manifest, stages);
|
||||||
|
|
@ -479,32 +489,38 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
Assert.Equal(1, CountOccurrences(manifest, stage));
|
Assert.Equal(1, CountOccurrences(manifest, stage));
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
manifest,
|
manifest,
|
||||||
"binding.Dispose();",
|
"Hard(\"combat command slot\", ingress.CombatCommands.Deactivate)",
|
||||||
"if (!binding.IsDisposalComplete)",
|
"Hard(\"diagnostic command slot\", ingress.DiagnosticCommands.Deactivate)",
|
||||||
"_windowCallbacks = null;");
|
"Hard(\"retained gameplay\", () => ingress.RetainedGameplay?.Deactivate())",
|
||||||
AssertAppearsInOrder(
|
"Hard(\"gameplay actions\", () => ingress.GameplayActions?.Deactivate())",
|
||||||
manifest,
|
"Hard(\"camera pointer\", () => ingress.CameraPointer?.Deactivate())",
|
||||||
"new(\"combat command slot\", _liveCombatModeCommands.Deactivate)",
|
"Hard(\"live session\", () => DisposeLiveSession(ingress.LiveSession))",
|
||||||
"new(\"diagnostic command slot\", _runtimeDiagnosticCommands.Deactivate)",
|
"new ResourceShutdownStage(\"physical ingress cleanup\"",
|
||||||
"new(\"retained gameplay\", () => _retainedUiGameplayBinding?.Deactivate())",
|
"Soft(\"retained gameplay\", () => DisposeRetainedGameplay(ingress.RetainedGameplay))",
|
||||||
"new(\"gameplay actions\", () => _gameplayInputActions?.Deactivate())",
|
"Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))",
|
||||||
"new(\"camera pointer\", () => _cameraPointerInput?.Deactivate())",
|
"Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))",
|
||||||
"new ResourceShutdownStage(\"session lifetime\"",
|
"Soft(\"native window callbacks\", () => DisposeWindowCallbacks(ingress.WindowCallbacks))",
|
||||||
"controller.Dispose();",
|
|
||||||
"new ResourceShutdownStage(\"input callback detach\"",
|
|
||||||
"binding.Dispose();",
|
|
||||||
"actions.Dispose();",
|
|
||||||
"pointer.Dispose();",
|
|
||||||
"new ResourceShutdownStage(\"session dependents\"",
|
"new ResourceShutdownStage(\"session dependents\"",
|
||||||
"pointer.ReleaseMouseLookAfterSessionRetirement();",
|
"Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())");
|
||||||
"_cameraPointerInput = null;");
|
Assert.Contains(
|
||||||
|
"new(name, action, ResourceShutdownOperationPolicy.ReportAndContinue)",
|
||||||
|
lifetime,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"UiHost? RetainedUiHost,",
|
||||||
|
lifetime,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains("_uiHost,", source, StringComparison.Ordinal);
|
||||||
|
AssertAppearsInOrder(
|
||||||
|
nativeRelease,
|
||||||
|
"TryComplete();",
|
||||||
|
"ReleaseNativeWindow();");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
dispose,
|
dispose,
|
||||||
"CompleteShutdown();",
|
"CompleteShutdown(releaseNativeWindow: true);",
|
||||||
"_window?.Dispose();",
|
|
||||||
"_window = null;");
|
"_window = null;");
|
||||||
Assert.Equal(1, CountOccurrences(dispose, "CompleteShutdown();"));
|
Assert.Equal(1, CountOccurrences(dispose, "CompleteShutdown(releaseNativeWindow: true);"));
|
||||||
Assert.Equal(1, CountOccurrences(dispose, "_window?.Dispose();"));
|
Assert.DoesNotContain("_window?.Dispose();", dispose, StringComparison.Ordinal);
|
||||||
Assert.Equal(1, CountOccurrences(dispose, "_window = null;"));
|
Assert.Equal(1, CountOccurrences(dispose, "_window = null;"));
|
||||||
int nativeReleased = dispose.IndexOf("_window = null;", StringComparison.Ordinal)
|
int nativeReleased = dispose.IndexOf("_window = null;", StringComparison.Ordinal)
|
||||||
+ "_window = null;".Length;
|
+ "_window = null;".Length;
|
||||||
|
|
@ -526,10 +542,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
string load = MethodBody(
|
string load = MethodBody(
|
||||||
"private void OnLoad()",
|
"private void OnLoad()",
|
||||||
"private void OnUpdate(double dt)");
|
"private void OnUpdate(double dt)");
|
||||||
string shutdown = Slice(
|
string shutdown = GameWindowLifetimeSource();
|
||||||
source,
|
|
||||||
"private ResourceShutdownTransaction CreateShutdownTransaction()",
|
|
||||||
"private void OnFocusChanged(bool focused)");
|
|
||||||
string terrainAtlas = File.ReadAllText(Path.Combine(
|
string terrainAtlas = File.ReadAllText(Path.Combine(
|
||||||
FindRepoRoot(),
|
FindRepoRoot(),
|
||||||
"src",
|
"src",
|
||||||
|
|
@ -586,17 +599,17 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"TerrainModernRenderer terrain = AcquireAndPublish(");
|
"TerrainModernRenderer terrain = AcquireAndPublish(");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
shutdown,
|
shutdown,
|
||||||
"publication.Dispose();",
|
"frame.FrameGraphPublication?.Dispose()",
|
||||||
"bindings.Dispose();",
|
"frame.FrameBindings?.Dispose()",
|
||||||
"_retailUiLease.Dispose();",
|
"DisposeRetailUi(live.RetailUi)",
|
||||||
"_localPlayerTeleport?.Dispose();",
|
"render.LocalTeleport?.Dispose();",
|
||||||
"_portalTunnelFallback.ReleaseFallback();",
|
"render.PortalTunnelFallback.ReleaseFallback();",
|
||||||
"_skyRenderer?.Dispose()",
|
"render.Sky?.Dispose()",
|
||||||
"_terrain?.Dispose();",
|
"render.Terrain?.Dispose()",
|
||||||
"_renderResourceLifetime.ReleaseSkyShader",
|
"render.DedicatedResources.ReleaseSkyShader",
|
||||||
"_renderResourceLifetime.ReleaseTerrainAtlas",
|
"render.DedicatedResources.ReleaseTerrainAtlas",
|
||||||
"_glConstructionCleanup.Dispose",
|
"render.ConstructionCleanup.Dispose",
|
||||||
"_gl?.Dispose();");
|
"platform.Gl?.Dispose()");
|
||||||
|
|
||||||
Assert.DoesNotContain(
|
Assert.DoesNotContain(
|
||||||
"RetailUiRuntime.Mount(",
|
"RetailUiRuntime.Mount(",
|
||||||
|
|
@ -614,7 +627,12 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
source,
|
source,
|
||||||
"_window.Run();",
|
"_window.Run();",
|
||||||
"_glConstructionCleanup.RetainFrom(failure);",
|
"_glConstructionCleanup.RetainFrom(failure);",
|
||||||
"_glConstructionCleanup.Dispose");
|
"private GameWindowShutdownRoots CaptureShutdownRoots()",
|
||||||
|
"_glConstructionCleanup)");
|
||||||
|
Assert.Contains(
|
||||||
|
"Hard(\"GL construction ledger\", render.ConstructionCleanup.Dispose)",
|
||||||
|
shutdown,
|
||||||
|
StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string MethodBody(string start, string end) =>
|
private static string MethodBody(string start, string end) =>
|
||||||
|
|
@ -661,6 +679,13 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"Rendering",
|
"Rendering",
|
||||||
"GameWindow.cs"));
|
"GameWindow.cs"));
|
||||||
|
|
||||||
|
private static string GameWindowLifetimeSource() => File.ReadAllText(Path.Combine(
|
||||||
|
FindRepoRoot(),
|
||||||
|
"src",
|
||||||
|
"AcDream.App",
|
||||||
|
"Rendering",
|
||||||
|
"GameWindowLifetime.cs"));
|
||||||
|
|
||||||
private static string LivePresentationSource() => File.ReadAllText(Path.Combine(
|
private static string LivePresentationSource() => File.ReadAllText(Path.Combine(
|
||||||
FindRepoRoot(),
|
FindRepoRoot(),
|
||||||
"src",
|
"src",
|
||||||
|
|
|
||||||
|
|
@ -159,4 +159,97 @@ public sealed class ResourceShutdownTransactionTests
|
||||||
Assert.Equal(1, calls);
|
Assert.Equal(1, calls);
|
||||||
Assert.True(transaction.IsComplete);
|
Assert.True(transaction.IsComplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReportableFailureRetriesThenAllowsDependentStagesAndIsStructured()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var failure = new InvalidOperationException("physical detach failed");
|
||||||
|
var transaction = new ResourceShutdownTransaction(
|
||||||
|
new ResourceShutdownStage("physical ingress",
|
||||||
|
[
|
||||||
|
new(
|
||||||
|
"mouse callback",
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
calls.Add("detach");
|
||||||
|
throw failure;
|
||||||
|
},
|
||||||
|
ResourceShutdownOperationPolicy.ReportAndContinue),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("dependent",
|
||||||
|
[
|
||||||
|
new("dependent", () => calls.Add("dependent")),
|
||||||
|
]));
|
||||||
|
|
||||||
|
transaction.CompleteOrThrow();
|
||||||
|
|
||||||
|
Assert.Equal(["detach", "detach", "dependent"], calls);
|
||||||
|
Assert.True(transaction.IsComplete);
|
||||||
|
ResourceShutdownCleanupFailure cleanup = Assert.Single(
|
||||||
|
transaction.CleanupFailures);
|
||||||
|
Assert.Equal("physical ingress", cleanup.Stage);
|
||||||
|
Assert.Equal("mouse callback", cleanup.Operation);
|
||||||
|
Assert.Same(failure, cleanup.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TransientReportableFailureConvergesWithoutCleanupFailure()
|
||||||
|
{
|
||||||
|
int calls = 0;
|
||||||
|
var transaction = new ResourceShutdownTransaction(
|
||||||
|
new ResourceShutdownStage("physical ingress",
|
||||||
|
[
|
||||||
|
new(
|
||||||
|
"keyboard callback",
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (calls++ == 0)
|
||||||
|
throw new InvalidOperationException("transient");
|
||||||
|
},
|
||||||
|
ResourceShutdownOperationPolicy.ReportAndContinue),
|
||||||
|
]));
|
||||||
|
|
||||||
|
transaction.CompleteOrThrow();
|
||||||
|
|
||||||
|
Assert.Equal(2, calls);
|
||||||
|
Assert.Empty(transaction.CleanupFailures);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettledReportableFailureNeverReplaysWhileHardBarrierRetries()
|
||||||
|
{
|
||||||
|
int softCalls = 0;
|
||||||
|
int hardCalls = 0;
|
||||||
|
bool allowHard = false;
|
||||||
|
var transaction = new ResourceShutdownTransaction(
|
||||||
|
new ResourceShutdownStage("mixed",
|
||||||
|
[
|
||||||
|
new(
|
||||||
|
"soft",
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
softCalls++;
|
||||||
|
throw new InvalidOperationException("soft");
|
||||||
|
},
|
||||||
|
ResourceShutdownOperationPolicy.ReportAndContinue),
|
||||||
|
new("hard", () =>
|
||||||
|
{
|
||||||
|
hardCalls++;
|
||||||
|
if (!allowHard)
|
||||||
|
throw new InvalidOperationException("hard");
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
|
||||||
|
Assert.Throws<AggregateException>(transaction.CompleteOrThrow);
|
||||||
|
int settledSoftCalls = softCalls;
|
||||||
|
allowHard = true;
|
||||||
|
|
||||||
|
transaction.CompleteOrThrow();
|
||||||
|
|
||||||
|
Assert.Equal(settledSoftCalls, softCalls);
|
||||||
|
Assert.True(hardCalls > 1);
|
||||||
|
Assert.True(transaction.IsComplete);
|
||||||
|
Assert.Single(transaction.CleanupFailures);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,12 @@ public sealed class WorldRenderFrameBuilderTests
|
||||||
public void World_scene_uses_the_typed_builder_and_orchestrator_owns_its_local_composition()
|
public void World_scene_uses_the_typed_builder_and_orchestrator_owns_its_local_composition()
|
||||||
{
|
{
|
||||||
string gameWindow = GameWindowSource();
|
string gameWindow = GameWindowSource();
|
||||||
|
string lifetime = File.ReadAllText(Path.Combine(
|
||||||
|
FindRepoRoot(),
|
||||||
|
"src",
|
||||||
|
"AcDream.App",
|
||||||
|
"Rendering",
|
||||||
|
"GameWindowLifetime.cs"));
|
||||||
string frameRoot = File.ReadAllText(Path.Combine(
|
string frameRoot = File.ReadAllText(Path.Combine(
|
||||||
FindRepoRoot(),
|
FindRepoRoot(),
|
||||||
"src",
|
"src",
|
||||||
|
|
@ -369,14 +375,14 @@ public sealed class WorldRenderFrameBuilderTests
|
||||||
"new RenderFrameOrchestrator(",
|
"new RenderFrameOrchestrator(",
|
||||||
"d.FrameGraphs.PublishOwned(");
|
"d.FrameGraphs.PublishOwned(");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
gameWindow,
|
lifetime,
|
||||||
"publication.Dispose();",
|
"frame.FrameGraphPublication?.Dispose()",
|
||||||
"new(\"equipped children\"",
|
"Hard(\"equipped children\"",
|
||||||
"new(\"effect network state\"",
|
"Hard(\"effect network state\"",
|
||||||
"new(\"audio\"",
|
"Hard(\"audio\"",
|
||||||
"new(\"mesh draw dispatcher\"",
|
"Hard(\"mesh draw dispatcher\"",
|
||||||
"new(\"environment cells\"",
|
"Hard(\"environment cells\"",
|
||||||
"new(\"scene lighting\"");
|
"Hard(\"scene lighting\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuilderSource() => File.ReadAllText(Path.Combine(
|
private static string BuilderSource() => File.ReadAllText(Path.Combine(
|
||||||
|
|
|
||||||
|
|
@ -356,23 +356,39 @@ public sealed class LandblockBuildOriginTests
|
||||||
"src",
|
"src",
|
||||||
"AcDream.App",
|
"AcDream.App",
|
||||||
"Rendering",
|
"Rendering",
|
||||||
"GameWindow.cs"));
|
"GameWindowLifetime.cs"));
|
||||||
int sessionStage = source.IndexOf(
|
int sessionStage = source.IndexOf(
|
||||||
"new ResourceShutdownStage(\"session lifetime\"",
|
"new ResourceShutdownStage(\"host and session barriers\"",
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
Assert.True(sessionStage >= 0);
|
Assert.True(sessionStage >= 0);
|
||||||
|
|
||||||
int sessionOperation = source.IndexOf(
|
int sessionOperation = source.IndexOf(
|
||||||
"new(\"live session\", () =>",
|
"Hard(\"live session\", () => DisposeLiveSession(ingress.LiveSession))",
|
||||||
sessionStage,
|
sessionStage,
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
Assert.True(sessionOperation > sessionStage);
|
Assert.True(sessionOperation > sessionStage);
|
||||||
|
|
||||||
int sessionDispose = source.IndexOf(
|
int dependentStage = source.IndexOf(
|
||||||
"controller.Dispose()",
|
"new ResourceShutdownStage(\"session dependents\"",
|
||||||
sessionOperation,
|
sessionOperation,
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
Assert.True(sessionDispose > sessionOperation);
|
Assert.True(dependentStage > sessionOperation);
|
||||||
|
|
||||||
|
int streamerDispose = source.IndexOf(
|
||||||
|
"Hard(\"streamer\", () => live.Streamer?.Dispose())",
|
||||||
|
dependentStage,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.True(streamerDispose > dependentStage);
|
||||||
|
|
||||||
|
int helper = source.IndexOf(
|
||||||
|
"private static void DisposeLiveSession(LiveSessionController? controller)",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.True(helper > streamerDispose);
|
||||||
|
int sessionDispose = source.IndexOf(
|
||||||
|
"controller.Dispose()",
|
||||||
|
helper,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.True(sessionDispose > helper);
|
||||||
|
|
||||||
int disposalCompletionBarrier = source.IndexOf(
|
int disposalCompletionBarrier = source.IndexOf(
|
||||||
"if (!controller.IsDisposalComplete)",
|
"if (!controller.IsDisposalComplete)",
|
||||||
|
|
@ -380,24 +396,10 @@ public sealed class LandblockBuildOriginTests
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
Assert.True(disposalCompletionBarrier > sessionDispose);
|
Assert.True(disposalCompletionBarrier > sessionDispose);
|
||||||
|
|
||||||
int sessionReferenceRelease = source.IndexOf(
|
Assert.Contains(
|
||||||
"_liveSessionController = null;",
|
"Live-session disposal was deferred by a reentrant lifecycle callback.",
|
||||||
disposalCompletionBarrier,
|
source[disposalCompletionBarrier..],
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
Assert.True(sessionReferenceRelease > disposalCompletionBarrier);
|
|
||||||
|
|
||||||
int dependentStage = source.IndexOf(
|
|
||||||
"new ResourceShutdownStage(\"session dependents\"",
|
|
||||||
sessionReferenceRelease,
|
|
||||||
StringComparison.Ordinal);
|
|
||||||
Assert.True(dependentStage > sessionReferenceRelease);
|
|
||||||
|
|
||||||
int streamerDispose = source.IndexOf(
|
|
||||||
"new(\"streamer\", () => _streamer?.Dispose())",
|
|
||||||
dependentStage,
|
|
||||||
StringComparison.Ordinal);
|
|
||||||
|
|
||||||
Assert.True(streamerDispose > dependentStage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LandblockBuild EmptyBuild(uint landblockId, LandblockBuildOrigin origin) =>
|
private static LandblockBuild EmptyBuild(uint landblockId, LandblockBuildOrigin origin) =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue