acdream/src/AcDream.App/Rendering/GameWindowLifetime.cs
Erik 31e6e192b3 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>
2026-07-22 19:43:52 +02:00

588 lines
23 KiB
C#

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.");
}
}