Flips the last remote classification (SetPosition: teleport-advanced and
cell-less) onto 4b-1's RuntimeRemotePlacementDriveController, runs retail's
teleport_hook before the placement, and deletes the legacy remote-teleport
machinery. Contract: docs/research/2026-08-04-c4-route-4b-3-contract.md.
Retail: MoveOrTeleport @0x00516330's branch @0x00516386 -> teleport_hook
@0x005163EF -> SetFlags(0x1012) @0x00516414 -> SetPosition @0x00516420 ->
return 1 @0x00516438. The hook @0x00514ED0 runs BEFORE the placement and
regardless of its outcome. Retail places this branch unconditionally, at any
distance and any contact state (arg4 is read only @0x0051638E, after the
branch) — which is what retires AP-137's cell-less enqueue-vs-place delta.
D1 — the classifier's cell-less input is now the PRE-merge committed cell.
Retail's predicate is `this_1->cell == 0`, the BODY's own cell at
MoveOrTeleport entry (this_1 is assigned from this @0x00516334). acdream fed
the POST-merge canonical.FullCellId, which RefreshSnapshot ->
RefreshDerivedState -> SetFullCell has already stamped with the accepted wire
cell; a zero wire cell fails validation into RejectedData first. The shipped
remote cell-less predicate was therefore dead code, not merely different from
remotePlacementRequired. Threaded via a builder overload; route 1's overload
is untouched. The graphical !IsSpatiallyVisible arm of
projectionRequiresTeleportHook is deleted — a presentation predicate with no
retail analogue that fired the teleport machinery on a routine hot path.
Deleted: RemoteTeleportController (605), RemoteTeleportPlacement (85),
RemoteShadowPlacementSynchronizer (49), their 1,709 lines of tests, the
remotePlacementRequired predicate, the TeleportHookRequired plumbing, the
legacy pre-operation ConstrainTo fallback, and the player arm's legacy
!IsGrounded fallback. Net -2,030 lines.
Structural fix (two independent Opus reviews, round 1 FAIL/FAIL): three of the
four MAJORs were one defect — OnPosition carried two parallel inline copies of
the routing tail (player-guid, NPC-guid) that had drifted. Extracted
RunRemoteArmTail (3 call sites) and ApplyWireAirborneLeftoverBookkeeping (2),
both branches now share one implementation.
A1 ToConstraintArm mapped AirborneSnap -> AirborneNoOperation, so the NPC
arm armed ConstrainTo ZERO times for an out-of-contact wire-grounded
creature — a regression this slice introduced while closing a
structurally identical hole. Now maps to NearInterpolate; switch made
total with a throwing default proven unreachable.
R1 D2's write-nothing shape existed on the player arm only; NPC packets
fell through and wrote the body. Retail makes no player/NPC distinction.
R2 report_collision_end(this,1) @0x00514F31 was bound to
ShadowObjects.Suspend, a port of a DIFFERENT retail function
(remove_shadows_from_cells) that teleport_hook never calls. Now routes
to RuntimeCollisionReportingState.LeaveWorld, which wraps the private
ForceEnd in an admission-blocking transaction so a DoCollisionEnd
callback cannot recreate the contact table.
R3/A2 A teleported NPC synthesized ServerVelocity from the teleport distance
(~1,000+ m/s) and planned a run cycle from it. Both the install and
RemoteServerControlledVelocityCycle.Apply now gate on !isTeleportRoute.
BISECT HAZARD — A1's fix is correct only BECAUSE R1 landed. AirborneSnap is
reachable wire-airborne on the NPC arm only while D2's shape is missing there.
Reverting R1 alone silently inverts A1 into the opposite divergence: arming
where retail returns 0. Revert both or neither.
Also in the velocity hunk: the NPC block's two !IsPlayerGuid(update.Guid)
guards were dropped when it was wrapped in `if (!isTeleportRoute)`. Safe — all
five exit paths of the enclosing IsPlayerGuid block return, so the predicate is
unconditionally false below it — but it was unremarked by both reviews.
Register: AP-137 REWRITTEN (not deleted) to the surviving acdream-only
divergences — null classification during the login window and Rejected*
through UnroutedCatchUp keep a row. AD-42's RemoteTeleportController citation
retired; AP-136/AP-138 writer lists corrected to the two surviving non-Position
rebucket writers; AP-138 gains the teleport arm as a second producer of the
visible-without-collision residual (retirement path remains #309). AP-135 is
untouched and its two airborne bookkeeping writes are preserved on both arms.
AP-131 does not retire; #276 does not close.
Proof obligation 1: ParkCollisionResidents' overlap throw stays unreachable —
the teleport arm adds packets to the same TryBeginExclusiveAuthoredPlacement
one-operation-per-key machinery the far arm uses, opens no new operation shape,
and every DeferredCell outcome cancels synchronously with
restoreCancelledPark: true. The guarded property remains
HasOldPrefixPlacementDebt's stall, not a throw (4b-1's B2 caveat stands).
Correction to an earlier claim: LiveEntityPresentationController's
_activePlacementOwners was NOT write-never at HEAD —
remotePlacementRequired -> BeginPlacement -> Begin -> BeginAuthoritativePlacement
was a live writer chain. It becomes write-never BECAUSE this slice deletes that
chain, which is why deleting the dead half is behaviour-preserving.
Probe: ACDREAM_PROBE_REMOTE_TELEPORT=1 emits one [remote-teleport] line per
routed arm (guid, cause, hook-ran, placement status). TEMPORARY, strip with the
probe family.
Carried, disclosed not fixed: no dedicated bidirectional collision-partner test
for R2 (the wiring, not LeaveWorld itself, is what lacks coverage); the
stress test's teleport step drives hand-written field assignments rather than
the canonical arm; the per-packet runTeleportHook closure allocation (network
path, not the resolve path Slice I's 0 B discipline governs — file before
route 5 adds a fourth call site). B2: IRuntimeCollisionReportObserver has zero
production implementations, so retail's bidirectional DoCollisionEnd half still
reaches no gameplay consumer — this fix closes the wrong-function binding, not
that nobody listens.
Complete Release suite MEASURED at 11,013 passed / 4 skipped / 0 failed
(baseline 11,027/4/0; net -14 = ~33 deleted test cases against ~19 added).
Neither known flake fired (#302 PortalProjectionTests GC-allocation, #308
NakEmissionTests wall-clock).
STILL OWED: the two-client connected gate, which MUST use an NPC/creature
teleport target. Both round-1 MAJORs lived on the NPC arm and the velocity
cycle early-returns for 0x50xxxxxx guids, so a player target structurally
cannot observe A1, A2, or R3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
588 lines
23 KiB
C#
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.Gpu;
|
|
using AcDream.App.Rendering.Sky;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Rendering.Vfx;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Settings;
|
|
using AcDream.App.Spells;
|
|
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.Runtime;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Session;
|
|
using AcDream.UI.Abstractions.Input;
|
|
using DatReaderWriter;
|
|
using Silk.NET.Input;
|
|
|
|
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,
|
|
GameRuntime Runtime,
|
|
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,
|
|
MagicRuntime? Magic,
|
|
ItemInteractionController? ItemInteraction,
|
|
ExternalContainerLifecycleController? ExternalContainers,
|
|
LandblockStreamer? Streamer,
|
|
EquippedChildRenderController? EquippedChildren,
|
|
LiveEntityRuntime? LiveEntities,
|
|
GameRuntime Runtime,
|
|
IDisposable RuntimeHostLease,
|
|
RenderSceneShadowRuntime? RenderSceneShadow,
|
|
LivePresentationRuntimeBindings? PresentationBindings,
|
|
DeferredEntityEffectAdvanceSource EffectAdvance,
|
|
EntityEffectController? EntityEffects,
|
|
AnimationHookRegistrationSet? HookRegistrations,
|
|
LiveEntityLightController? LiveLights,
|
|
LiveEntityPresentationController? LivePresentation,
|
|
AnimationHookFrameQueue? AnimationHookFrames,
|
|
EntityEffectPoseRegistry EffectPoses,
|
|
OpenAlAudioEngine? Audio);
|
|
|
|
internal sealed record RenderShutdownRoots(
|
|
GpuFrameFlightController? FrameFlights,
|
|
IGpuDevice? GpuDevice,
|
|
LocalPlayerTeleportController? LocalTeleport,
|
|
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
|
PaperdollViewportRenderer? Paperdoll,
|
|
CreatureAppraisalViewportRenderer? CreatureAppraisal,
|
|
WbDrawDispatcher? DrawDispatcher,
|
|
EnvCellRenderer? EnvironmentCells,
|
|
PortalDepthMaskRenderer? PortalDepthMask,
|
|
ClipFrame? ClipFrame,
|
|
SkyRenderer? Sky,
|
|
ParticleRenderer? Particles,
|
|
TextureCache? Textures,
|
|
WbMeshAdapter? MeshAdapter,
|
|
TerrainModernRenderer? Terrain,
|
|
SceneLightingUboBinding? SceneLighting,
|
|
DebugLineRenderer? DebugLines,
|
|
TextRenderer? TextRenderer,
|
|
BitmapFont? DebugFont,
|
|
DisplayFramePacingController FramePacing,
|
|
FrameProfiler FrameProfiler,
|
|
GameRenderResourceLifetime DedicatedResources,
|
|
ResourceConstructionCleanupLedger ConstructionCleanup);
|
|
|
|
internal sealed record PlatformShutdownRoots(
|
|
IDatReaderWriter? Dats,
|
|
IPreparedAssetSource? PreparedAssets,
|
|
IInputContext? Input,
|
|
GameWindowGraphics? Graphics);
|
|
|
|
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("game runtime session", ingress.Runtime.StopSession),
|
|
]),
|
|
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("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("magic runtime", () => live.Magic?.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()),
|
|
Hard(
|
|
"shadow render scene",
|
|
() => live.RenderSceneShadow?.Dispose()),
|
|
]),
|
|
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("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("portal tunnel", () =>
|
|
{
|
|
render.LocalTeleport?.Dispose();
|
|
render.PortalTunnelFallback.ReleaseFallback();
|
|
}),
|
|
Hard("paperdoll viewport", () => render.Paperdoll?.Dispose()),
|
|
Hard(
|
|
"creature appraisal viewport",
|
|
() => render.CreatureAppraisal?.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("game runtime root",
|
|
[
|
|
Hard("graphical runtime host lease", live.RuntimeHostLease.Dispose),
|
|
Hard("game runtime", () => DisposeGameRuntime(live.Runtime)),
|
|
]),
|
|
new ResourceShutdownStage("shared texture owners",
|
|
[
|
|
Hard("texture cache", () => render.Textures?.Dispose()),
|
|
]),
|
|
new ResourceShutdownStage("mesh adapter",
|
|
[
|
|
Hard("WB mesh adapter", () => render.MeshAdapter?.Dispose()),
|
|
]),
|
|
new ResourceShutdownStage("remaining render owners",
|
|
[
|
|
Hard("terrain", () => render.Terrain?.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),
|
|
Hard("GPU device (RHI)", () => render.GpuDevice?.Dispose()),
|
|
]),
|
|
new ResourceShutdownStage("dedicated render resources",
|
|
[
|
|
Hard("terrain atlas", render.DedicatedResources.ReleaseTerrainAtlas),
|
|
]),
|
|
new ResourceShutdownStage("failed render construction cleanup",
|
|
[
|
|
Hard("resource construction ledger", render.ConstructionCleanup.Dispose),
|
|
]),
|
|
new ResourceShutdownStage("frame flight owner",
|
|
[
|
|
Hard("frame flights", () => render.FrameFlights?.Dispose()),
|
|
]),
|
|
new ResourceShutdownStage("content mappings",
|
|
[
|
|
Hard("prepared asset source", () => platform.PreparedAssets?.Dispose()),
|
|
Hard("DAT collection", () => platform.Dats?.Dispose()),
|
|
]),
|
|
new ResourceShutdownStage("input context",
|
|
[
|
|
Hard("input context", () => platform.Input?.Dispose()),
|
|
]),
|
|
new ResourceShutdownStage("graphics API context",
|
|
[
|
|
Hard("graphics API", () => platform.Graphics?.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 DisposeGameRuntime(GameRuntime runtime)
|
|
{
|
|
runtime.Dispose();
|
|
GameRuntimeOwnershipSnapshot ownership = runtime.CaptureOwnership();
|
|
if (!ownership.IsConverged)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The canonical game runtime did not converge after disposal.");
|
|
}
|
|
}
|
|
|
|
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.");
|
|
}
|
|
|
|
}
|