#416 (char-select roster highlight never cleared on hover-leave): three decomp-grounded mechanisms replace the media-keyed _availableStates approximation. - UIElement_Button::UpdateState_ @0x00471CF0: the button machine commits ONLY states authored on the button's OWN ElementDesc (AccessStateDesc gate); unauthored requests no-op, preserving custom semantic states. - UIElement::SetState @0x00464E70: an unauthored state id is coerced to state 0 (the unnamed base state) and committed — ported into UiDatElement.TrySetRetailState with the base-descriptor PassToChildren cascade arm. - The SetState media rule @0x004651c0: a committed state replaces the playing media ONLY when its media array is non-empty. UiButton now keeps per-face-segment media states under that rule (segments model retail's PassToChildren children), and LayoutImporter records the raw MediaCount including the File=0 draw-nothing images the drawable filter drops — the roster bar children's base state is exactly such an image, and it is what clears the bar. The row template truth (probe, installed DAT): the row authors EMPTY Normal/rollover/Highlight descriptors with PassToChildren; the three bar children author rollover/Highlight media, NO Normal state, and a File=0 base image. An empty-media Normal_pressed still never blanks a Normal-art button (the media rule keeps the previous art — the exact behavior the old gate approximated), and the Appearance spins' property-only Highlight now genuinely commits: label recolors, arrow art lingers — the retail split AP-222 approximated with a requested-keyed label hack, now retired. Live-verified at char select: hover +alex shows the grey bar, moving off clears it, the selected row keeps its amber bar. #415 (probe wait world-* verbs dead): the filed snapshot-reset diagnosis was wrong — the automation bridge simply never bound without ACDREAM_AUTOMATION_ARTIFACT_DIR. A facts-only WorldRevealFactsAutomationRuntime now binds whenever the retained UI exists; checkpoint/screenshot verbs still require the artifact directory and now report that instead of a generic timeout. App tests 5568/3 skips, Runtime 1756/0, UI.Abstractions 926/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
693 lines
29 KiB
C#
693 lines
29 KiB
C#
using AcDream.App.Diagnostics;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Rendering.Vfx;
|
|
using AcDream.App.Runtime;
|
|
using AcDream.App.Settings;
|
|
using AcDream.App.Update;
|
|
using AcDream.App.World;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Session;
|
|
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.Windowing;
|
|
|
|
namespace AcDream.App.Composition;
|
|
|
|
internal sealed record FrameRootDependencies(
|
|
RuntimeOptions Options,
|
|
GameRuntime Runtime,
|
|
GameWindowGraphics Graphics,
|
|
IWindow Window,
|
|
IInputContext Input,
|
|
WorldTimeService WorldTime,
|
|
WeatherSystem Weather,
|
|
LightManager Lighting,
|
|
WorldEnvironmentController WorldEnvironment,
|
|
PhysicsEngine PhysicsEngine,
|
|
CellVisibility CellVisibility,
|
|
LocalPlayerModeState PlayerMode,
|
|
LocalPlayerIdentityState PlayerIdentity,
|
|
ChaseCameraInputState ChaseCameraInput,
|
|
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,
|
|
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> Animations,
|
|
UpdateFrameClock UpdateClock,
|
|
GameFrameGraphSlot FrameGraphs,
|
|
Action<string> Log)
|
|
{
|
|
public RuntimeLocalPlayerMovementState PlayerController =>
|
|
Runtime.MovementOwner;
|
|
|
|
public SelectionState Selection => Runtime.ActionOwner.Selection;
|
|
|
|
public CombatState Combat => Runtime.ActionOwner.Combat;
|
|
}
|
|
|
|
internal sealed record FrameRootResult(
|
|
UpdateFrameOrchestrator Update,
|
|
RenderFrameOrchestrator Render,
|
|
FrameRootRuntimeBindings RuntimeBindings,
|
|
IDisposable FrameGraphPublication,
|
|
LiveSessionHost SessionHost,
|
|
CurrentGameRuntimeAdapter GameRuntime);
|
|
|
|
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<GameWindowGraphics, 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<GameWindowGraphics, 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.Graphics, 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;
|
|
// Campaign V slice V6h: the frame root's raw-GL render graph fork was
|
|
// deleted at slice V11. The graph is the clear pass, the private-
|
|
// presentation phase, and the retained UI inside it — the client's
|
|
// own frame, drawn entirely through the RHI.
|
|
var renderLoginState = new RenderLoginStateSource(
|
|
d.Options.LiveMode,
|
|
d.PlayerMode);
|
|
var teleportRenderState =
|
|
new LocalPlayerTeleportRenderStateSource(
|
|
session.LocalTeleport,
|
|
renderLoginState);
|
|
// Campaign V slice V6i-3: on Vulkan the frame's clear is a load op of the
|
|
// world pass rather than a pass of its own, so the two phases share this
|
|
// one value. See VulkanWorldScenePhase for why the merge is required
|
|
// rather than tidier.
|
|
var vulkanClear = new AcDream.App.Rendering.Gpu.Vk.VulkanBackbufferClearState();
|
|
var renderFrameLivePreparation =
|
|
new RuntimeRenderFrameLivePreparation(
|
|
foundation.TextureCache,
|
|
foundation.MeshAdapter,
|
|
session.WorldReveal,
|
|
teleportRenderState,
|
|
renderLoginState,
|
|
new LiveLoginRevealCellSource(
|
|
live.LiveEntities,
|
|
d.PlayerIdentity),
|
|
live.ParticleRenderer,
|
|
d.FrameProfiler,
|
|
d.FrameDiagnosticsEnabled);
|
|
IRenderFrameClearPhase clearPhase =
|
|
new AcDream.App.Rendering.Gpu.Vk.VulkanRenderFrameClearPhase(
|
|
d.WorldTime,
|
|
d.Weather,
|
|
teleportRenderState,
|
|
d.ParticleVisibility,
|
|
vulkanClear);
|
|
var renderFrameResources = new RenderFrameResourceController(
|
|
host.FrameSlots,
|
|
new RuntimeRenderFrameBeginResources(
|
|
foundation.TextureCache,
|
|
live.DrawDispatcher,
|
|
live.EnvCellRenderer,
|
|
live.PortalDepthMask,
|
|
live.ClipFrame,
|
|
foundation.Terrain,
|
|
foundation.SceneLighting),
|
|
clearPhase,
|
|
renderFrameLivePreparation);
|
|
Fault(FrameRootCompositionPoint.RenderResourcesCreated);
|
|
|
|
var renderWeatherFrame = new RenderWeatherFrameController(
|
|
d.WorldTime,
|
|
d.Weather);
|
|
var skyPesFrame = new SkyPesFrameController(
|
|
content.ScriptRunner,
|
|
content.ParticleSink,
|
|
d.EffectPoses,
|
|
live.EntityEffects);
|
|
IWorldSceneFramePhase? worldSceneRenderer = null;
|
|
CurrentRenderSceneOracle? currentRenderSceneOracle =
|
|
interaction.RetainedUi?.Screenshots is not null
|
|
&& d.Options.AutomationArtifactDirectory is not null
|
|
? new CurrentRenderSceneOracle()
|
|
: null;
|
|
RenderSceneShadowComparisonController? renderSceneShadowComparison =
|
|
currentRenderSceneOracle is not null
|
|
&& live.RenderSceneShadow is not null
|
|
? new RenderSceneShadowComparisonController(
|
|
live.RenderSceneShadow,
|
|
currentRenderSceneOracle,
|
|
message => d.Log("[UI-PROBE] " + message),
|
|
acknowledgeDirty: false)
|
|
: null;
|
|
RenderScenePViewFrameProductController? renderFrameProduct = null;
|
|
{
|
|
// Campaign V slice V6j: the world scene is composed on BOTH arms.
|
|
// Every renderer below now exists on Vulkan too; what forks is one
|
|
// pass surface, one state restorer, one GL-state reader, and the
|
|
// three renderers that stay raw GL until their own slices — sky,
|
|
// particles and the portal depth mask, which the executors already
|
|
// accept as absent.
|
|
WorldRenderDiagnostics worldRenderDiagnostics =
|
|
host.WorldRenderDiagnostics
|
|
?? new WorldRenderDiagnostics(
|
|
NullRenderGlStateReader.Instance,
|
|
d.RenderDiagnosticLog);
|
|
IRenderFrameGlState worldFrameGlState = NullRenderFrameGlState.Instance;
|
|
IWorldPassScope? worldPassScope = d.Graphics.WorldPassScope;
|
|
var worldFramebufferSource =
|
|
new SilkRetailPViewFramebufferSource(d.Window);
|
|
IWorldPassSurface worldPassSurface = new RhiWorldPassSurface(
|
|
worldPassScope
|
|
?? throw new InvalidOperationException(
|
|
"The graphics backend must publish a world pass scope."),
|
|
host.GpuFrameLifetime,
|
|
live.ClipFrame,
|
|
worldFramebufferSource);
|
|
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,
|
|
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(
|
|
worldPassSurface,
|
|
worldFrameGlState,
|
|
live.ClipFrame,
|
|
foundation.Terrain,
|
|
live.EnvCellRenderer!,
|
|
live.DrawDispatcher!,
|
|
live.SkyRenderer,
|
|
content.ParticleSystem,
|
|
live.ParticleRenderer,
|
|
live.PortalDepthMask,
|
|
d.RetailAlphaQueue,
|
|
worldRenderDiagnostics,
|
|
terrainDrawDiagnostics);
|
|
var worldSceneDiagnostics = new WorldSceneDiagnosticsController(
|
|
worldRenderDiagnostics,
|
|
new RuntimeWorldScenePViewDiagnosticSource(
|
|
d.PlayerController,
|
|
d.PhysicsEngine,
|
|
d.CellVisibility),
|
|
d.WorldSceneDebugState,
|
|
// Campaign V slice V6j: no debug-line renderer on the Vulkan arm.
|
|
// DrawAndPublish flushes it INSIDE the world phase and that
|
|
// renderer opens its own pass, which the frame's one backbuffer
|
|
// pass forbids. The collision-wireframe toggle was DevTools-only
|
|
// and the ImGui DevTools frontend is gone as of Campaign V slice
|
|
// V11, so nothing is lost — composing it would throw on the
|
|
// first wireframe frame rather than silently misdraw (plan
|
|
// §5.5.14 item 7).
|
|
null,
|
|
d.PhysicsEngine,
|
|
d.PlayerMode,
|
|
d.PlayerController,
|
|
d.DebugVmRenderFacts,
|
|
debugVmConsumerActive: false);
|
|
var worldScenePasses = new WorldScenePassExecutor(
|
|
worldPassSurface,
|
|
worldFrameGlState,
|
|
live.ClipFrame,
|
|
live.DrawDispatcher!,
|
|
live.EnvCellRenderer!,
|
|
foundation.Terrain,
|
|
terrainDrawDiagnostics,
|
|
live.SkyRenderer,
|
|
content.ParticleSystem,
|
|
live.ParticleRenderer);
|
|
renderFrameProduct =
|
|
live.RenderSceneShadow is not null
|
|
? new RenderScenePViewFrameProductController(
|
|
live.RenderSceneShadow,
|
|
currentRenderSceneOracle,
|
|
message => d.Log("[UI-PROBE] " + message),
|
|
live.DrawDispatcher!)
|
|
: null;
|
|
// After G4 the retained product is the production object source.
|
|
// The old dispatcher/selection observer is intentionally detached;
|
|
// automation still compares the independently built PView route list.
|
|
live.DrawDispatcher!.SetCurrentRenderSceneObserver(null);
|
|
live.SelectionScene.SetCurrentRenderSceneObserver(null);
|
|
worldSceneRenderer = new WorldSceneRenderer(
|
|
renderFrameResources,
|
|
renderLoginState,
|
|
d.WorldEnvironment,
|
|
worldRenderFrameBuilder,
|
|
new RuntimeWorldSceneEntitySource(live.WorldState),
|
|
live.SelectionScene,
|
|
d.RetailAlphaQueue,
|
|
d.ParticleVisibility,
|
|
new WorldScenePViewRenderer(
|
|
new RetailPViewRenderer(
|
|
currentRenderSceneOracle,
|
|
renderFrameProduct),
|
|
retailPViewPassExecutor,
|
|
retailPViewPassExecutor),
|
|
retailPViewCells,
|
|
worldScenePasses,
|
|
d.RenderRange,
|
|
worldSceneDiagnostics,
|
|
live.WorldAvailability);
|
|
// The world renderer runs INSIDE the frame's one backbuffer pass,
|
|
// which this phase opens, publishes on the scope, and closes.
|
|
worldSceneRenderer =
|
|
new AcDream.App.Rendering.Gpu.Vk.VulkanWorldScenePhase(
|
|
host.GpuFrameLifetime,
|
|
vulkanClear,
|
|
() => d.Graphics.Vulkan?.SampleCount ?? 1,
|
|
(d.Graphics as VulkanGameWindowGraphics)?.WorldPassScopeCore
|
|
?? throw new InvalidOperationException(
|
|
"The Vulkan world phase requires the Vulkan graphics handle."),
|
|
worldSceneRenderer);
|
|
}
|
|
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,
|
|
foundation.Residency,
|
|
d.PhysicsEngine.DataCache
|
|
?? throw new InvalidOperationException(
|
|
"Lifecycle automation requires the canonical physics cache."),
|
|
currentRenderSceneOracle,
|
|
renderSceneShadowComparison,
|
|
renderFrameProduct);
|
|
lifecycleAutomation =
|
|
new WorldLifecycleAutomationController(
|
|
() => session.WorldReveal.Snapshot,
|
|
() => d.WorldEnvironment.Runtime.Ownership,
|
|
() => live.WorldTransit.Ownership,
|
|
() => 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));
|
|
}
|
|
else if (interaction.RetainedUi is not null)
|
|
{
|
|
// #415: without ACDREAM_AUTOMATION_ARTIFACT_DIR the deferred
|
|
// automation wrapper stayed unbound, so a probe script's
|
|
// `wait world-ready/world-visible/materialized` verbs read
|
|
// false forever and timed out even while the world revealed.
|
|
// The wait verbs need only the reveal facts — bind them always;
|
|
// checkpoint/screenshot verbs keep requiring the artifact
|
|
// directory and now report that instead of a generic timeout.
|
|
bindings.Adopt(
|
|
"world reveal facts automation binding",
|
|
interaction.LateBindings.Automation.Bind(
|
|
new WorldRevealFactsAutomationRuntime(
|
|
() => session.WorldReveal.Snapshot,
|
|
() => session.WorldReveal.PortalMaterializationCount)));
|
|
}
|
|
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,
|
|
live.ChargenPreviewController,
|
|
live.SummaryPreviewController),
|
|
retainedGameplayUi,
|
|
// The ImGui developer-tools frontend was removed at Campaign V
|
|
// slice V11; this optional hook is unbound until a follow-up
|
|
// re-homes it onto the retained UI (IDevToolsFrameLifecycle stays
|
|
// as the seam).
|
|
devTools: null,
|
|
privateScreenshot);
|
|
var framePreparation = new RenderFramePreparationController(
|
|
renderFrameResources,
|
|
devTools: null,
|
|
renderWeatherFrame);
|
|
IRenderFramePostDiagnosticsPhase postDiagnostics =
|
|
renderSceneShadowComparison is not null
|
|
&& lifecycleAutomation is not null
|
|
? new SerialRenderFramePostDiagnosticsPhase(
|
|
renderSceneShadowComparison,
|
|
lifecycleAutomation)
|
|
: (IRenderFramePostDiagnosticsPhase?)lifecycleAutomation
|
|
?? NullRenderFramePostDiagnosticsPhase.Instance;
|
|
if (RenderPresentationDiagnostics.ProbeLoginFrames)
|
|
{
|
|
// Enter-world gate round (2026-08-17): the per-frame presentation
|
|
// classification probe (world/tunnel/black/void transitions). The
|
|
// tunnel fact is the controller's RAW portal-scene visibility, not
|
|
// the composed render-state source, so the probe can distinguish
|
|
// "covered black" from "tunnel scene drawn".
|
|
var loginFrameProbe = new LoginPresentationFrameProbe(
|
|
() => session.LocalTeleport.IsPortalViewportVisible,
|
|
renderLoginState,
|
|
d.Log);
|
|
postDiagnostics =
|
|
postDiagnostics is NullRenderFramePostDiagnosticsPhase
|
|
? loginFrameProbe
|
|
: new SerialRenderFramePostDiagnosticsPhase(
|
|
postDiagnostics,
|
|
loginFrameProbe);
|
|
}
|
|
var renderFrame = new RenderFrameOrchestrator(
|
|
host.GpuFrameLifetime,
|
|
// Campaign V slice V8: the Vulkan arm measures the frame bracket
|
|
// through its own timestamp scope — see VulkanFrameGpuMeasurement.
|
|
// The raw-GL adapter (FrameProfilerGpuMeasurement) was deleted at
|
|
// slice V11.
|
|
d.Graphics.Vulkan is { } vulkanGraphics
|
|
? new AcDream.App.Rendering.Gpu.Vk.VulkanFrameGpuMeasurement(
|
|
d.FrameProfiler,
|
|
vulkanGraphics.Device)
|
|
: AcDream.App.Rendering.Gpu.Vk.NullRenderFrameGpuMeasurement.Instance,
|
|
framePreparation,
|
|
worldSceneRenderer,
|
|
privatePresentation,
|
|
live.FrameDiagnostics,
|
|
postDiagnostics,
|
|
NullRenderFrameFailureRecovery.Instance);
|
|
Fault(FrameRootCompositionPoint.RenderRootCreated);
|
|
|
|
var liveFrameCoordinator = new RetailLiveFrameCoordinator(
|
|
session.LiveObjectFrame,
|
|
live.WorldState,
|
|
session.SessionHost,
|
|
session.LocalPlayerFrame,
|
|
session.LiveSpatialReconciler,
|
|
live.WorldAvailability,
|
|
live.RenderSceneShadow?.LiveProjections,
|
|
session.PlacementProjectionRetry);
|
|
var cameraFrame = new CameraFrameController(
|
|
host.CameraController,
|
|
d.InputCapture,
|
|
d.CameraInput,
|
|
session.LocalPlayerFrameRuntime,
|
|
d.ChaseCameraInput,
|
|
session.LocalPlayerFrame,
|
|
session.LiveSpatialReconciler,
|
|
new AcDream.App.Combat.CombatCameraTargetSource(
|
|
// D7 Group-C re-point (Campaign OP OP4): server bit, not
|
|
// the client-local GameplaySettings record — see
|
|
// CharacterOptionCombatSettingsSource's doc comment.
|
|
new AcDream.App.Combat.CharacterOptionCombatSettingsSource(
|
|
d.Runtime.CharacterOwner.Options),
|
|
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,
|
|
new RenderSceneUpdateCommitPhase(live.RenderSceneShadow),
|
|
live.WorldAvailability);
|
|
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,
|
|
session.GameRuntime);
|
|
_publication.PublishFrameRoots(result);
|
|
graphLease.Transfer();
|
|
bindingsLease.Transfer();
|
|
Fault(FrameRootCompositionPoint.ResultPublished);
|
|
return result;
|
|
}
|
|
|
|
private void Fault(FrameRootCompositionPoint point) =>
|
|
_faultInjection?.Invoke(point);
|
|
}
|