refactor(app): compose interaction and retained UI startup

This commit is contained in:
Erik 2026-07-22 17:20:47 +02:00
parent f663b04a54
commit aa6ffa5176
15 changed files with 2157 additions and 467 deletions

View file

@ -0,0 +1,832 @@
using System.Collections.Concurrent;
using AcDream.App.Combat;
using AcDream.App.Diagnostics;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Net;
using AcDream.App.Plugins;
using AcDream.App.Rendering;
using AcDream.App.Settings;
using AcDream.App.Spells;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Player;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Vitals;
using DatReaderWriter;
using Silk.NET.Input;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Composition;
internal sealed record InteractionRetainedUiDependencies(
RuntimeOptions Options,
GL Gl,
IView Window,
IInputContext Input,
string ShadersDirectory,
IDatReaderWriter Dats,
object DatLock,
TextureCache TextureCache,
BitmapFont? DebugFont,
HostQuiescenceGate HostQuiescence,
RetainedUiInputCaptureSlot RetainedInputCapture,
InputDispatcher? InputDispatcher,
RuntimeSettingsController Settings,
CombatState Combat,
ICombatAttackOperations CombatAttackOperations,
SelectionState Selection,
ExternalContainerState ExternalContainers,
ClientObjectTable Objects,
MagicCatalog MagicCatalog,
Spellbook Spellbook,
ChatLog Chat,
LocalPlayerState LocalPlayer,
ItemManaState ItemMana,
StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? UiRegistry,
LiveCombatModeCommandSlot CombatModeCommands,
ILocalPlayerIdentitySource PlayerIdentity,
ILocalPlayerControllerSource PlayerController,
ILocalPlayerModeSource PlayerMode,
PlayerCharacterOptionsState CharacterOptions,
ShortcutSnapshotState Shortcuts,
Func<DeferredSelectionViewPlaneSource, SelectionCameraSource>
SelectionCameraFactory,
DeferredRenderFrameDiagnosticsSource FrameDiagnostics,
VitalsVM? ExistingVitals,
Action<string>? Toast,
Func<double> ClientTime,
Action<string> Log);
internal sealed record RetainedUiComposition(
UiHost Host,
RetailUiRuntime Runtime,
VitalsVM Vitals,
ChatVM Chat,
CharacterSheetProvider CharacterSheet,
MagicRuntime Magic,
FrameScreenshotController? Screenshots);
internal sealed record InteractionRetainedUiResult(
CombatAttackController CombatAttack,
CombatTargetController CombatTarget,
ExternalContainerLifecycleController ExternalContainerLifecycle,
ItemInteractionController ItemInteraction,
RetainedUiComposition? RetainedUi,
InteractionUiLateBindings LateBindings);
internal interface IGameWindowInteractionRetainedUiPublication
{
void PublishInteractionRetainedUi(InteractionRetainedUiResult result);
}
internal enum InteractionRetainedUiCompositionPoint
{
LateBindingsCreated,
CombatAttackCreated,
CombatTargetCreated,
ExternalContainerLifecycleCreated,
ItemInteractionCreated,
RetainedUiDisabled,
UiHostAcquired,
InputCaptureBound,
CursorAssetsCreated,
CharacterSheetCreated,
MagicRuntimeCreated,
MouseInputWired,
KeyboardInputWired,
UiAssetsCreated,
UiProbeCreated,
UiRuntimeMounted,
InventoryContainerBound,
ResultPublished,
}
internal sealed class InteractionUiLateBindings : IDisposable
{
private IDisposable? _inputCapture;
private IDisposable? _inventoryContainer;
private readonly List<(string Name, IDisposable Binding)> _lateOwnerBindings = [];
private SelectionCameraSource? _selectionCamera;
private bool _deactivationStarted;
public DeferredLiveSessionUiAuthority Session { get; } = new();
public DeferredSelectionUiAuthority Selection { get; } = new();
public DeferredSelectionViewPlaneSource SelectionViewPlane { get; } = new();
public DeferredRadarSnapshotSource Radar { get; } = new();
public DeferredInventoryContainerSource InventoryContainer { get; } = new();
public DeferredWorldLifecycleAutomationRuntime Automation { get; } = new();
public SelectionCameraSource SelectionCamera =>
_selectionCamera ?? throw new InvalidOperationException(
"The retained-UI selection camera is not initialized.");
public void InitializeSelectionCamera(SelectionCameraSource source)
{
ArgumentNullException.ThrowIfNull(source);
ObjectDisposedException.ThrowIf(_deactivationStarted, this);
if (_selectionCamera is not null)
throw new InvalidOperationException("The retained selection camera is already initialized.");
_selectionCamera = source;
}
public void AdoptInputCapture(IDisposable binding)
{
ArgumentNullException.ThrowIfNull(binding);
ObjectDisposedException.ThrowIf(_deactivationStarted, this);
if (_inputCapture is not null)
throw new InvalidOperationException("Retained input capture is already owned.");
_inputCapture = binding;
}
public void AdoptInventoryContainer(IDisposable binding)
{
ArgumentNullException.ThrowIfNull(binding);
ObjectDisposedException.ThrowIf(_deactivationStarted, this);
if (_inventoryContainer is not null)
throw new InvalidOperationException("Retained inventory binding is already owned.");
_inventoryContainer = binding;
}
public void AdoptLateOwnerBinding(string name, IDisposable binding)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(binding);
ObjectDisposedException.ThrowIf(_deactivationStarted, this);
_lateOwnerBindings.Add((name, binding));
}
public void Dispose()
{
if (_deactivationStarted
&& _lateOwnerBindings.Count == 0
&& _inventoryContainer is null
&& _inputCapture is null)
return;
_deactivationStarted = true;
List<Exception>? failures = null;
Automation.Deactivate();
Radar.Deactivate();
SelectionViewPlane.Deactivate();
Selection.Deactivate();
Session.Deactivate();
InventoryContainer.Deactivate();
for (int i = _lateOwnerBindings.Count - 1; i >= 0; i--)
{
(string name, IDisposable binding) = _lateOwnerBindings[i];
try
{
binding.Dispose();
_lateOwnerBindings.RemoveAt(i);
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
$"Retained UI late owner binding '{name}' did not detach.",
failure));
}
}
Release(ref _inventoryContainer, "inventory container binding", ref failures);
Release(ref _inputCapture, "retained input capture", ref failures);
if (failures is not null)
throw new AggregateException("Retained UI late binding cleanup failed.", failures);
}
private static void Release(
ref IDisposable? binding,
string name,
ref List<Exception>? failures)
{
IDisposable? current = binding;
if (current is null)
return;
try
{
current.Dispose();
binding = null;
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
$"Retained UI {name} did not detach.",
failure));
}
}
}
internal interface IInteractionRetainedUiCompositionFactory
{
CombatAttackController CreateCombatAttack(
InteractionRetainedUiDependencies dependencies);
CombatTargetController CreateCombatTarget(
InteractionRetainedUiDependencies dependencies,
DeferredSelectionUiAuthority selection);
ExternalContainerLifecycleController CreateExternalContainerLifecycle(
InteractionRetainedUiDependencies dependencies,
DeferredLiveSessionUiAuthority session);
ItemInteractionController CreateItemInteraction(
InteractionRetainedUiDependencies dependencies,
InteractionUiLateBindings lateBindings);
RetainedUiComposition CreateRetainedUi(
InteractionRetainedUiDependencies dependencies,
InteractionUiLateBindings lateBindings,
RetailUiRuntimeLease lease,
CombatAttackController combatAttack,
ItemInteractionController itemInteraction,
Action<InteractionRetainedUiCompositionPoint> checkpoint);
void Release(IDisposable resource);
}
internal sealed class RetailInteractionRetainedUiCompositionFactory
: IInteractionRetainedUiCompositionFactory
{
public CombatAttackController CreateCombatAttack(
InteractionRetainedUiDependencies d) =>
new(d.Combat, d.CombatAttackOperations);
public CombatTargetController CreateCombatTarget(
InteractionRetainedUiDependencies d,
DeferredSelectionUiAuthority selection) =>
new(
d.Combat,
d.Selection,
autoTarget: () => d.Settings.Gameplay.AutoTarget,
selectClosestTarget: () =>
selection.SelectClosestCombatTarget(showToast: false));
public ExternalContainerLifecycleController CreateExternalContainerLifecycle(
InteractionRetainedUiDependencies d,
DeferredLiveSessionUiAuthority session) =>
new(
d.ExternalContainers,
d.Objects,
guid => session.CurrentSession?.SendNoLongerViewingContents(guid));
public ItemInteractionController CreateItemInteraction(
InteractionRetainedUiDependencies d,
InteractionUiLateBindings late)
{
DeferredLiveSessionUiAuthority session = late.Session;
DeferredSelectionUiAuthority selection = late.Selection;
return new ItemInteractionController(
d.Objects,
playerGuid: () => d.PlayerIdentity.ServerGuid,
sendUse: selection.SendUse,
sendExamine: guid => session.CurrentSession?.SendAppraise(guid),
sendUseWithTarget: (source, target) =>
session.CurrentSession?.SendUseWithTarget(source, target),
sendWield: (item, mask) =>
session.CurrentSession?.SendGetAndWieldItem(item, mask),
sendDrop: item => session.CurrentSession?.SendDropItem(item),
sendGive: (target, item, amount) =>
session.CurrentSession?.SendGiveObject(target, item, amount),
dragOnPlayerOpensSecureTrade: () =>
d.CharacterOptions.DragItemOnPlayerOpensSecureTrade,
toast: d.Toast,
readyForInventoryRequest: () => session.IsInWorld,
playerOnGround: () =>
d.PlayerMode.IsPlayerMode
&& d.PlayerController.Controller is { IsAirborne: false },
inNonCombatMode: () =>
d.Combat.CurrentMode == CombatMode.NonCombat,
combatState: d.Combat,
sendChangeCombatMode: mode =>
session.CurrentSession?.SendChangeCombatMode(mode),
isComponentPack: d.MagicCatalog.IsComponentPack,
placeInBackpack: selection.SendPickup,
backpackContainerId: () => late.InventoryContainer.Current(
d.PlayerIdentity.ServerGuid),
groundObjectId: () => d.ExternalContainers.CurrentContainerId,
sendSplitToWorld: (item, amount) =>
session.CurrentSession?.SendStackableSplitTo3D(item, amount),
selectedObjectId: () => d.Selection.SelectedObjectId ?? 0u,
stackSplitQuantity: d.StackSplitQuantity,
systemMessage: text => d.Chat.OnSystemMessage(text, 0x1Au),
sendPutItemInContainer: (item, container, placement) =>
session.CurrentSession?.SendPutItemInContainer(
item,
container,
placement),
sendSplitToContainer: (item, container, placement, amount) =>
session.CurrentSession?.SendStackableSplitToContainer(
item,
container,
placement,
amount),
requestExternalContainer: guid =>
{
d.ExternalContainers.RequestOpen(guid);
});
}
public RetainedUiComposition CreateRetainedUi(
InteractionRetainedUiDependencies d,
InteractionUiLateBindings late,
RetailUiRuntimeLease lease,
CombatAttackController combatAttack,
ItemInteractionController itemInteraction,
Action<InteractionRetainedUiCompositionPoint> checkpoint)
{
IDisposable? inputCapture = null;
IDisposable? inventoryContainer = null;
try
{
VitalsVM vitals = d.ExistingVitals
?? new VitalsVM(d.Combat, d.LocalPlayer);
UiHost host = lease.AcquireHost(
() => new UiHost(
d.Gl,
d.ShadersDirectory,
d.DebugFont,
d.HostQuiescence));
checkpoint(InteractionRetainedUiCompositionPoint.UiHostAcquired);
inputCapture = d.RetainedInputCapture.Bind(host.Root);
checkpoint(InteractionRetainedUiCompositionPoint.InputCaptureBound);
host.Root.UiLocked = d.Settings.Gameplay.LockUI;
var cursorFeedback = new CursorFeedbackController(
itemInteraction,
worldTargetProvider: () =>
late.Selection.PickAtCursor(includeSelf: true) ?? 0u,
combatModeProvider: () => d.Combat.CurrentMode);
var cursorManager = new RetailCursorManager(d.Dats, d.DatLock);
checkpoint(InteractionRetainedUiCompositionPoint.CursorAssetsCreated);
var characterSheet = new CharacterSheetProvider(
d.Objects,
d.LocalPlayer,
playerGuid: () => d.PlayerIdentity.ServerGuid,
activeToonName: () => d.Settings.ActiveToonKey,
fallbackSheet: Studio.SampleData.SampleCharacter,
canSendRaise: () => late.Session.IsInWorld,
sendRaiseAttribute: (statId, cost) =>
late.Session.CurrentSession?.SendRaiseAttribute(statId, cost),
sendRaiseVital: (statId, cost) =>
late.Session.CurrentSession?.SendRaiseVital(statId, cost),
sendRaiseSkill: (statId, cost) =>
late.Session.CurrentSession?.SendRaiseSkill(statId, cost),
sendTrainSkill: (statId, credits) =>
late.Session.CurrentSession?.SendTrainSkill(statId, credits));
checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated);
MagicRuntime magic = MagicRuntime.Create(
d.MagicCatalog,
d.Spellbook,
d.Objects,
selectedObject: () => d.Selection.SelectedObjectId,
localPlayerId: () => d.PlayerIdentity.ServerGuid,
accountName: () => late.Session.AccountName
?? d.Options.LiveUser
?? string.Empty,
stopCompletely: d.CombatAttackOperations.PrepareAttackRequest,
sendUntargeted: spellId =>
late.Session.CurrentSession?.SendCastUntargetedSpell(spellId),
sendTargeted: (target, spellId) =>
late.Session.CurrentSession?.SendCastTargetedSpell(target, spellId),
displayMessage: text => d.Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: itemInteraction.IncrementBusyCount,
canSend: () => late.Session.IsInWorld);
checkpoint(InteractionRetainedUiCompositionPoint.MagicRuntimeCreated);
foreach (IMouse mouse in d.Input.Mice)
host.WireMouse(mouse);
checkpoint(InteractionRetainedUiCompositionPoint.MouseInputWired);
foreach (IKeyboard keyboard in d.Input.Keyboards)
host.WireKeyboard(keyboard);
checkpoint(InteractionRetainedUiCompositionPoint.KeyboardInputWired);
(uint, int, int) ResolveChrome(uint id)
{
uint texture = d.TextureCache.GetOrUploadRenderSurface(
id,
out int width,
out int height);
return (texture, width, height);
}
var iconComposer = new IconComposer(d.Dats, d.TextureCache);
ControlsIni controls = d.Options.AcDir is { } acDir
? ControlsIni.Load(Path.Combine(acDir, "controls", "controls.ini"))
: ControlsIni.Parse(string.Empty);
UiDatFont? defaultFont;
lock (d.DatLock)
defaultFont = UiDatFont.Load(d.Dats, d.TextureCache);
var datFontCache = new ConcurrentDictionary<uint, UiDatFont?>();
if (defaultFont is not null)
datFontCache.TryAdd(UiDatFont.DefaultFontId, defaultFont);
UiDatFont? ResolveDatFont(uint fontDid) =>
datFontCache.GetOrAdd(fontDid, id =>
{
lock (d.DatLock)
return UiDatFont.Load(d.Dats, d.TextureCache, id);
});
d.Log(defaultFont is not null
? "[D.2b] vitals dat-font 0x40000000 loaded for numeric overlay."
: "[D.2b] vitals dat-font 0x40000000 unavailable — falling back to debug font.");
checkpoint(InteractionRetainedUiCompositionPoint.UiAssetsCreated);
host.Root.Width = d.Window.Size.X;
host.Root.Height = d.Window.Size.Y;
var chat = new ChatVM(d.Chat, displayLimit: 200);
AcDream.UI.Abstractions.Panels.Settings.SettingsStore? layoutStore =
d.Settings.LayoutStore;
RetailUiPersistenceBindings? persistence = layoutStore is null
? null
: new RetailUiPersistenceBindings(
layoutStore,
CharacterKey: () => d.Settings.ActiveToonKey,
ScreenSize: () => (d.Window.Size.X, d.Window.Size.Y));
void ProbeLog(string message) => d.Log("[UI-PROBE] " + message);
FrameScreenshotController? screenshots = null;
if (d.Options.UiProbeEnabled
&& d.Options.AutomationArtifactDirectory is { } artifactDirectory)
{
screenshots = new FrameScreenshotController(
d.Gl,
Path.Combine(artifactDirectory, "screenshots"),
ProbeLog);
}
checkpoint(InteractionRetainedUiCompositionPoint.UiProbeCreated);
var assets = new RetailUiAssets(
d.Dats,
d.DatLock,
ResolveChrome,
ResolveDatFont,
defaultFont,
d.DebugFont,
controls,
iconComposer);
var bindings = new RetailUiRuntimeBindings(
Host: host,
Assets: assets,
Vitals: new VitalsRuntimeBindings(vitals),
Chat: new ChatRuntimeBindings(chat, () => late.Session.Commands),
Radar: new RadarRuntimeBindings(
late.Radar.Snapshot,
d.Selection,
d.Settings.SetUiLocked),
Combat: new CombatRuntimeBindings(
d.Combat,
combatAttack,
() => d.Settings.Gameplay,
d.Settings.SetCombatGameplay),
Magic: new MagicRuntimeBindings(
d.Spellbook,
magic.Casting,
d.Objects,
() => d.PlayerIdentity.ServerGuid,
d.MagicCatalog.Components,
iconComposer.GetIcon,
iconComposer.GetDragIcon,
iconComposer.GetSpellIcon,
iconComposer.GetSpellComponentIcon,
d.Selection,
d.MagicCatalog.GetSpellLevel,
guid => d.Selection.Select(guid, SelectionChangeSource.Inventory),
guid => late.Session.TryUseItem(guid, d.Log),
(tab, position, spellId) =>
late.Session.CurrentSession?.SendAddSpellFavorite(
spellId,
position,
tab),
(tab, spellId) =>
late.Session.CurrentSession?.SendRemoveSpellFavorite(spellId, tab),
filters => late.Session.CurrentSession?.SendSpellbookFilter(filters),
spellId => late.Session.CurrentSession?.SendRemoveSpell(spellId),
(componentId, amount) =>
late.Session.CurrentSession?.SendSetDesiredComponentLevel(
componentId,
amount),
d.ClientTime),
JumpPowerbar: new JumpPowerbarRuntimeBindings(
() => d.PlayerController.Controller?.JumpCharge ?? default),
Fps: new FpsRuntimeBindings(
() => d.FrameDiagnostics.Snapshot.Fps,
() => 1.0,
() => d.Settings.DisplayPreview.ShowFps),
VividTarget: new VividTargetRuntimeBindings(
d.Selection,
() => d.PlayerIdentity.ServerGuid,
() => d.Settings.Gameplay.VividTargetingIndicator,
late.Selection.ResolveVividTargetInfo,
late.SelectionCamera.UiSnapshot),
Indicators: new IndicatorRuntimeBindings(
d.Spellbook,
d.Objects,
() => d.PlayerIdentity.ServerGuid,
() => d.LocalPlayer.GetAttribute(
LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
() => late.Session.LinkStatus,
d.ClientTime,
() => late.Session.CurrentSession?.RequestLinkStatusPing(),
d.Window.Close),
Toolbar: new ToolbarRuntimeBindings(
d.Objects,
() => d.Shortcuts.Items,
iconComposer.GetIcon,
iconComposer.GetDragIcon,
guid => late.Session.TryUseItem(guid, d.Log),
d.Combat,
d.ItemMana,
d.CombatModeCommands.Toggle,
itemInteraction,
entry => late.Session.CurrentSession?.SendAddShortcut(entry),
index => late.Session.CurrentSession?.SendRemoveShortcut(index),
d.Selection,
handler => d.Combat.HealthChanged += handler,
handler => d.Combat.HealthChanged -= handler,
late.Selection.ShouldShowHealth,
guid => d.Objects.Get(guid)?.GetAppropriateName(),
d.Combat.GetHealthPercent,
d.Combat.HasHealth,
guid => (uint)(d.Objects.Get(guid)?.StackSize ?? 0),
guid => late.Session.CurrentSession?.SendQueryHealth(guid),
guid => late.Session.CurrentSession?.SendQueryItemMana(guid),
() => d.PlayerIdentity.ServerGuid,
(item, container, placement) =>
late.Session.CurrentSession?.SendPutItemInContainer(
item,
container,
placement)),
Character: new CharacterRuntimeBindings(characterSheet),
Inventory: new InventoryRuntimeBindings(
d.Objects,
() => d.PlayerIdentity.ServerGuid,
iconComposer.GetIcon,
iconComposer.GetDragIcon,
() => d.LocalPlayer.GetAttribute(
LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
guid => late.Session.CurrentSession?.SendUse(guid),
(item, container, placement) =>
late.Session.CurrentSession?.SendPutItemInContainer(
item,
container,
placement),
(item, container, placement, amount) =>
late.Session.CurrentSession?.SendStackableSplitToContainer(
item,
container,
placement,
amount),
(source, target, amount) =>
late.Session.CurrentSession?.SendStackableMerge(
source,
target,
amount),
itemInteraction,
d.Selection),
ExternalContainer: new ExternalContainerRuntimeBindings(
d.ExternalContainers,
d.Objects,
iconComposer.GetIcon,
iconComposer.GetDragIcon,
itemInteraction,
d.Selection,
guid => late.Session.CurrentSession?.SendUse(guid),
(item, container, placement) =>
late.Session.CurrentSession?.SendPutItemInContainer(
item,
container,
placement),
(item, container, placement, amount) =>
late.Session.CurrentSession?.SendStackableSplitToContainer(
item,
container,
placement,
amount),
late.Selection.IsWithinExternalContainerUseRange),
Cursor: new RetailUiCursorBindings(cursorFeedback, cursorManager),
Confirmations: new ConfirmationRuntimeBindings(
(type, context, accepted) =>
late.Session.CurrentSession?.SendConfirmationResponse(
type,
context,
accepted)),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,
Probe: new RetailUiProbeBindings(
d.Options.UiProbeEnabled,
d.Options.UiProbeScript,
d.Options.UiProbeDump,
ProbeLog,
action => d.InputDispatcher?.TryInvokeAutomationAction(action) == true,
(action, held) =>
d.InputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
late.Automation));
RetailUiRuntime runtime = lease.Mount(
() => RetailUiRuntime.CreateUninitialized(bindings));
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
inventoryContainer = late.InventoryContainer.Bind(runtime);
checkpoint(InteractionRetainedUiCompositionPoint.InventoryContainerBound);
late.AdoptInputCapture(inputCapture);
inputCapture = null;
late.AdoptInventoryContainer(inventoryContainer);
inventoryContainer = null;
return new RetainedUiComposition(
host,
runtime,
vitals,
chat,
characterSheet,
magic,
screenshots);
}
catch (Exception failure)
{
List<Exception>? cleanup = null;
TryRelease(ref inventoryContainer, "inventory container", ref cleanup);
TryRelease(ref inputCapture, "input capture", ref cleanup);
if (cleanup is not null)
{
cleanup.Insert(0, failure);
throw new AggregateException(
"Retained UI construction and local binding rollback failed.",
cleanup);
}
throw;
}
}
public void Release(IDisposable resource) => resource.Dispose();
private static void TryRelease(
ref IDisposable? resource,
string name,
ref List<Exception>? failures)
{
if (resource is null)
return;
try
{
resource.Dispose();
resource = null;
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
$"Retained UI {name} rollback failed.",
failure));
}
}
}
internal sealed class InteractionRetainedUiCompositionPhase
: IInteractionUiCompositionPhase<
HostInputCameraResult,
ContentEffectsAudioResult,
SettingsDevToolsResult,
WorldRenderResult,
InteractionRetainedUiResult>
{
private readonly InteractionRetainedUiDependencies _dependencies;
private readonly RetailUiRuntimeLease _retainedUiLease;
private readonly IGameWindowInteractionRetainedUiPublication _publication;
private readonly IInteractionRetainedUiCompositionFactory _factory;
private readonly Action<InteractionRetainedUiCompositionPoint>? _faultInjection;
public InteractionRetainedUiCompositionPhase(
InteractionRetainedUiDependencies dependencies,
RetailUiRuntimeLease retainedUiLease,
IGameWindowInteractionRetainedUiPublication publication,
IInteractionRetainedUiCompositionFactory? factory = null,
Action<InteractionRetainedUiCompositionPoint>? faultInjection = null)
{
_dependencies = dependencies
?? throw new ArgumentNullException(nameof(dependencies));
_retainedUiLease = retainedUiLease
?? throw new ArgumentNullException(nameof(retainedUiLease));
_publication = publication
?? throw new ArgumentNullException(nameof(publication));
_factory = factory ?? new RetailInteractionRetainedUiCompositionFactory();
_faultInjection = faultInjection;
}
public InteractionRetainedUiResult Compose()
{
var scope = new CompositionAcquisitionScope();
try
{
var lateLease = scope.Acquire(
"retained UI late bindings",
static () => new InteractionUiLateBindings(),
_factory.Release);
InteractionUiLateBindings late = lateLease.Resource;
late.InitializeSelectionCamera(
_dependencies.SelectionCameraFactory(late.SelectionViewPlane));
Fault(InteractionRetainedUiCompositionPoint.LateBindingsCreated);
var attackLease = scope.Acquire(
"combat attack controller",
() => _factory.CreateCombatAttack(_dependencies),
_factory.Release);
Fault(InteractionRetainedUiCompositionPoint.CombatAttackCreated);
var targetLease = scope.Acquire(
"combat target controller",
() => _factory.CreateCombatTarget(_dependencies, late.Selection),
_factory.Release);
Fault(InteractionRetainedUiCompositionPoint.CombatTargetCreated);
var externalLease = scope.Acquire(
"external container lifecycle",
() => _factory.CreateExternalContainerLifecycle(
_dependencies,
late.Session),
_factory.Release);
Fault(InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated);
var itemLease = scope.Acquire(
"item interaction controller",
() => _factory.CreateItemInteraction(_dependencies, late),
_factory.Release);
Fault(InteractionRetainedUiCompositionPoint.ItemInteractionCreated);
var uiLease = scope.Own(
"retained UI runtime lease",
_retainedUiLease,
_factory.Release);
RetainedUiComposition? retainedUi = null;
if (_dependencies.Options.RetailUi)
{
retainedUi = _factory.CreateRetainedUi(
_dependencies,
late,
_retainedUiLease,
attackLease.Resource,
itemLease.Resource,
Fault);
}
else
{
Fault(InteractionRetainedUiCompositionPoint.RetainedUiDisabled);
}
var result = new InteractionRetainedUiResult(
attackLease.Resource,
targetLease.Resource,
externalLease.Resource,
itemLease.Resource,
retainedUi,
late);
_publication.PublishInteractionRetainedUi(result);
lateLease.Transfer();
attackLease.Transfer();
targetLease.Transfer();
externalLease.Transfer();
itemLease.Transfer();
uiLease.Transfer();
Fault(InteractionRetainedUiCompositionPoint.ResultPublished);
scope.Complete();
return result;
}
catch (Exception failure)
{
scope.RollbackAndThrow(failure);
throw new System.Diagnostics.UnreachableException();
}
}
public InteractionRetainedUiResult Compose(
HostInputCameraResult host,
ContentEffectsAudioResult content,
SettingsDevToolsResult settings,
WorldRenderResult world)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(world);
if (!ReferenceEquals(_dependencies.InputDispatcher, host.InputDispatcher)
|| !ReferenceEquals(_dependencies.Dats, content.Dats)
|| !ReferenceEquals(_dependencies.MagicCatalog, content.MagicCatalog)
|| !ReferenceEquals(_dependencies.TextureCache, world.Foundation.TextureCache)
|| !ReferenceEquals(_dependencies.DebugFont, world.Foundation.DebugFont))
{
throw new InvalidOperationException(
"Interaction/UI dependencies do not match the ordered phase results.");
}
return Compose();
}
private void Fault(InteractionRetainedUiCompositionPoint point) =>
_faultInjection?.Invoke(point);
}

View file

@ -0,0 +1,479 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.UI.Testing;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.UI.Abstractions;
using Silk.NET.Windowing;
namespace AcDream.App.Composition;
/// <summary>
/// Early UI view over the later live-session owner. A binding lease clears only
/// the exact owner it installed, so rollback cannot withdraw a replacement.
/// </summary>
internal sealed class DeferredLiveSessionUiAuthority
: ILiveInWorldSource,
ILiveWorldSessionSource
{
private readonly object _gate = new();
private ILiveUiSessionTarget? _target;
private bool _deactivated;
public bool IsInWorld
{
get
{
lock (_gate)
return !_deactivated && _target?.IsInWorld == true;
}
}
public WorldSession? CurrentSession
{
get
{
lock (_gate)
return !_deactivated ? _target?.CurrentSession : null;
}
}
public ICommandBus Commands
{
get
{
lock (_gate)
return !_deactivated
? _target?.Commands ?? NullCommandBus.Instance
: NullCommandBus.Instance;
}
}
public string? AccountName => CurrentSession?.Characters?.AccountName;
public LinkStatusSnapshot LinkStatus =>
CurrentSession?.LinkStatus ?? LinkStatusSnapshot.Disconnected;
public IDisposable Bind(ILiveUiSessionTarget target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null)
{
throw new InvalidOperationException(
"The retained-UI live-session authority is already bound.");
}
_target = target;
}
return new ExpectedOwnerBinding<ILiveUiSessionTarget>(
target,
Release);
}
public void Deactivate()
{
lock (_gate)
{
_deactivated = true;
_target = null;
}
}
public bool TryUseItem(uint guid, Action<string>? log = null)
{
WorldSession? session = CurrentSession;
if (!IsInWorld || session is null)
return false;
uint sequence = session.NextGameActionSequence();
session.SendGameAction(InteractRequests.BuildUse(sequence, guid));
log?.Invoke($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={sequence}");
return true;
}
private void Release(ILiveUiSessionTarget expected)
{
lock (_gate)
{
if (ReferenceEquals(_target, expected))
_target = null;
}
}
}
/// <summary>
/// Selection operations mounted by retained UI before the Phase-6 world query
/// and interaction controller exist.
/// </summary>
internal sealed class DeferredSelectionUiAuthority
{
private readonly object _gate = new();
private IRetainedUiSelectionQuery? _query;
private SelectionInteractionController? _interactions;
private bool _deactivated;
public IDisposable Bind(
IRetainedUiSelectionQuery query,
SelectionInteractionController interactions)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(interactions);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_query is not null || _interactions is not null)
{
throw new InvalidOperationException(
"The retained-UI selection authority is already bound.");
}
_query = query;
_interactions = interactions;
}
return new ExpectedSelectionBinding(this, query, interactions);
}
public void Deactivate()
{
lock (_gate)
{
_deactivated = true;
_query = null;
_interactions = null;
}
}
public uint? PickAtCursor(bool includeSelf)
{
SelectionInteractionController? target;
lock (_gate)
target = !_deactivated ? _interactions : null;
return target?.PickAtCursor(includeSelf);
}
public void SendUse(uint guid)
{
SelectionInteractionController? target;
lock (_gate)
target = !_deactivated ? _interactions : null;
target?.SendUse(guid);
}
public void SendPickup(uint itemGuid, uint destinationContainerId, int placement)
{
SelectionInteractionController? target;
lock (_gate)
target = !_deactivated ? _interactions : null;
target?.SendPickup(itemGuid, destinationContainerId, placement);
}
public uint? SelectClosestCombatTarget(bool showToast)
{
SelectionInteractionController? target;
lock (_gate)
target = !_deactivated ? _interactions : null;
return target?.SelectClosestCombatTarget(showToast);
}
public bool IsWithinExternalContainerUseRange(uint targetGuid)
{
IRetainedUiSelectionQuery? query;
lock (_gate)
query = !_deactivated ? _query : null;
return query?.IsWithinExternalContainerUseRange(targetGuid) != false;
}
public bool ShouldShowHealth(uint guid)
{
IRetainedUiSelectionQuery? query;
lock (_gate)
query = !_deactivated ? _query : null;
return query?.ShouldShowHealth(guid) == true;
}
public VividTargetInfo? ResolveVividTargetInfo(uint guid)
{
IRetainedUiSelectionQuery? query;
lock (_gate)
query = !_deactivated ? _query : null;
return query?.ResolveVividTargetInfo(guid);
}
private void Release(
IRetainedUiSelectionQuery expectedQuery,
SelectionInteractionController expectedInteractions)
{
lock (_gate)
{
if (ReferenceEquals(_query, expectedQuery)
&& ReferenceEquals(_interactions, expectedInteractions))
{
_query = null;
_interactions = null;
}
}
}
private sealed class ExpectedSelectionBinding : IDisposable
{
private DeferredSelectionUiAuthority? _owner;
private readonly IRetainedUiSelectionQuery _query;
private readonly SelectionInteractionController _interactions;
public ExpectedSelectionBinding(
DeferredSelectionUiAuthority owner,
IRetainedUiSelectionQuery query,
SelectionInteractionController interactions)
{
_owner = owner;
_query = query;
_interactions = interactions;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Release(_query, _interactions);
}
}
/// <summary>Identity view plane until Phase 7 binds portal presentation.</summary>
internal sealed class DeferredSelectionViewPlaneSource
{
private ISelectionViewPlaneSource? _target;
private bool _deactivated;
public IDisposable Bind(ISelectionViewPlaneSource target)
{
ArgumentNullException.ThrowIfNull(target);
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null)
{
throw new InvalidOperationException(
"The retained-UI selection view plane is already bound.");
}
_target = target;
return new ExpectedOwnerBinding<ISelectionViewPlaneSource>(target, Release);
}
public ICamera Apply(ICamera camera) =>
!_deactivated && _target is { } target
? target.ApplyViewPlane(camera)
: camera;
public void Deactivate()
{
_deactivated = true;
_target = null;
}
private void Release(ISelectionViewPlaneSource expected)
{
if (ReferenceEquals(_target, expected))
_target = null;
}
}
internal sealed class SelectionCameraSource
{
private readonly CameraController _camera;
private readonly IView _window;
private readonly DeferredSelectionViewPlaneSource _viewPlane;
public SelectionCameraSource(
CameraController camera,
IView window,
DeferredSelectionViewPlaneSource viewPlane)
{
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_window = window ?? throw new ArgumentNullException(nameof(window));
_viewPlane = viewPlane ?? throw new ArgumentNullException(nameof(viewPlane));
}
public SelectionCameraSnapshot Snapshot()
{
ICamera camera = _viewPlane.Apply(_camera.Active);
return new SelectionCameraSnapshot(
camera.View,
camera.Projection,
new Vector2(_window.Size.X, _window.Size.Y));
}
public (Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport) UiSnapshot()
{
SelectionCameraSnapshot snapshot = Snapshot();
return (snapshot.View, snapshot.Projection, snapshot.Viewport);
}
}
internal sealed class DeferredRadarSnapshotSource
{
private RadarSnapshotProvider? _target;
private bool _deactivated;
public UiRadarSnapshot Snapshot() =>
!_deactivated && _target is { } target
? target.BuildSnapshot()
: UiRadarSnapshot.Empty;
public IDisposable Bind(RadarSnapshotProvider target)
{
ArgumentNullException.ThrowIfNull(target);
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null)
throw new InvalidOperationException("The retained-UI radar is already bound.");
_target = target;
return new ExpectedOwnerBinding<RadarSnapshotProvider>(target, Release);
}
public void Deactivate()
{
_deactivated = true;
_target = null;
}
private void Release(RadarSnapshotProvider expected)
{
if (ReferenceEquals(_target, expected))
_target = null;
}
}
internal sealed class DeferredInventoryContainerSource
{
private RetailUiRuntime? _runtime;
private bool _deactivated;
public uint Current(uint playerGuid) =>
!_deactivated
? _runtime?.InventoryPanelController?.CurrentOpenContainerId ?? playerGuid
: playerGuid;
public IDisposable Bind(RetailUiRuntime runtime)
{
ArgumentNullException.ThrowIfNull(runtime);
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_runtime is not null)
throw new InvalidOperationException("The retained inventory container is already bound.");
_runtime = runtime;
return new ExpectedOwnerBinding<RetailUiRuntime>(runtime, Release);
}
public void Deactivate()
{
_deactivated = true;
_runtime = null;
}
private void Release(RetailUiRuntime expected)
{
if (ReferenceEquals(_runtime, expected))
_runtime = null;
}
}
internal sealed class PlayerCharacterOptionsState
{
public PlayerDescriptionParser.CharacterOptions1 Options { get; set; } =
PlayerDescriptionParser.CharacterOptions1.Default;
public bool DragItemOnPlayerOpensSecureTrade =>
(Options
& PlayerDescriptionParser.CharacterOptions1
.DragItemOnPlayerOpensSecureTrade) != 0;
public void Reset() => Options = PlayerDescriptionParser.CharacterOptions1.Default;
}
internal sealed class ShortcutSnapshotState
{
private IReadOnlyList<AcDream.Core.Items.ShortcutEntry> _items =
Array.Empty<AcDream.Core.Items.ShortcutEntry>();
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Items
{
get => _items;
set => _items = value ?? Array.Empty<AcDream.Core.Items.ShortcutEntry>();
}
}
/// <summary>
/// Probe scripts are mounted in Phase 5; reveal/resource facts become valid in
/// Phase 7. Calls remain inert and diagnostic until that exact owner binds.
/// </summary>
internal sealed class DeferredWorldLifecycleAutomationRuntime
: IRetailUiAutomationRuntime
{
private IRetailUiAutomationRuntime? _target;
private bool _deactivated;
public bool IsWorldReady => !_deactivated && _target?.IsWorldReady == true;
public bool IsWorldViewportVisible =>
!_deactivated && _target?.IsWorldViewportVisible == true;
public int PortalMaterializationCount =>
!_deactivated ? _target?.PortalMaterializationCount ?? 0 : 0;
public IDisposable Bind(IRetailUiAutomationRuntime target)
{
ArgumentNullException.ThrowIfNull(target);
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null)
throw new InvalidOperationException("World lifecycle automation is already bound.");
_target = target;
return new ExpectedOwnerBinding<IRetailUiAutomationRuntime>(target, Release);
}
public bool TryWriteCheckpoint(string name, out string error)
{
if (!_deactivated && _target is { } target)
return target.TryWriteCheckpoint(name, out error);
error = "world lifecycle automation is not bound";
return false;
}
public bool TryRequestScreenshot(string name, out string error)
{
if (!_deactivated && _target is { } target)
return target.TryRequestScreenshot(name, out error);
error = "world lifecycle automation is not bound";
return false;
}
public bool IsScreenshotComplete(string name) =>
!_deactivated && _target?.IsScreenshotComplete(name) == true;
public void Deactivate()
{
_deactivated = true;
_target = null;
}
private void Release(IRetailUiAutomationRuntime expected)
{
if (ReferenceEquals(_target, expected))
_target = null;
}
}
internal sealed class ExpectedOwnerBinding<T> : IDisposable where T : class
{
private Action<T>? _release;
private readonly T _expected;
public ExpectedOwnerBinding(T expected, Action<T> release)
{
_expected = expected ?? throw new ArgumentNullException(nameof(expected));
_release = release ?? throw new ArgumentNullException(nameof(release));
}
public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke(_expected);
}