diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md
index 61eccef9..b60e4e37 100644
--- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md
+++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md
@@ -16,7 +16,7 @@ audit.
- [x] A — freeze construction/callback/shutdown order and delete proven dead or
test-facade residue.
-- [ ] B — make native-window callback intake an explicit reversible owner and
+- [x] B — make native-window callback intake an explicit reversible owner and
define host-quiescence failure semantics.
- [ ] C — extract live-session host/reset/binding callbacks and verify the
embedded skill formula against named retail.
@@ -220,20 +220,31 @@ Add a focused reversible binding equivalent to:
```csharp
internal sealed class SilkWindowCallbackBinding : IDisposable
{
- public static SilkWindowCallbackBinding Attach(
+ public static SilkWindowCallbackBinding Create(
IWindow window,
WindowCallbackTargets targets,
DisplayFramePacingController pacing);
+
+ public void Attach();
}
```
`WindowCallbackTargets` is a fixed typed set for Load, Update, Render, Closing,
-FocusChanged, and FramebufferResize. It is not a general callback list. Attach
-rolls back in reverse order if any registration fails; Dispose detaches in
-reverse registration order, is idempotent/reentrant-safe, and makes later
-window events inert. The separate pacing Render callback remains ordered after
-main Render. Every callback first checks the shared no-throw host-quiescence
-gate, so a failed physical unsubscribe cannot re-enter a retired owner.
+FocusChanged, and FramebufferResize. It is not a general callback list. The
+binding is published into its lifetime slot after `Create` and before `Attach`
+begins, so even an attach failure plus rollback failure retains a cleanup owner.
+Attach treats the current edge as possibly acquired before calling a custom
+event add accessor and rolls back in reverse order if registration fails.
+The explicit lifecycle state monitor is released around every external event
+accessor; external cleanup waits for Attaching/Detaching to converge, while a
+gate-owned reentrant cleanup reports typed deferred completion instead of
+deadlocking or falsely succeeding. Dispose detaches in reverse registration
+order, joins concurrent callers, is idempotent/reentrant-safe, and makes later
+window events inert. `GameWindow` clears the retained slot only after terminal
+disposal. The separate pacing Render callback remains ordered after main Render.
+Every callback enters the shared no-throw host-quiescence monitor, so external
+shutdown drains an admitted callback, Closing can stop reentrantly, and a failed
+physical unsubscribe cannot re-enter a retired owner.
### 3.2 Input owners
@@ -416,6 +427,17 @@ tests and the complete App suite (2,991 pass / 3 intentional skips) pass.
- Add post-detach silence, failure-after-Nth-attach, repeated/reentrant Dispose,
callback-during-detach, and persistent physical-detach tests.
+Result: one fixed typed binding now owns the exact nine Silk edges, including
+the two ordered Render callbacks. Create/publish/Attach and the explicit
+lifecycle state preserve cleanup ownership across partial event-accessor
+failure, rollback failure, concurrent or reentrant shutdown, and condition
+wakeups. The shared host gate drains admitted callbacks and makes failed
+physical detach logically inert. Three corrected-diff review loops are clean;
+54 focused tests, the App suite (3,027 pass / 3 intentional skips), and the full
+Release suite (7,386 pass / 5 intentional skips) pass. No connected gate was
+required because this checkpoint changes ownership only and preserves the
+frozen callback behavior.
+
### C — live-session host and skill formula
- Extract selection/entry/reset/binding factories around the existing canonical
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index e94cbb6d..b1cefe90 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -22,7 +22,9 @@ public sealed class GameWindow : IDisposable
private readonly WorldGameState _worldGameState;
private readonly WorldEvents _worldEvents;
private readonly AcDream.Core.Selection.SelectionState _selection;
+ private readonly HostQuiescenceGate _hostQuiescence = new();
private IWindow? _window;
+ private SilkWindowCallbackBinding? _windowCallbacks;
private GL? _gl;
private IInputContext? _input;
private TerrainModernRenderer? _terrain;
@@ -667,23 +669,20 @@ public sealed class GameWindow : IDisposable
_window = Window.Create(options);
_displayFramePacing.BindSurface(
new SilkDisplayFramePacingSurface(_window));
- _window.Load += OnLoad;
- _window.Update += OnUpdate;
- _window.Render += OnRender;
- // Registered after OnRender so software pacing waits after all frame
- // work and before Silk.NET performs its automatic buffer swap.
- _window.Render += _displayFramePacing.OnFrameRendered;
- _window.Closing += OnClosing;
- _window.FocusChanged += OnFocusChanged;
- _window.Move += _displayFramePacing.OnWindowMoved;
- _window.StateChanged += _displayFramePacing.OnWindowStateChanged;
- // L.0 Display tab: keep the GL viewport + camera aspect in sync
- // with the window framebuffer. Without this handler, resizing
- // the window (or applying a Display-tab Resolution change at
- // startup) leaves the viewport pinned to the original size —
- // user sees a small render in the corner of a big window.
- _window.FramebufferResize += OnFramebufferResize;
-
+ // The fixed binding preserves main Render before post-render pacing,
+ // owns rollback/reverse-detach, and gates every native entry point.
+ _windowCallbacks = SilkWindowCallbackBinding.Create(
+ _window,
+ new WindowCallbackTargets(
+ OnLoad,
+ OnUpdate,
+ OnRender,
+ OnClosing,
+ OnFocusChanged,
+ OnFramebufferResize),
+ _displayFramePacing,
+ _hostQuiescence);
+ _windowCallbacks.Attach();
_window.Run();
}
@@ -4375,10 +4374,14 @@ public sealed class GameWindow : IDisposable
}
private void OnClosing()
- => CompleteShutdown();
+ {
+ _hostQuiescence.StopAccepting();
+ CompleteShutdown();
+ }
private void CompleteShutdown()
{
+ _hostQuiescence.StopAccepting();
_shutdown ??= CreateShutdownTransaction();
try
{
@@ -4574,6 +4577,18 @@ public sealed class GameWindow : IDisposable
]),
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("combat input subscription", ()
=> Combat.CombatModeChanged -= SetInputCombatScope),
new("input context", () =>
diff --git a/src/AcDream.App/Rendering/HostQuiescenceGate.cs b/src/AcDream.App/Rendering/HostQuiescenceGate.cs
new file mode 100644
index 00000000..8685e173
--- /dev/null
+++ b/src/AcDream.App/Rendering/HostQuiescenceGate.cs
@@ -0,0 +1,55 @@
+namespace AcDream.App.Rendering;
+
+///
+/// Single logical admission gate for callbacks entering the application host.
+/// Physical event removal remains important for ownership, but closing this
+/// gate first makes a still-attached native callback inert while teardown
+/// converges.
+///
+internal sealed class HostQuiescenceGate
+{
+ private readonly object _sync = new();
+ private bool _accepting = true;
+
+ public bool IsAccepting
+ {
+ get
+ {
+ lock (_sync)
+ return _accepting;
+ }
+ }
+
+ internal bool IsEnteredByCurrentThread => Monitor.IsEntered(_sync);
+
+ ///
+ /// Closes admission after any callback already executing on another
+ /// thread has returned. Monitor reentrancy lets the native Closing
+ /// callback close its own gate without waiting on itself.
+ ///
+ public void StopAccepting()
+ {
+ lock (_sync)
+ _accepting = false;
+ }
+
+ public void Invoke(Action callback)
+ {
+ ArgumentNullException.ThrowIfNull(callback);
+ lock (_sync)
+ {
+ if (_accepting)
+ callback();
+ }
+ }
+
+ public void Invoke(Action callback, T value)
+ {
+ ArgumentNullException.ThrowIfNull(callback);
+ lock (_sync)
+ {
+ if (_accepting)
+ callback(value);
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/SilkWindowCallbackBinding.cs b/src/AcDream.App/Rendering/SilkWindowCallbackBinding.cs
new file mode 100644
index 00000000..73081938
--- /dev/null
+++ b/src/AcDream.App/Rendering/SilkWindowCallbackBinding.cs
@@ -0,0 +1,413 @@
+using Silk.NET.Maths;
+using Silk.NET.Windowing;
+
+namespace AcDream.App.Rendering;
+
+internal sealed class NativeWindowCallbackCleanupDeferredException()
+ : InvalidOperationException(
+ "Native window callback cleanup was requested reentrantly and remains pending.");
+
+/// Fixed application callbacks accepted from the native window.
+internal sealed class WindowCallbackTargets
+{
+ public WindowCallbackTargets(
+ Action load,
+ Action update,
+ Action render,
+ Action closing,
+ Action focusChanged,
+ Action> framebufferResize)
+ {
+ Load = load ?? throw new ArgumentNullException(nameof(load));
+ Update = update ?? throw new ArgumentNullException(nameof(update));
+ Render = render ?? throw new ArgumentNullException(nameof(render));
+ Closing = closing ?? throw new ArgumentNullException(nameof(closing));
+ FocusChanged = focusChanged ?? throw new ArgumentNullException(nameof(focusChanged));
+ FramebufferResize = framebufferResize
+ ?? throw new ArgumentNullException(nameof(framebufferResize));
+ }
+
+ public Action Load { get; }
+ public Action Update { get; }
+ public Action Render { get; }
+ public Action Closing { get; }
+ public Action FocusChanged { get; }
+ public Action> FramebufferResize { get; }
+}
+
+///
+/// Narrow event surface used to test native callback acquisition without
+/// implementing Silk's complete contract.
+///
+internal interface IWindowCallbackSurface
+{
+ void AddLoad(Action callback);
+ void RemoveLoad(Action callback);
+ void AddUpdate(Action callback);
+ void RemoveUpdate(Action callback);
+ void AddRender(Action callback);
+ void RemoveRender(Action callback);
+ void AddClosing(Action callback);
+ void RemoveClosing(Action callback);
+ void AddFocusChanged(Action callback);
+ void RemoveFocusChanged(Action callback);
+ void AddMove(Action> callback);
+ void RemoveMove(Action> callback);
+ void AddStateChanged(Action callback);
+ void RemoveStateChanged(Action callback);
+ void AddFramebufferResize(Action> callback);
+ void RemoveFramebufferResize(Action> callback);
+}
+
+/// Production adapter over Silk's native event surface.
+internal sealed class SilkWindowCallbackSurface(IWindow window) : IWindowCallbackSurface
+{
+ private readonly IWindow _window = window ?? throw new ArgumentNullException(nameof(window));
+
+ public void AddLoad(Action callback) => _window.Load += callback;
+ public void RemoveLoad(Action callback) => _window.Load -= callback;
+ public void AddUpdate(Action callback) => _window.Update += callback;
+ public void RemoveUpdate(Action callback) => _window.Update -= callback;
+ public void AddRender(Action callback) => _window.Render += callback;
+ public void RemoveRender(Action callback) => _window.Render -= callback;
+ public void AddClosing(Action callback) => _window.Closing += callback;
+ public void RemoveClosing(Action callback) => _window.Closing -= callback;
+ public void AddFocusChanged(Action callback) => _window.FocusChanged += callback;
+ public void RemoveFocusChanged(Action callback) => _window.FocusChanged -= callback;
+ public void AddMove(Action> callback) => _window.Move += callback;
+ public void RemoveMove(Action> callback) => _window.Move -= callback;
+ public void AddStateChanged(Action callback) => _window.StateChanged += callback;
+ public void RemoveStateChanged(Action callback) => _window.StateChanged -= callback;
+ public void AddFramebufferResize(Action> callback) =>
+ _window.FramebufferResize += callback;
+ public void RemoveFramebufferResize(Action> callback) =>
+ _window.FramebufferResize -= callback;
+}
+
+///
+/// Owns the exact GameWindow-to-Silk callback edge. Registration is
+/// transactional, physical removal is retriable, and the shared logical gate
+/// is closed before any detach can re-enter a retiring owner.
+///
+internal sealed class SilkWindowCallbackBinding : IDisposable
+{
+ private enum LifecycleState
+ {
+ Created,
+ Attaching,
+ Attached,
+ AttachFailed,
+ Detaching,
+ DetachPending,
+ Detached,
+ }
+
+ private const int LoadIndex = 0;
+ private const int UpdateIndex = 1;
+ private const int MainRenderIndex = 2;
+ private const int PacingRenderIndex = 3;
+ private const int ClosingIndex = 4;
+ private const int FocusChangedIndex = 5;
+ private const int MoveIndex = 6;
+ private const int StateChangedIndex = 7;
+ private const int FramebufferResizeIndex = 8;
+ private const int AttachmentCount = 9;
+
+ private readonly IWindowCallbackSurface _surface;
+ private readonly HostQuiescenceGate _quiescence;
+ private readonly Action _load;
+ private readonly Action _update;
+ private readonly Action _mainRender;
+ private readonly Action _pacingRender;
+ private readonly Action _closing;
+ private readonly Action _focusChanged;
+ private readonly Action> _moved;
+ private readonly Action _stateChanged;
+ private readonly Action> _framebufferResize;
+ private readonly ResourceShutdownTransaction _detach;
+ private readonly bool[] _attached = new bool[AttachmentCount];
+ private readonly object _lifecycleSync = new();
+ private int _attachedCount;
+ private int _detachRequested;
+ private int _lifecycleOwnerThreadId;
+ private LifecycleState _lifecycleState = LifecycleState.Created;
+
+ private SilkWindowCallbackBinding(
+ IWindowCallbackSurface surface,
+ WindowCallbackTargets targets,
+ DisplayFramePacingController pacing,
+ HostQuiescenceGate quiescence)
+ {
+ _surface = surface ?? throw new ArgumentNullException(nameof(surface));
+ ArgumentNullException.ThrowIfNull(targets);
+ ArgumentNullException.ThrowIfNull(pacing);
+ _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
+
+ _load = () => _quiescence.Invoke(targets.Load);
+ _update = value => _quiescence.Invoke(targets.Update, value);
+ _mainRender = value => _quiescence.Invoke(targets.Render, value);
+ _pacingRender = value => _quiescence.Invoke(pacing.OnFrameRendered, value);
+ _closing = () => _quiescence.Invoke(targets.Closing);
+ _focusChanged = value => _quiescence.Invoke(targets.FocusChanged, value);
+ _moved = value => _quiescence.Invoke(pacing.OnWindowMoved, value);
+ _stateChanged = value => _quiescence.Invoke(pacing.OnWindowStateChanged, value);
+ _framebufferResize = value => _quiescence.Invoke(targets.FramebufferResize, value);
+
+ _detach = new ResourceShutdownTransaction(
+ new ResourceShutdownStage("native window callbacks",
+ [
+ new("framebuffer resize", () => Remove(FramebufferResizeIndex)),
+ new("window state", () => Remove(StateChangedIndex)),
+ new("window move", () => Remove(MoveIndex)),
+ new("focus changed", () => Remove(FocusChangedIndex)),
+ new("closing", () => Remove(ClosingIndex)),
+ new("pacing render", () => Remove(PacingRenderIndex)),
+ new("main render", () => Remove(MainRenderIndex)),
+ new("update", () => Remove(UpdateIndex)),
+ new("load", () => Remove(LoadIndex)),
+ ]));
+ }
+
+ public bool IsDetached
+ {
+ get
+ {
+ lock (_lifecycleSync)
+ return _attached.All(static attached => !attached);
+ }
+ }
+
+ public bool IsDisposalComplete
+ {
+ get
+ {
+ lock (_lifecycleSync)
+ return _lifecycleState == LifecycleState.Detached;
+ }
+ }
+
+ public static SilkWindowCallbackBinding Create(
+ IWindow window,
+ WindowCallbackTargets targets,
+ DisplayFramePacingController pacing,
+ HostQuiescenceGate quiescence) =>
+ Create(new SilkWindowCallbackSurface(window), targets, pacing, quiescence);
+
+ internal static SilkWindowCallbackBinding Create(
+ IWindowCallbackSurface surface,
+ WindowCallbackTargets targets,
+ DisplayFramePacingController pacing,
+ HostQuiescenceGate quiescence) =>
+ new(surface, targets, pacing, quiescence);
+
+ public void Attach()
+ {
+ int threadId = Environment.CurrentManagedThreadId;
+ lock (_lifecycleSync)
+ {
+ if (_lifecycleState != LifecycleState.Created)
+ {
+ throw _lifecycleState == LifecycleState.Detached
+ ? new ObjectDisposedException(nameof(SilkWindowCallbackBinding))
+ : new InvalidOperationException(
+ "Native window callback attachment has already started.");
+ }
+ if (Volatile.Read(ref _detachRequested) != 0 || _detach.IsComplete)
+ throw new ObjectDisposedException(nameof(SilkWindowCallbackBinding));
+
+ _lifecycleState = LifecycleState.Attaching;
+ _lifecycleOwnerThreadId = threadId;
+ }
+
+ try
+ {
+ Attach(LoadIndex);
+ Attach(UpdateIndex);
+ Attach(MainRenderIndex);
+ Attach(PacingRenderIndex);
+ Attach(ClosingIndex);
+ Attach(FocusChangedIndex);
+ Attach(MoveIndex);
+ Attach(StateChangedIndex);
+ Attach(FramebufferResizeIndex);
+
+ lock (_lifecycleSync)
+ {
+ if (Volatile.Read(ref _detachRequested) != 0)
+ {
+ throw new ObjectDisposedException(
+ nameof(SilkWindowCallbackBinding),
+ "Shutdown was requested while native callbacks were attaching.");
+ }
+
+ _lifecycleState = LifecycleState.Attached;
+ _lifecycleOwnerThreadId = 0;
+ System.Threading.Monitor.PulseAll(_lifecycleSync);
+ }
+ }
+ catch (Exception attachError)
+ {
+ Interlocked.Exchange(ref _detachRequested, 1);
+ _quiescence.StopAccepting();
+ List? rollbackErrors = null;
+ for (int index = _attachedCount - 1; index >= 0; index--)
+ {
+ try
+ {
+ Remove(index);
+ }
+ catch (Exception rollbackError)
+ {
+ (rollbackErrors ??= []).Add(new InvalidOperationException(
+ $"Failed to roll back native window callback index {index}.",
+ rollbackError));
+ }
+ }
+
+ lock (_lifecycleSync)
+ {
+ _lifecycleState = LifecycleState.AttachFailed;
+ _lifecycleOwnerThreadId = 0;
+ System.Threading.Monitor.PulseAll(_lifecycleSync);
+ }
+
+ if (rollbackErrors is not null)
+ {
+ rollbackErrors.Insert(0, new InvalidOperationException(
+ "Native window callback registration failed.", attachError));
+ throw new AggregateException(
+ "Native window callback registration and rollback both failed.",
+ rollbackErrors);
+ }
+
+ throw new InvalidOperationException(
+ "Native window callback registration failed and was rolled back.",
+ attachError);
+ }
+ }
+
+ private void Attach(int index)
+ {
+ // A custom event add accessor is allowed to perform its side effect
+ // before throwing. Treat the edge as possibly acquired until a
+ // matching remove succeeds; removing an unattached delegate is safe.
+ lock (_lifecycleSync)
+ {
+ _attached[index] = true;
+ _attachedCount = Math.Max(_attachedCount, index + 1);
+ }
+ switch (index)
+ {
+ case LoadIndex: _surface.AddLoad(_load); break;
+ case UpdateIndex: _surface.AddUpdate(_update); break;
+ case MainRenderIndex: _surface.AddRender(_mainRender); break;
+ case PacingRenderIndex: _surface.AddRender(_pacingRender); break;
+ case ClosingIndex: _surface.AddClosing(_closing); break;
+ case FocusChangedIndex: _surface.AddFocusChanged(_focusChanged); break;
+ case MoveIndex: _surface.AddMove(_moved); break;
+ case StateChangedIndex: _surface.AddStateChanged(_stateChanged); break;
+ case FramebufferResizeIndex:
+ _surface.AddFramebufferResize(_framebufferResize);
+ break;
+ default: throw new ArgumentOutOfRangeException(nameof(index));
+ }
+
+ if (Volatile.Read(ref _detachRequested) != 0)
+ {
+ throw new ObjectDisposedException(
+ nameof(SilkWindowCallbackBinding),
+ "Shutdown was requested while native callbacks were attaching.");
+ }
+ }
+
+ private void Remove(int index)
+ {
+ lock (_lifecycleSync)
+ {
+ if (index >= _attachedCount || !_attached[index])
+ return;
+ }
+
+ switch (index)
+ {
+ case LoadIndex: _surface.RemoveLoad(_load); break;
+ case UpdateIndex: _surface.RemoveUpdate(_update); break;
+ case MainRenderIndex: _surface.RemoveRender(_mainRender); break;
+ case PacingRenderIndex: _surface.RemoveRender(_pacingRender); break;
+ case ClosingIndex: _surface.RemoveClosing(_closing); break;
+ case FocusChangedIndex: _surface.RemoveFocusChanged(_focusChanged); break;
+ case MoveIndex: _surface.RemoveMove(_moved); break;
+ case StateChangedIndex: _surface.RemoveStateChanged(_stateChanged); break;
+ case FramebufferResizeIndex:
+ _surface.RemoveFramebufferResize(_framebufferResize);
+ break;
+ default: throw new ArgumentOutOfRangeException(nameof(index));
+ }
+
+ lock (_lifecycleSync)
+ _attached[index] = false;
+ }
+
+ public void Dispose()
+ {
+ Interlocked.Exchange(ref _detachRequested, 1);
+ // Gate order is deliberate: never hold the physical-detach monitor
+ // while waiting for an in-flight native callback to finish. Closing
+ // itself holds the gate reentrantly and may enter this method.
+ _quiescence.StopAccepting();
+ int threadId = Environment.CurrentManagedThreadId;
+ bool ownsGate = _quiescence.IsEnteredByCurrentThread;
+ while (true)
+ {
+ lock (_lifecycleSync)
+ {
+ if (_lifecycleState == LifecycleState.Detached)
+ return;
+ if (_lifecycleState == LifecycleState.Attaching)
+ {
+ if (_lifecycleOwnerThreadId == threadId || ownsGate)
+ throw new NativeWindowCallbackCleanupDeferredException();
+
+ System.Threading.Monitor.Wait(_lifecycleSync);
+ continue;
+ }
+ if (_lifecycleState == LifecycleState.Detaching)
+ {
+ if (_lifecycleOwnerThreadId == threadId)
+ return;
+ if (ownsGate)
+ throw new NativeWindowCallbackCleanupDeferredException();
+
+ System.Threading.Monitor.Wait(_lifecycleSync);
+ continue;
+ }
+
+ _lifecycleState = LifecycleState.Detaching;
+ _lifecycleOwnerThreadId = threadId;
+ break;
+ }
+ }
+
+ try
+ {
+ _detach.CompleteOrThrow();
+ lock (_lifecycleSync)
+ {
+ _lifecycleState = LifecycleState.Detached;
+ _lifecycleOwnerThreadId = 0;
+ System.Threading.Monitor.PulseAll(_lifecycleSync);
+ }
+ }
+ catch
+ {
+ lock (_lifecycleSync)
+ {
+ _lifecycleState = LifecycleState.DetachPending;
+ _lifecycleOwnerThreadId = 0;
+ System.Threading.Monitor.PulseAll(_lifecycleSync);
+ }
+
+ throw;
+ }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs
index f466e66b..e2f0da7d 100644
--- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs
@@ -22,31 +22,25 @@ public sealed class GameWindowSlice8BoundaryTests
"Samples = startupQuality.MsaaSamples",
"Window.Create(options)",
"_displayFramePacing.BindSurface(",
- "_window.Load += OnLoad;",
- "_window.Update += OnUpdate;",
- "_window.Render += OnRender;",
- "_window.Render += _displayFramePacing.OnFrameRendered;",
- "_window.Closing += OnClosing;",
- "_window.FocusChanged += OnFocusChanged;",
- "_window.Move += _displayFramePacing.OnWindowMoved;",
- "_window.StateChanged += _displayFramePacing.OnWindowStateChanged;",
- "_window.FramebufferResize += OnFramebufferResize;",
+ "_windowCallbacks = SilkWindowCallbackBinding.Create(",
+ "new WindowCallbackTargets(",
+ "OnLoad,",
+ "OnUpdate,",
+ "OnRender,",
+ "OnClosing,",
+ "OnFocusChanged,",
+ "OnFramebufferResize),",
+ "_displayFramePacing,",
+ "_hostQuiescence);",
+ "_windowCallbacks.Attach();",
"_window.Run();");
- string[] registrations =
- [
- "_window.Load += OnLoad;",
- "_window.Update += OnUpdate;",
- "_window.Render += OnRender;",
- "_window.Render += _displayFramePacing.OnFrameRendered;",
- "_window.Closing += OnClosing;",
- "_window.FocusChanged += OnFocusChanged;",
- "_window.Move += _displayFramePacing.OnWindowMoved;",
- "_window.StateChanged += _displayFramePacing.OnWindowStateChanged;",
- "_window.FramebufferResize += OnFramebufferResize;",
- ];
- foreach (string registration in registrations)
- Assert.Equal(1, CountOccurrences(body, registration));
+ Assert.Equal(1, CountOccurrences(body, "SilkWindowCallbackBinding.Create("));
+ Assert.Equal(1, CountOccurrences(body, "_windowCallbacks.Attach();"));
+ Assert.DoesNotContain("_window.Load +=", body, StringComparison.Ordinal);
+ Assert.DoesNotContain("_window.Update +=", body, StringComparison.Ordinal);
+ Assert.DoesNotContain("_window.Render +=", body, StringComparison.Ordinal);
+ Assert.DoesNotContain("_window.Closing +=", body, StringComparison.Ordinal);
}
[Fact]
@@ -160,7 +154,11 @@ public sealed class GameWindowSlice8BoundaryTests
"new AcDream.App.Rendering.RenderFrameInput(");
Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal);
AssertAppearsInOrder(focus, "if (!focused)", "_gameplayInputFrame?.EndMouseLook();");
- AssertAppearsInOrder(close, "=> CompleteShutdown();", "_shutdown.CompleteOrThrow();");
+ AssertAppearsInOrder(
+ close,
+ "_hostQuiescence.StopAccepting();",
+ "CompleteShutdown();",
+ "_shutdown.CompleteOrThrow();");
}
[Fact]
@@ -197,6 +195,11 @@ public sealed class GameWindowSlice8BoundaryTests
Assert.Equal(stages.Length, CountOccurrences(manifest, "new ResourceShutdownStage("));
foreach (string stage in stages)
Assert.Equal(1, CountOccurrences(manifest, stage));
+ AssertAppearsInOrder(
+ manifest,
+ "binding.Dispose();",
+ "if (!binding.IsDisposalComplete)",
+ "_windowCallbacks = null;");
AssertAppearsInOrder(
dispose,
"CompleteShutdown();",
diff --git a/tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs b/tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs
new file mode 100644
index 00000000..04602aa3
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs
@@ -0,0 +1,57 @@
+using AcDream.App.Rendering;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class HostQuiescenceGateTests
+{
+ [Fact]
+ public async Task ExternalStopWaitsForAdmittedCallbackToReturn()
+ {
+ var gate = new HostQuiescenceGate();
+ using var callbackEntered = new ManualResetEventSlim(false);
+ using var releaseCallback = new ManualResetEventSlim(false);
+ int calls = 0;
+ Task callback = Task.Run(() => gate.Invoke(() =>
+ {
+ callbackEntered.Set();
+ Assert.True(releaseCallback.Wait(TimeSpan.FromSeconds(5)));
+ calls++;
+ }));
+ Assert.True(callbackEntered.Wait(TimeSpan.FromSeconds(5)));
+
+ using var stopStarted = new ManualResetEventSlim(false);
+ Task stop = Task.Run(() =>
+ {
+ stopStarted.Set();
+ gate.StopAccepting();
+ });
+ Assert.True(stopStarted.Wait(TimeSpan.FromSeconds(5)));
+ await Task.Delay(50);
+ Assert.False(stop.IsCompleted);
+
+ releaseCallback.Set();
+ await callback;
+ await stop;
+ gate.Invoke(() => calls++);
+
+ Assert.Equal(1, calls);
+ Assert.False(gate.IsAccepting);
+ }
+
+ [Fact]
+ public void CallbackCanStopGateReentrantlyWithoutDeadlock()
+ {
+ var gate = new HostQuiescenceGate();
+ int calls = 0;
+
+ gate.Invoke(() =>
+ {
+ calls++;
+ gate.StopAccepting();
+ });
+ gate.Invoke(() => calls++);
+
+ Assert.Equal(1, calls);
+ Assert.False(gate.IsAccepting);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs b/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs
new file mode 100644
index 00000000..f7480830
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs
@@ -0,0 +1,801 @@
+using AcDream.App.Diagnostics;
+using AcDream.App.Rendering;
+using Silk.NET.Maths;
+using Silk.NET.Windowing;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class SilkWindowCallbackBindingTests
+{
+ [Fact]
+ public void AttachAndDisposeUseExactForwardAndReverseOrder()
+ {
+ using var profiler = new FrameProfiler();
+ using var pacing = CreatePacing(profiler);
+ var surface = new FakeWindowCallbackSurface();
+ var gate = new HostQuiescenceGate();
+
+ using SilkWindowCallbackBinding binding = CreateAttachedBinding(
+ surface,
+ Targets(gate),
+ pacing,
+ gate);
+
+ Assert.Equal(
+ [
+ "add load",
+ "add update",
+ "add render main",
+ "add render pacing",
+ "add closing",
+ "add focus",
+ "add move",
+ "add state",
+ "add framebuffer",
+ ], surface.Operations);
+
+ surface.Operations.Clear();
+ binding.Dispose();
+
+ Assert.Equal(
+ [
+ "remove framebuffer",
+ "remove state",
+ "remove move",
+ "remove focus",
+ "remove closing",
+ "remove render pacing",
+ "remove render main",
+ "remove update",
+ "remove load",
+ ], surface.Operations);
+ Assert.True(binding.IsDetached);
+ Assert.False(gate.IsAccepting);
+ }
+
+ [Fact]
+ public void MainRenderIsRegisteredBeforePacingRender()
+ {
+ var order = new List();
+ var clock = new OrderingClock();
+ using var profiler = new FrameProfiler();
+ using var pacing = new DisplayFramePacingController(
+ uncappedRendering: false,
+ profiler,
+ new FramePacingController(clock, new OrderingWaiter(clock, order)));
+ pacing.InitializeStartup(requestedVSync: false);
+ var surface = new FakeWindowCallbackSurface();
+ var gate = new HostQuiescenceGate();
+ WindowCallbackTargets targets = Targets(
+ gate,
+ render: _ => order.Add("main render"));
+ using SilkWindowCallbackBinding binding = CreateAttachedBinding(
+ surface,
+ targets,
+ pacing,
+ gate);
+
+ Assert.Equal(2, surface.RenderCallbacks.Count);
+
+ surface.RaiseRender(1d / 60d);
+
+ Assert.Equal(["main render", "pacing render"], order);
+ }
+
+ public static IEnumerable