acdream/src/AcDream.App/Composition/FrameRootComposition.cs
Erik 7b456b49d6 perf(diag): per-frame history export + checkpoint LOH/cache counters + soak capped mode (2026-07-24 audit review)
An adversarial performance review found our own instruments cannot
measure the project's own performance gates:

- FrameProfiler aggregated CPU/GPU/alloc/stage samples into ~5-second
  windows and reset the ring buffers after each report, so route-wide
  p50/p95/p99 distributions across a whole soak could not be
  reconstructed after the fact. ACDREAM_FRAME_HISTORY=<path> now opts
  into a separate per-frame history (one record per frame, ~72
  bytes/record, accumulated in memory with zero frame-thread I/O) that
  a shutdown-only Dispose() writes as CSV. The aggregated [frame-prof]
  report format and its existing metrics are unchanged.

- The canonical checkpoint JSON tracked cache residency (entry/byte
  counts) but never LOH size/fragmentation, process-wide allocated
  bytes, or cache hit/miss/eviction traffic — a committed audit JSON
  showed 65% LOH fragmentation that no tracked instrument recorded,
  and "does a revisit portal hit or miss the caches" was unanswerable
  from an artifact alone. WorldLifecycleResourceSnapshot now carries
  loh_size_bytes/loh_fragmentation_bytes (GCMemoryInfo.GenerationInfo
  index 3), process_total_allocated_bytes (GC.GetTotalAllocatedBytes),
  and Interlocked hit/miss/eviction counters for the CPU mesh cache,
  decoded-texture cache, and the four bounded DAT-object caches
  (portal/cell/highRes/language, aggregated).

- run-connected-r6-soak.ps1 unconditionally forced
  ACDREAM_UNCAPPED_RENDER=1 with no capped mode, while its sibling
  lifecycle-gate script correctly gated it behind a switch. Added
  -Uncapped (default capped, matching the sibling script's pattern),
  fixed the stationary dwell (12s -> 26s, past the 25s
  LiveEntityLivenessController deadline the adjacent comment already
  cited), and now write an env-disclosure.json into the automation
  artifact directory before every launch listing every ACDREAM_* var
  the script sets plus -Uncapped, since the prior audit could only see
  ACDREAM_DUMP_MOVE_TRUTH and nothing else was ever recorded anywhere.

Cache counters are wired via the existing composition path
(ObjectMeshManager already owns the CPU mesh cache and the mesh
extractor directly; content.Dats is threaded into
WorldLifecycleResourceSnapshotSource the same way every other
composition consumer receives it). The DAT-object cache lives behind
IDatReaderWriter, a third-party interface from the DatReaderWriter
package that cannot be extended; RuntimeDatCollection (the one
production implementation) exposes the aggregate stats directly and a
pattern match reads them, degrading to zero for any test double —
no new static registry was introduced (GpuMemoryTracker remains the
one precedented process-wide static).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 1da2c33c875b41fa383dd79694ee2765f0e21896)
2026-07-24 12:00:30 +02:00

531 lines
19 KiB
C#

using AcDream.App.Diagnostics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Settings;
using AcDream.App.Update;
using AcDream.App.World;
using AcDream.Core.Combat;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
using Silk.NET.Input;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Composition;
internal sealed record FrameRootDependencies(
RuntimeOptions Options,
GL Gl,
IWindow Window,
IInputContext Input,
WorldTimeService WorldTime,
WeatherSystem Weather,
LightManager Lighting,
WorldEnvironmentController WorldEnvironment,
PhysicsEngine PhysicsEngine,
CellVisibility CellVisibility,
LocalPlayerModeState PlayerMode,
LocalPlayerIdentityState PlayerIdentity,
ChaseCameraInputState ChaseCameraInput,
LocalPlayerControllerSlot PlayerController,
LiveWorldOriginState WorldOrigin,
ParticleVisibilityController ParticleVisibility,
EntityEffectPoseRegistry EffectPoses,
WorldRenderRangeState RenderRange,
RuntimeSettingsController Settings,
DisplayFramePacingController DisplayFramePacing,
WorldSceneDebugState WorldSceneDebugState,
RetailAlphaQueue RetailAlphaQueue,
FrameProfiler FrameProfiler,
bool FrameDiagnosticsEnabled,
IRenderFrameDiagnosticLog RenderDiagnosticLog,
DebugVmRenderFactsPublisher DebugVmRenderFacts,
IInputCaptureSource InputCapture,
DispatcherCameraInputSource CameraInput,
SelectionState Selection,
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> Animations,
UpdateFrameClock UpdateClock,
CombatState Combat,
GameFrameGraphSlot FrameGraphs,
Action<string> Log);
internal sealed record FrameRootResult(
UpdateFrameOrchestrator Update,
RenderFrameOrchestrator Render,
FrameRootRuntimeBindings RuntimeBindings,
IDisposable FrameGraphPublication,
AcDream.App.Net.LiveSessionHost SessionHost);
internal interface IGameWindowFrameRootPublication
{
void PublishFrameRoots(FrameRootResult result);
}
internal enum FrameRootCompositionPoint
{
RenderResourcesCreated,
WorldRendererCreated,
LifecycleAutomationBound,
RenderRootCreated,
UpdateRootCreated,
FrameGraphPublished,
ResultPublished,
}
/// <summary>
/// Exact late edges created with the frame roots. Completed detach operations
/// are removed immediately so shutdown retry never replays them.
/// </summary>
internal sealed class FrameRootRuntimeBindings : IDisposable
{
private readonly List<(string Name, IDisposable Binding)> _bindings = [];
private bool _deactivationStarted;
public void Adopt(string name, IDisposable binding)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(binding);
ObjectDisposedException.ThrowIf(_deactivationStarted, this);
_bindings.Add((name, binding));
}
public void Dispose()
{
if (_deactivationStarted && _bindings.Count == 0)
return;
_deactivationStarted = true;
List<Exception>? failures = null;
for (int i = _bindings.Count - 1; i >= 0; i--)
{
(string name, IDisposable binding) = _bindings[i];
try
{
binding.Dispose();
_bindings.RemoveAt(i);
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
$"Frame-root binding '{name}' did not detach.",
failure));
}
}
if (failures is not null)
{
throw new AggregateException(
"Frame-root binding cleanup remains incomplete.",
failures);
}
}
}
internal sealed class FrameRootCompositionPhase
: IFrameRootCompositionPhase<
GameWindowPlatformResult<GL, IInputContext>,
HostInputCameraResult,
ContentEffectsAudioResult,
SettingsDevToolsResult,
WorldRenderResult,
InteractionRetainedUiResult,
LivePresentationResult,
SessionPlayerResult,
FrameRootResult>
{
private readonly FrameRootDependencies _dependencies;
private readonly IGameWindowFrameRootPublication _publication;
private readonly Action<FrameRootCompositionPoint>? _faultInjection;
public FrameRootCompositionPhase(
FrameRootDependencies dependencies,
IGameWindowFrameRootPublication publication,
Action<FrameRootCompositionPoint>? faultInjection = null)
{
_dependencies = dependencies
?? throw new ArgumentNullException(nameof(dependencies));
_publication = publication
?? throw new ArgumentNullException(nameof(publication));
_faultInjection = faultInjection;
}
public FrameRootResult Compose(
GameWindowPlatformResult<GL, IInputContext> platform,
HostInputCameraResult host,
ContentEffectsAudioResult content,
SettingsDevToolsResult settings,
WorldRenderResult world,
InteractionRetainedUiResult interaction,
LivePresentationResult live,
SessionPlayerResult session)
{
ArgumentNullException.ThrowIfNull(platform);
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(world);
ArgumentNullException.ThrowIfNull(interaction);
ArgumentNullException.ThrowIfNull(live);
ArgumentNullException.ThrowIfNull(session);
if (!ReferenceEquals(_dependencies.Gl, platform.Graphics)
|| !ReferenceEquals(_dependencies.Input, platform.Input))
{
throw new InvalidOperationException(
"Frame-root dependencies do not match the ordered platform result.");
}
var scope = new CompositionAcquisitionScope();
FrameRootRuntimeBindings? bindings = null;
bool bindingsOwnedByScope = false;
try
{
FrameRootResult result = ComposeCore(
host,
content,
settings,
world,
interaction,
live,
session,
scope,
ref bindings,
ref bindingsOwnedByScope);
scope.Complete();
return result;
}
catch (Exception failure)
{
if (bindings is not null && !bindingsOwnedByScope)
{
scope.Own(
"frame-root runtime bindings",
bindings,
static value => value.Dispose());
}
scope.RollbackAndThrow(failure);
throw new System.Diagnostics.UnreachableException();
}
}
private FrameRootResult ComposeCore(
HostInputCameraResult host,
ContentEffectsAudioResult content,
SettingsDevToolsResult settings,
WorldRenderResult world,
InteractionRetainedUiResult interaction,
LivePresentationResult live,
SessionPlayerResult session,
CompositionAcquisitionScope scope,
ref FrameRootRuntimeBindings? bindings,
ref bool bindingsOwnedByScope)
{
FrameRootDependencies d = _dependencies;
WorldRenderFoundation foundation = world.Foundation;
var teleportRenderState =
new LocalPlayerTeleportRenderStateSource(session.LocalTeleport);
var renderLoginState = new RenderLoginStateSource(
d.Options.LiveMode,
d.PlayerMode);
var renderFrameGlState = new RenderFrameGlStateController(
new SilkRenderFrameGlStateApi(d.Gl));
var renderFrameLivePreparation =
new RuntimeRenderFrameLivePreparation(
foundation.TextureCache,
foundation.MeshAdapter,
session.WorldReveal,
teleportRenderState,
renderLoginState,
new LiveLoginRevealCellSource(
live.LiveEntities,
d.PlayerIdentity),
live.ParticleRenderer,
d.FrameProfiler,
d.FrameDiagnosticsEnabled);
var renderFrameResources = new RenderFrameResourceController(
host.GpuFrameFlights,
new RuntimeRenderFrameBeginResources(
foundation.TextureCache,
live.DrawDispatcher,
live.EnvCellRenderer,
live.PortalDepthMask,
foundation.TextRenderer,
interaction.RetainedUi?.Host.TextRenderer,
live.ClipFrame,
foundation.Terrain,
foundation.SceneLighting,
d.FrameProfiler,
d.Gl),
new RuntimeRenderFrameClearPhase(
d.Gl,
d.WorldTime,
d.Weather,
teleportRenderState,
d.ParticleVisibility,
host.WorldRenderDiagnostics,
renderFrameGlState),
renderFrameLivePreparation);
Fault(FrameRootCompositionPoint.RenderResourcesCreated);
var renderWeatherFrame = new RenderWeatherFrameController(
d.WorldTime,
d.Weather);
var skyPesFrame = new SkyPesFrameController(
content.ScriptRunner,
content.ParticleSink,
d.EffectPoses,
live.EntityEffects);
var worldFrameEnvironment =
new RuntimeWorldFrameEnvironmentPreparation(
d.Options,
d.WorldTime,
d.Lighting,
live.DrawDispatcher,
live.EnvCellRenderer,
foundation.SceneLighting,
d.RenderRange,
skyPesFrame);
var worldRenderFrameBuilder = new WorldRenderFrameBuilder(
new RuntimeWorldFrameCameraSource(
host.CameraController,
session.LocalTeleport),
new RuntimeWorldFrameVisibilityPreparation(
live.SelectionScene,
d.ParticleVisibility,
foundation.Terrain,
session.WorldReveal,
live.EnvCellFrustum),
new RuntimeWorldFrameSettingsPreview(
d.Settings,
content.Audio?.Engine,
host.CameraController,
d.DisplayFramePacing),
new RuntimeWorldFrameRootSource(
d.PhysicsEngine,
d.CellVisibility,
d.PlayerMode,
d.ChaseCameraInput,
d.PlayerController,
d.WorldOrigin),
worldFrameEnvironment,
new RuntimeWorldFrameAnimatedEntitySource(
d.Animations,
live.StaticAnimationScheduler,
live.EquippedChildren),
new RuntimeWorldFrameBuildingSource(
live.LandblockPipeline,
d.CellVisibility));
var terrainDrawDiagnostics = new TerrainDrawDiagnosticsController(
d.FrameDiagnosticsEnabled,
host.WorldRenderDiagnostics,
new RuntimeFramePipelineDiagnosticFactsSource(
foundation.Terrain,
live.LandblockPipeline,
renderFrameLivePreparation,
live.DrawDispatcher,
session.Streaming,
live.LiveEntities,
live.WorldState),
d.RenderDiagnosticLog);
var retailPViewCells = new RetailPViewCellSource(d.CellVisibility);
var retailPViewPassExecutor = new RetailPViewPassExecutor(
d.Gl,
renderFrameGlState,
new SilkRetailPViewFramebufferSource(d.Window),
live.ClipFrame,
foundation.Terrain,
live.EnvCellRenderer,
live.DrawDispatcher,
live.SkyRenderer,
content.ParticleSystem,
live.ParticleRenderer,
live.PortalDepthMask,
d.RetailAlphaQueue,
host.WorldRenderDiagnostics,
terrainDrawDiagnostics);
var worldSceneDiagnostics = new WorldSceneDiagnosticsController(
host.WorldRenderDiagnostics,
new RuntimeWorldScenePViewDiagnosticSource(
d.PlayerController,
d.PhysicsEngine,
d.CellVisibility),
d.WorldSceneDebugState,
foundation.DebugLines,
d.PhysicsEngine,
d.PlayerMode,
d.PlayerController,
d.DebugVmRenderFacts,
settings.DevTools is not null);
var worldScenePasses = new WorldScenePassExecutor(
d.Gl,
renderFrameGlState,
live.ClipFrame,
live.DrawDispatcher,
live.EnvCellRenderer,
foundation.Terrain,
terrainDrawDiagnostics,
live.SkyRenderer,
content.ParticleSystem,
live.ParticleRenderer);
var worldSceneRenderer = new WorldSceneRenderer(
renderFrameResources,
renderLoginState,
d.WorldEnvironment,
worldRenderFrameBuilder,
new RuntimeWorldSceneEntitySource(live.WorldState),
live.SelectionScene,
d.RetailAlphaQueue,
d.ParticleVisibility,
new WorldScenePViewRenderer(
new RetailPViewRenderer(),
retailPViewPassExecutor,
retailPViewPassExecutor),
retailPViewCells,
worldScenePasses,
d.RenderRange,
worldSceneDiagnostics);
Fault(FrameRootCompositionPoint.WorldRendererCreated);
bindings = new FrameRootRuntimeBindings();
WorldLifecycleAutomationController? lifecycleAutomation = null;
if (interaction.RetainedUi?.Screenshots is { } screenshots
&& d.Options.AutomationArtifactDirectory is { } artifactDirectory)
{
var resourceSnapshots =
new WorldLifecycleResourceSnapshotSource(
live.WorldState,
d.Animations,
live.FrameDiagnostics,
live.LiveEntities,
session.Streaming,
content.ParticleSystem,
content.ParticleSink,
live.EntityEffects,
live.Lights,
content.ScriptRunner,
foundation.MeshAdapter,
foundation.TextureCache,
live.DrawDispatcher,
d.FrameProfiler,
content.Dats);
lifecycleAutomation =
new WorldLifecycleAutomationController(
() => session.WorldReveal.Snapshot,
() => session.WorldReveal.PortalMaterializationCount,
resourceSnapshots.Capture,
screenshots,
artifactDirectory,
message => d.Log("[UI-PROBE] " + message));
bindings.Adopt(
"world lifecycle automation owner",
lifecycleAutomation);
bindings.Adopt(
"world lifecycle automation binding",
interaction.LateBindings.Automation.Bind(
lifecycleAutomation));
}
Fault(FrameRootCompositionPoint.LifecycleAutomationBound);
IRetainedGameplayUiFrame? retainedGameplayUi =
d.Options.RetailUi && interaction.RetainedUi is { } retained
? new RetainedGameplayUiFrame(retained.Runtime, d.Input)
: null;
IPrivateFrameScreenshot? privateScreenshot =
interaction.RetainedUi?.Screenshots is { } frameScreenshots
? new PrivateFrameScreenshot(frameScreenshots)
: null;
var privatePresentation = new PrivatePresentationRenderer(
new LocalPlayerPortalViewport(
session.LocalTeleport,
host.CameraController),
renderFrameResources,
new PrivateEntityViewportFrameGroup(
live.PaperdollPresenter,
live.CreatureAppraisalPresenter),
retainedGameplayUi,
settings.DevTools?.Presenter,
privateScreenshot);
var framePreparation = new RenderFramePreparationController(
renderFrameResources,
settings.DevTools?.Presenter,
renderWeatherFrame);
var renderFrame = new RenderFrameOrchestrator(
host.GpuFrameFlights,
framePreparation,
worldSceneRenderer,
privatePresentation,
live.FrameDiagnostics,
(IRenderFramePostDiagnosticsPhase?)lifecycleAutomation
?? NullRenderFramePostDiagnosticsPhase.Instance,
(IRenderFrameFailureRecovery?)settings.DevTools?.Presenter
?? NullRenderFrameFailureRecovery.Instance);
Fault(FrameRootCompositionPoint.RenderRootCreated);
var liveFrameCoordinator = new RetailLiveFrameCoordinator(
session.LiveObjectFrame,
live.WorldState,
session.LiveSession,
session.LocalPlayerFrame,
session.LiveSpatialReconciler);
var cameraFrame = new CameraFrameController(
host.CameraController,
d.InputCapture,
d.CameraInput,
session.LocalPlayerFrameRuntime,
d.ChaseCameraInput,
session.LocalPlayerFrame,
session.LiveSpatialReconciler,
new AcDream.App.Combat.CombatCameraTargetSource(
d.Settings,
d.Combat,
d.Selection,
live.SelectionQuery));
var updateFrame = new UpdateFrameOrchestrator(
new LiveEntityTeardownFramePhase(live.LiveEntities),
new ConsoleUpdateFrameFailureSink(),
d.UpdateClock,
new PhysicsScriptClockPublisher(content.ScriptRunner),
session.StreamingFrame,
session.GameplayInput,
liveFrameCoordinator,
new LiveEntityLivenessFramePhase(
session.Liveness,
new StopwatchClientMonotonicTimeSource()),
session.LocalTeleport,
new PlayerModeAutoEntryFramePhase(session.PlayerModeAutoEntry),
cameraFrame);
Fault(FrameRootCompositionPoint.UpdateRootCreated);
var bindingsLease = scope.Own(
"frame-root runtime bindings",
bindings,
static value => value.Dispose());
bindingsOwnedByScope = true;
IDisposable frameGraphPublication = d.FrameGraphs.PublishOwned(
updateFrame,
renderFrame);
var graphLease = scope.Own(
"game frame graph publication",
frameGraphPublication,
static value => value.Dispose());
Fault(FrameRootCompositionPoint.FrameGraphPublished);
var result = new FrameRootResult(
updateFrame,
renderFrame,
bindings,
frameGraphPublication,
session.SessionHost);
_publication.PublishFrameRoots(result);
graphLease.Transfer();
bindingsLease.Transfer();
Fault(FrameRootCompositionPoint.ResultPublished);
return result;
}
private void Fault(FrameRootCompositionPoint point) =>
_faultInjection?.Invoke(point);
}