1168 lines
53 KiB
C#
1168 lines
53 KiB
C#
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.Net.Messages;
|
|
using AcDream.Core.Player;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Session;
|
|
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.Windowing;
|
|
|
|
namespace AcDream.App.Composition;
|
|
|
|
/// <param name="Graphics">
|
|
/// The backend handle this composition was built against. Only the consistency
|
|
/// check reads it — Campaign V slice V6h moved the retained UI's last raw-GL
|
|
/// use, the probe screenshot reader, onto <see cref="BackbufferReader"/>.
|
|
/// </param>
|
|
/// <param name="BackbufferReader">
|
|
/// Reads the presented frame as tightly packed RGBA8 in the backend's own row
|
|
/// order; <see cref="FrameScreenshotController"/> applies the bottom-up flip
|
|
/// glReadPixels needs, so a top-left-origin backend pre-flips to cancel it.
|
|
/// </param>
|
|
internal sealed record InteractionRetainedUiDependencies(
|
|
RuntimeOptions Options,
|
|
GameWindowGraphics Graphics,
|
|
Func<int, int, byte[]> BackbufferReader,
|
|
IView Window,
|
|
IInputContext Input,
|
|
string ShadersDirectory,
|
|
IDatReaderWriter Dats,
|
|
object DatLock,
|
|
TextureCache TextureCache,
|
|
BitmapFont? DebugFont,
|
|
HostQuiescenceGate HostQuiescence,
|
|
RetainedUiInputCaptureSlot RetainedInputCapture,
|
|
InputDispatcher? InputDispatcher,
|
|
// Campaign OP slice OP8: the portable keybinds.json path
|
|
// (ApplicationPathSet.KeyBindingsFile) — the Configure Keyboard screen's
|
|
// Save button writes here, same file GameWindow's startup load reads.
|
|
string KeyBindingsFilePath,
|
|
RuntimeSettingsController Settings,
|
|
GameRuntime Runtime,
|
|
IRuntimeCombatAttackOperations CombatAttackOperations,
|
|
RuntimeCombatTargetOperationsSlot CombatTargetOperations,
|
|
RuntimeSpellCastOperationsSlot SpellCastOperations,
|
|
MagicCatalog MagicCatalog,
|
|
StackSplitQuantityState StackSplitQuantity,
|
|
BufferedUiRegistry? UiRegistry,
|
|
LiveCombatModeCommandSlot CombatModeCommands,
|
|
ILocalPlayerIdentitySource PlayerIdentity,
|
|
ILocalPlayerModeSource PlayerMode,
|
|
Func<DeferredSelectionViewPlaneSource, SelectionCameraSource>
|
|
SelectionCameraFactory,
|
|
DeferredRenderFrameDiagnosticsSource FrameDiagnostics,
|
|
VitalsVM? ExistingVitals,
|
|
Action<string>? Toast,
|
|
Func<double> ClientTime,
|
|
Action<string> Log,
|
|
AcDream.App.Rendering.Gpu.IGpuDevice GpuDevice,
|
|
ICurrentGpuFrameSource GpuFrameSource)
|
|
{
|
|
public RuntimeActionState Actions => Runtime.ActionOwner;
|
|
|
|
public RuntimeInventoryState Inventory => Runtime.InventoryOwner;
|
|
|
|
public RuntimeCharacterState Character => Runtime.CharacterOwner;
|
|
|
|
public RuntimeCommunicationState Communication =>
|
|
Runtime.CommunicationOwner;
|
|
|
|
public IRuntimeLocalPlayerControllerSource PlayerController =>
|
|
Runtime.MovementOwner;
|
|
}
|
|
|
|
internal sealed record RetainedUiComposition(
|
|
UiHost Host,
|
|
RetailUiRuntime Runtime,
|
|
VitalsVM Vitals,
|
|
ChatVM Chat,
|
|
CharacterSheetProvider CharacterSheet,
|
|
FrameScreenshotController? Screenshots);
|
|
|
|
internal sealed record InteractionRetainedUiResult(
|
|
RuntimeCombatAttackState CombatAttack,
|
|
ExternalContainerLifecycleController ExternalContainerLifecycle,
|
|
ItemInteractionController ItemInteraction,
|
|
MagicRuntime Magic,
|
|
RetainedUiComposition? RetainedUi,
|
|
InteractionUiLateBindings LateBindings);
|
|
|
|
internal interface IGameWindowInteractionRetainedUiPublication
|
|
{
|
|
void PublishInteractionRetainedUi(InteractionRetainedUiResult result);
|
|
}
|
|
|
|
internal enum InteractionRetainedUiCompositionPoint
|
|
{
|
|
LateBindingsCreated,
|
|
CombatTargetCreated,
|
|
ExternalContainerLifecycleCreated,
|
|
ItemInteractionCreated,
|
|
MagicRuntimeCreated,
|
|
RetainedUiDisabled,
|
|
UiHostAcquired,
|
|
InputCaptureBound,
|
|
CursorAssetsCreated,
|
|
CharacterSheetCreated,
|
|
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 DeferredGameRuntimeStateCommands GameRuntime { 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();
|
|
GameRuntime.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
|
|
{
|
|
IDisposable BindCombatTarget(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
DeferredSelectionUiAuthority selection);
|
|
|
|
ExternalContainerLifecycleController CreateExternalContainerLifecycle(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
DeferredLiveSessionUiAuthority session);
|
|
|
|
ItemInteractionController CreateItemInteraction(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
InteractionUiLateBindings lateBindings);
|
|
|
|
MagicRuntime CreateMagicRuntime(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
InteractionUiLateBindings lateBindings,
|
|
ItemInteractionController itemInteraction);
|
|
|
|
RetainedUiComposition CreateRetainedUi(
|
|
InteractionRetainedUiDependencies dependencies,
|
|
InteractionUiLateBindings lateBindings,
|
|
RetailUiRuntimeLease lease,
|
|
RuntimeCombatAttackState combatAttack,
|
|
ItemInteractionController itemInteraction,
|
|
MagicRuntime magic,
|
|
Action<InteractionRetainedUiCompositionPoint> checkpoint);
|
|
|
|
void Release(IDisposable resource);
|
|
}
|
|
|
|
internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|
: IInteractionRetainedUiCompositionFactory
|
|
{
|
|
public IDisposable BindCombatTarget(
|
|
InteractionRetainedUiDependencies d,
|
|
DeferredSelectionUiAuthority selection) =>
|
|
d.CombatTargetOperations.BindOwned(new LiveCombatTargetOperations(
|
|
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server
|
|
// bit — see CharacterOptionCombatSettingsSource's doc comment.
|
|
autoTarget: () => d.Character.Options.GetOptionBit(
|
|
CharacterOptionId.AutoTarget),
|
|
selectClosestTarget: () =>
|
|
selection.SelectClosestCombatTarget(showToast: false)));
|
|
|
|
public ExternalContainerLifecycleController CreateExternalContainerLifecycle(
|
|
InteractionRetainedUiDependencies d,
|
|
DeferredLiveSessionUiAuthority session) =>
|
|
new(
|
|
d.Inventory.ExternalContainers,
|
|
d.Inventory.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.Inventory.Objects,
|
|
d.Actions.Transactions,
|
|
d.Actions.Interaction,
|
|
playerGuid: () => d.PlayerIdentity.ServerGuid,
|
|
sendUse: null,
|
|
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.Character.Options.DragItemOnPlayerOpensSecureTrade,
|
|
toast: d.Toast,
|
|
readyForInventoryRequest: () => session.IsInWorld,
|
|
playerOnGround: () =>
|
|
d.PlayerMode.IsPlayerMode
|
|
&& d.PlayerController.Controller is { IsAirborne: false },
|
|
inNonCombatMode: () =>
|
|
d.Actions.Combat.CurrentMode == CombatMode.NonCombat,
|
|
combatState: d.Actions.Combat,
|
|
sendChangeCombatMode: mode =>
|
|
session.CurrentSession?.SendChangeCombatMode(mode),
|
|
isComponentPack: d.MagicCatalog.IsComponentPack,
|
|
placeInBackpack: selection.SendPickup,
|
|
backpackContainerId: () => late.InventoryContainer.Current(
|
|
d.PlayerIdentity.ServerGuid),
|
|
groundObjectId: () =>
|
|
d.Inventory.ExternalContainers.CurrentContainerId,
|
|
// Slice 5.3: finally wires the dormant vendor-id seam
|
|
// (ItemInteractionPolicy.ActiveVendorId's "using an item inside
|
|
// the currently-open vendor's shop is swallowed as a no-op"
|
|
// branch, research doc §C.1). A live delegate rather than a
|
|
// captured value, so it reads 0 automatically once VendorState
|
|
// closes — no separate "clear on close" wiring needed.
|
|
activeVendorId: () => d.Inventory.Vendor.VendorId,
|
|
sendSplitToWorld: (item, amount) =>
|
|
session.CurrentSession?.SendStackableSplitTo3D(item, amount),
|
|
selectedObjectId: () =>
|
|
d.Actions.Selection.SelectedObjectId ?? 0u,
|
|
stackSplitQuantity: d.StackSplitQuantity,
|
|
systemMessage:
|
|
text => d.Communication.AddText(text, RetailLogTextType.ClientLocal),
|
|
// ServerSaysAttemptFailed / HandleFailureEvent refusal lines
|
|
// (0x00A0) — typed so per-code routing (SpewBox vs chat) follows
|
|
// WeenieErrorMessages' resolved destination.
|
|
interfaceText: (text, type) => d.Communication.AddText(text, type),
|
|
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.Inventory.ExternalContainers.RequestOpen(guid);
|
|
},
|
|
requestUse: selection.RequestUse,
|
|
// Slice 6.3: ItemInteractionController.TryBuy owns the
|
|
// reservation dance itself (see its doc comment); this is a
|
|
// plain wire send, not a second requestUse-shaped delegate.
|
|
// F4 (Slice 6 review): report whether the send actually
|
|
// happened — a null CurrentSession or a not-in-world session
|
|
// must return false so TryBuy releases the reservation instead
|
|
// of marking it dispatched for a request nothing ever sent.
|
|
sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) =>
|
|
{
|
|
if (session.CurrentSession is not { } activeSession || !session.IsInWorld)
|
|
return false;
|
|
activeSession.SendBuy(vendorGuid, itemGuid, amount, alternateCurrencyId);
|
|
return true;
|
|
},
|
|
// Slice 6b: "Buy All" — one batched 0x005F for every staged entry.
|
|
sendBuyAll: (vendorGuid, items, alternateCurrencyId) =>
|
|
{
|
|
if (session.CurrentSession is not { } activeSession || !session.IsInWorld)
|
|
return false;
|
|
activeSession.SendBuy(vendorGuid, items, alternateCurrencyId);
|
|
return true;
|
|
},
|
|
// Slice 6c: Sell (0x0060) — both "Sell Item" and "Sell All" reuse this.
|
|
sendSell: (vendorGuid, items) =>
|
|
{
|
|
if (session.CurrentSession is not { } activeSession || !session.IsInWorld)
|
|
return false;
|
|
activeSession.SendSell(vendorGuid, items);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
public MagicRuntime CreateMagicRuntime(
|
|
InteractionRetainedUiDependencies d,
|
|
InteractionUiLateBindings late,
|
|
ItemInteractionController itemInteraction) =>
|
|
MagicRuntime.Create(
|
|
d.MagicCatalog,
|
|
d.Actions.SpellCast,
|
|
d.SpellCastOperations,
|
|
d.Inventory.Objects,
|
|
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.Communication.AddText(text, RetailLogTextType.ClientLocal),
|
|
incrementBusy: itemInteraction.IncrementBusyCount,
|
|
canSend: () => late.Session.IsInWorld);
|
|
|
|
/// <summary>
|
|
/// Consolidated-review round (2026-08-10), SHOULD-FIX 2: extracted out
|
|
/// of <see cref="CreateRetainedUi"/> so this specific wiring — the
|
|
/// composed <see cref="ChatVM.OnInterfaceText"/> hook that routes
|
|
/// <c>ChatCommandRouter</c>'s <c>0x1A</c> refusals to
|
|
/// <see cref="RuntimeCommunicationState.AddText"/>/SpewBox — is
|
|
/// independently testable without the rest of <see cref="CreateRetainedUi"/>'s
|
|
/// GPU/dat/UiHost dependencies (all null-safe in a pure unit test; this
|
|
/// method only touches <see cref="InteractionRetainedUiDependencies.Communication"/>).
|
|
/// The a5a7eb4f defect class (a composed hook wired but never
|
|
/// transferred) is exactly what this seam existing untested let slip
|
|
/// through — see <c>InteractionRetainedUiCompositionTests.ComposedChatViewModelWiresOnInterfaceTextToSpewBox</c>.
|
|
/// </summary>
|
|
internal static ChatVM CreateChatViewModel(InteractionRetainedUiDependencies d) =>
|
|
new ChatVM(
|
|
d.Communication.Chat,
|
|
displayLimit: 200,
|
|
commandTargets: d.Communication.CommandTargets)
|
|
{
|
|
// Issue #363 / #367: routes ChatCommandRouter's 0x1A
|
|
// (ClientLocal) command refusals to the same SpewBox
|
|
// chokepoint every other interface-text producer uses,
|
|
// instead of the chat scroll.
|
|
OnInterfaceText = text =>
|
|
d.Communication.AddText(text, RetailLogTextType.ClientLocal),
|
|
};
|
|
|
|
public RetainedUiComposition CreateRetainedUi(
|
|
InteractionRetainedUiDependencies d,
|
|
InteractionUiLateBindings late,
|
|
RetailUiRuntimeLease lease,
|
|
RuntimeCombatAttackState combatAttack,
|
|
ItemInteractionController itemInteraction,
|
|
MagicRuntime magic,
|
|
Action<InteractionRetainedUiCompositionPoint> checkpoint)
|
|
{
|
|
IDisposable? inputCapture = null;
|
|
IDisposable? inventoryContainer = null;
|
|
try
|
|
{
|
|
VitalsVM vitals = d.ExistingVitals
|
|
?? new VitalsVM(
|
|
d.Actions.Combat,
|
|
d.Character.LocalPlayer);
|
|
UiHost host = lease.AcquireHost(
|
|
() => new UiHost(
|
|
d.GpuDevice,
|
|
d.GpuFrameSource,
|
|
d.ShadersDirectory,
|
|
d.DebugFont,
|
|
d.HostQuiescence));
|
|
checkpoint(InteractionRetainedUiCompositionPoint.UiHostAcquired);
|
|
inputCapture = d.RetainedInputCapture.Bind(host.Root);
|
|
checkpoint(InteractionRetainedUiCompositionPoint.InputCaptureBound);
|
|
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server
|
|
// bit at mount time — see CharacterOptionCombatSettingsSource's
|
|
// doc comment. Live toggling afterward still flows through
|
|
// RuntimeSettingsController.SetUiLocked's existing
|
|
// _runtimeTargets?.ApplyUiLock push (ToggleUiLock's Bindings
|
|
// site, LiveSessionRuntimeFactory.cs, now also sends the wire
|
|
// bit — see its own comment).
|
|
host.Root.UiLocked = d.Character.Options.GetOptionBit(
|
|
CharacterOptionId.LockUI);
|
|
|
|
var cursorFeedback = new CursorFeedbackController(
|
|
itemInteraction,
|
|
worldTargetProvider: () =>
|
|
late.Selection.PickAtCursor(includeSelf: true) ?? 0u,
|
|
combatModeProvider: () =>
|
|
d.Actions.Combat.CurrentMode);
|
|
var cursorManager = new RetailCursorManager(d.Dats, d.DatLock);
|
|
checkpoint(InteractionRetainedUiCompositionPoint.CursorAssetsCreated);
|
|
|
|
var characterSheet = new CharacterSheetProvider(
|
|
d.Inventory.Objects,
|
|
d.Character.LocalPlayer,
|
|
playerGuid: () => d.PlayerIdentity.ServerGuid,
|
|
activeToonName: () => d.Settings.ActiveToonKey,
|
|
fallbackSheet: SampleData.SampleCharacter,
|
|
canSendRaise: () => late.GameRuntime.IsInWorld,
|
|
sendRaiseAttribute: (statId, cost) =>
|
|
late.GameRuntime.Advance(
|
|
RuntimeAdvancementKind.Attribute,
|
|
statId,
|
|
cost),
|
|
sendRaiseVital: (statId, cost) =>
|
|
late.GameRuntime.Advance(
|
|
RuntimeAdvancementKind.Vital,
|
|
statId,
|
|
cost),
|
|
sendRaiseSkill: (statId, cost) =>
|
|
late.GameRuntime.Advance(
|
|
RuntimeAdvancementKind.Skill,
|
|
statId,
|
|
cost),
|
|
sendTrainSkill: (statId, credits) =>
|
|
late.GameRuntime.Advance(
|
|
RuntimeAdvancementKind.TrainSkill,
|
|
statId,
|
|
credits));
|
|
checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated);
|
|
|
|
uint MagicSkillLevel(MagicSchool school)
|
|
{
|
|
static uint SkillId(MagicSchool value) => value switch
|
|
{
|
|
MagicSchool.CreatureEnchantment => 0x1Fu,
|
|
MagicSchool.ItemEnchantment => 0x20u,
|
|
MagicSchool.LifeMagic => 0x21u,
|
|
MagicSchool.WarMagic => 0x22u,
|
|
MagicSchool.VoidMagic => 0x2Bu,
|
|
_ => 0u,
|
|
};
|
|
|
|
uint skillId = SkillId(school);
|
|
if (skillId != 0u)
|
|
return d.Character.LocalPlayer.GetSkill(skillId)?.CurrentLevel ?? 0u;
|
|
|
|
uint highest = 0u;
|
|
foreach (MagicSchool candidate in new[]
|
|
{
|
|
MagicSchool.CreatureEnchantment,
|
|
MagicSchool.ItemEnchantment,
|
|
MagicSchool.LifeMagic,
|
|
MagicSchool.WarMagic,
|
|
MagicSchool.VoidMagic,
|
|
})
|
|
{
|
|
highest = Math.Max(
|
|
highest,
|
|
d.Character.LocalPlayer.GetSkill(
|
|
SkillId(candidate))?.CurrentLevel ?? 0u);
|
|
}
|
|
return highest;
|
|
}
|
|
|
|
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 = CreateChatViewModel(d);
|
|
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.BackbufferReader,
|
|
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,
|
|
d.Communication.ChatWindows,
|
|
layoutStore),
|
|
Radar: new RadarRuntimeBindings(
|
|
late.Radar.Snapshot,
|
|
d.Actions.Selection,
|
|
d.Settings.SetUiLocked),
|
|
Combat: new CombatRuntimeBindings(
|
|
d.Actions.Combat,
|
|
combatAttack),
|
|
Magic: new MagicRuntimeBindings(
|
|
d.Character.Spellbook,
|
|
magic.Casting,
|
|
d.Inventory.Objects,
|
|
() => d.PlayerIdentity.ServerGuid,
|
|
d.MagicCatalog.Components,
|
|
iconComposer.GetIcon,
|
|
iconComposer.GetDragIcon,
|
|
iconComposer.GetSpellIcon,
|
|
iconComposer.GetSpellComponentIcon,
|
|
d.Actions.Selection,
|
|
d.MagicCatalog.GetSpellLevel,
|
|
magic.GetExamineComponents,
|
|
MagicSkillLevel,
|
|
guid => d.Actions.Selection.Select(
|
|
guid,
|
|
SelectionChangeSource.Inventory),
|
|
guid => late.Session.TryUseItem(guid, d.Log),
|
|
(tab, position, spellId) =>
|
|
late.GameRuntime.AddFavorite(tab, position, spellId),
|
|
(tab, spellId) =>
|
|
late.GameRuntime.RemoveFavorite(tab, spellId),
|
|
filters => late.GameRuntime.SetSpellbookFilter(filters),
|
|
spellId => late.GameRuntime.ForgetSpell(spellId),
|
|
(componentId, amount) =>
|
|
late.GameRuntime.SetDesiredComponent(
|
|
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.Actions.Selection,
|
|
() => d.PlayerIdentity.ServerGuid,
|
|
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11):
|
|
// server bit, not the client-local GameplaySettings
|
|
// record — CH3 precedent (server-authoritative,
|
|
// local-write-then-send; the Character-tab panel row
|
|
// writes through RuntimeCharacterOptionsState.
|
|
// TrySetOption before this read can observe it).
|
|
() => d.Character.Options.GetOptionBit(
|
|
CharacterOptionId.VividTargetingIndicator),
|
|
late.Selection.ResolveVividTargetInfo,
|
|
late.SelectionCamera.UiSnapshot),
|
|
Indicators: new IndicatorRuntimeBindings(
|
|
d.Character.Spellbook,
|
|
d.Inventory.Objects,
|
|
() => d.PlayerIdentity.ServerGuid,
|
|
() => d.Character.LocalPlayer.GetEffectiveAttribute(
|
|
LocalPlayerState.AttributeKind.Strength),
|
|
() => late.Session.LinkStatus,
|
|
d.ClientTime,
|
|
() => late.Session.CurrentSession?.RequestLinkStatusPing(),
|
|
d.Window.Close),
|
|
Toolbar: new ToolbarRuntimeBindings(
|
|
d.Inventory.Objects,
|
|
d.Inventory.Shortcuts,
|
|
iconComposer.GetIcon,
|
|
iconComposer.GetDragIcon,
|
|
guid => late.Session.TryUseItem(guid, d.Log),
|
|
d.Actions.Combat,
|
|
d.Inventory.ItemMana,
|
|
d.CombatModeCommands.Toggle,
|
|
itemInteraction,
|
|
entry => late.GameRuntime.AddShortcut(entry),
|
|
index => late.GameRuntime.RemoveShortcut(index),
|
|
d.Actions.Selection,
|
|
handler => d.Actions.Combat.HealthChanged += handler,
|
|
handler => d.Actions.Combat.HealthChanged -= handler,
|
|
late.Selection.ShouldShowHealth,
|
|
guid => d.Inventory.Objects.Get(guid)?.GetAppropriateName(),
|
|
d.Actions.Combat.GetHealthPercent,
|
|
d.Actions.Combat.HasHealth,
|
|
guid =>
|
|
(uint)(d.Inventory.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),
|
|
// Slice 6.2: retail gmToolbarUI::HandleSelectionChanged's
|
|
// own vendor-owned gate (pc:198781) is literally
|
|
// "selected weenie's ContainerId == the open vendor's
|
|
// guid" — Slice 6.1 guarantees a materialized shop
|
|
// item's ContainerId IS the vendor's guid, so this reads
|
|
// straight off the same ClientObjectTable/VendorState
|
|
// pair VendorUiController.ResolveBuyQuantity (F2, Slice 6
|
|
// review) also reads, through the SAME VendorSplitPolicy
|
|
// mask helper (no second mask copy).
|
|
guid =>
|
|
d.Inventory.Vendor.VendorId != 0u
|
|
&& d.Inventory.Objects.Get(guid) is { } vendorCandidate
|
|
&& vendorCandidate.ContainerId == d.Inventory.Vendor.VendorId
|
|
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type)),
|
|
Character: new CharacterRuntimeBindings(characterSheet),
|
|
Inventory: new InventoryRuntimeBindings(
|
|
d.Inventory.Objects,
|
|
() => d.PlayerIdentity.ServerGuid,
|
|
iconComposer.GetIcon,
|
|
iconComposer.GetDragIcon,
|
|
() => d.Character.LocalPlayer.GetEffectiveAttribute(
|
|
LocalPlayerState.AttributeKind.Strength),
|
|
d.Character.Spellbook,
|
|
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.Actions.Selection),
|
|
ExternalContainer: new ExternalContainerRuntimeBindings(
|
|
d.Inventory.ExternalContainers,
|
|
d.Inventory.Objects,
|
|
iconComposer.GetIcon,
|
|
iconComposer.GetDragIcon,
|
|
itemInteraction,
|
|
d.Actions.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),
|
|
Vendor: new VendorRuntimeBindings(
|
|
d.Inventory.Vendor,
|
|
iconComposer.GetIcon,
|
|
itemInteraction,
|
|
d.Actions.Selection,
|
|
text => d.Communication.AddText(text, RetailLogTextType.ClientLocal)),
|
|
Cursor: new RetailUiCursorBindings(cursorFeedback, cursorManager),
|
|
Confirmations: new ConfirmationRuntimeBindings(
|
|
(type, context, accepted) =>
|
|
late.Session.CurrentSession?.SendConfirmationResponse(
|
|
type,
|
|
context,
|
|
accepted)),
|
|
Appraisal: new AppraisalRuntimeBindings(
|
|
characterSheet.CharacterName,
|
|
(item, inscription) =>
|
|
late.Session.CurrentSession?.SendSetInscription(
|
|
item,
|
|
inscription),
|
|
text =>
|
|
d.Communication.AddText(text, RetailLogTextType.ClientLocal)),
|
|
Options: new OptionsRuntimeBindings(
|
|
CommandBus: () => late.Session.Commands,
|
|
// Tri-state per gmGamePlayUI::UseTime @0x004EA3A0's exact
|
|
// branch structure (mechanism review S3 / blast review
|
|
// SHOULD-FIX 1, 2026-08-11 fix round): retail's airborne
|
|
// test only runs INSIDE `else if (smartbox->player)` —
|
|
// with no player object (not in player mode, or no live
|
|
// controller yet) neither the refusal nor the logoff
|
|
// itself ever fires, i.e. a SILENT no-op, not a refusal.
|
|
// The original two-way bool collapsed that third case
|
|
// into "not grounded", which fired the mid-air message
|
|
// outside player mode — the opposite of retail and the
|
|
// opposite of what the old comment here claimed.
|
|
// R2 (OP3 re-review, 2026-08-11): a NULL controller in
|
|
// player mode must ALSO yield null (silent) — the prior
|
|
// `is { IsAirborne: false }` pattern returned false for
|
|
// null and fired the refusal retail cannot produce.
|
|
IsGrounded: () =>
|
|
d.PlayerMode.IsPlayerMode
|
|
&& d.PlayerController.Controller is { } liveController
|
|
? !liveController.IsAirborne
|
|
: (bool?)null,
|
|
IsUseMouseTurningEnabled: () =>
|
|
CharacterOptionTable.TryGet(
|
|
CharacterOptionId.UseMouseTurning,
|
|
out CharacterOptionTableEntry entry)
|
|
&& (d.Character.Options.Options2 & entry.Mask) != 0u,
|
|
DisplaySystemMessage: text =>
|
|
d.Communication.AddText(text, RetailLogTextType.ClientLocal),
|
|
// OP3 review-fix round (2026-08-11), MUST-FIX M1: BYTE-
|
|
// VERIFIED against the PDB-paired binary — all six
|
|
// gmConfigUI::SetMouseTurningDefaults chat-line sites
|
|
// push `6a 07` (type=7=Magic) immediately before the
|
|
// text-pointer push and the AddTextToScroll call
|
|
// (0x0049E972/E9E2/EA52/EAA4/EAF6/EB48). Magic routes to
|
|
// the scrolling chat transcript, not the 4-slot SpewBox
|
|
// ClientLocal uses.
|
|
DisplayMouseTurningMacroLine: text =>
|
|
d.Communication.AddText(text, RetailLogTextType.Magic),
|
|
LoadCameraTurning: d.Settings.LoadCameraTurning,
|
|
SaveCameraTurning: d.Settings.SaveCameraTurning,
|
|
// Campaign OP slice OP4 (2026-08-11): the Character-tab
|
|
// panel's row-seed source.
|
|
CurrentCharacterOption: id => d.Character.Options.GetOptionBit(id),
|
|
// Campaign OP slice OP6 (2026-08-11): the Config tab's
|
|
// Display/Audio-backed rows — RuntimeSettingsController
|
|
// is the sole writer of both sections (unlike Chat; see
|
|
// RetailUiRuntime.MountOptionsPanel's own note).
|
|
LoadDisplay: () => d.Settings.Display,
|
|
SaveDisplay: d.Settings.SaveDisplay,
|
|
LoadAudio: () => d.Settings.Audio,
|
|
SaveAudio: d.Settings.SaveAudio),
|
|
// Campaign FA slice FA3: the social panel's own bindings —
|
|
// FA2's typed Fellowship/Allegiance snapshot readers off the
|
|
// GameRuntime views, plus J4.1's Friends/Squelch owners
|
|
// directly (the panel's read-only rows need full-collection
|
|
// enumeration, not the bot-facing IRuntimeSocialView's
|
|
// per-id lookup).
|
|
// Campaign FA slice FA4: the seven Fellowship write commands
|
|
// route through `late.GameRuntime` (DeferredGameRuntimeStateCommands)
|
|
// — the same late-bound, generation-race-safe seam
|
|
// AddShortcut/Advance/etc already use — NOT a raw
|
|
// WorldSession.SendXxx call (see SocialRuntimeBindings' own
|
|
// FA4 doc addendum for why Quit specifically must not
|
|
// bypass the command layer).
|
|
Social: new SocialRuntimeBindings(
|
|
() => d.Runtime.Fellowship.Snapshot,
|
|
() => d.Runtime.Allegiance.Snapshot,
|
|
d.Communication.Friends,
|
|
d.Communication.Squelch,
|
|
() => d.Runtime.Fellowship.GetMembers(),
|
|
(name, shareXp) => late.GameRuntime.FellowshipCreate(name, shareXp),
|
|
guid => late.GameRuntime.FellowshipRecruit(guid),
|
|
guid => late.GameRuntime.FellowshipDismiss(guid),
|
|
disband => late.GameRuntime.FellowshipQuit(disband),
|
|
guid => late.GameRuntime.FellowshipAssignLeader(guid),
|
|
isOpen => late.GameRuntime.FellowshipSetOpen(isOpen),
|
|
panelOpen => late.GameRuntime.FellowshipSetPanelOpen(panelOpen),
|
|
d.Actions.Selection,
|
|
() => d.PlayerIdentity.ServerGuid,
|
|
// Campaign FA slice FA5: IRuntimeAllegianceView's out-param
|
|
// accessors projected into nullable-returning delegates —
|
|
// the same shape SocialAllegiancePageController.Bindings
|
|
// wants, mirroring the Fellowship projection above.
|
|
AllegianceMonarch: () =>
|
|
d.Runtime.Allegiance.TryGetMonarch(out var monarch)
|
|
? monarch
|
|
: (RuntimeAllegianceMemberSnapshot?)null,
|
|
AllegiancePatron: guid =>
|
|
d.Runtime.Allegiance.TryGetPatron(guid, out var patron)
|
|
? patron
|
|
: (RuntimeAllegianceMemberSnapshot?)null,
|
|
AllegianceMember: guid =>
|
|
d.Runtime.Allegiance.TryGetMember(guid, out var member)
|
|
? member
|
|
: (RuntimeAllegianceMemberSnapshot?)null,
|
|
AllegianceVassals: guid => d.Runtime.Allegiance.GetVassals(guid),
|
|
AllegianceSwear: guid => late.GameRuntime.AllegianceSwear(guid),
|
|
AllegianceBreak: guid => late.GameRuntime.AllegianceBreak(guid),
|
|
AllegianceKick: guid => late.GameRuntime.AllegianceKick(guid),
|
|
AllegianceSetUpdateSubscription: on =>
|
|
late.GameRuntime.AllegianceSetUpdateSubscription(on),
|
|
Trade: d.Runtime.Trade),
|
|
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),
|
|
Keyboard: new KeyboardRuntimeBindings(
|
|
d.InputDispatcher,
|
|
d.KeyBindingsFilePath),
|
|
CharacterSelection: d.Options.LiveCharacterSelector is null
|
|
? new CharacterSelectionRuntimeBindings(
|
|
() => late.GameRuntime.CharacterSelection,
|
|
late.GameRuntime.CharacterSelectionHighlight,
|
|
late.GameRuntime.CharacterSelectionEnter,
|
|
late.GameRuntime.CharacterSelectionRequestDelete,
|
|
late.GameRuntime.CharacterSelectionConfirmDelete,
|
|
late.GameRuntime.CharacterSelectionRestore,
|
|
late.GameRuntime.CharacterSelectionCancel)
|
|
: null);
|
|
RetailUiRuntime runtime = lease.Mount(
|
|
() => RetailUiRuntime.CreateUninitialized(bindings));
|
|
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
|
|
// OP4 re-review R2: open option-bearing panels converge on every
|
|
// PlayerDescription seed (login + reconnect), closing the
|
|
// stale-rows/stale-baseline window a retained panel left open
|
|
// across the session boundary would otherwise hold.
|
|
d.Settings.ServerOptionsSeeded = () =>
|
|
{
|
|
runtime.OptionsPanelController?.OnServerOptionsSeeded();
|
|
runtime.CombatUiController?.OnServerOptionsSeeded();
|
|
};
|
|
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,
|
|
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<
|
|
GameWindowPlatformResult<GameWindowGraphics, IInputContext>,
|
|
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);
|
|
|
|
IDisposable combatTargetBinding = _factory.BindCombatTarget(
|
|
_dependencies,
|
|
late.Selection);
|
|
late.AdoptLateOwnerBinding(
|
|
"combat-target operations",
|
|
combatTargetBinding);
|
|
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 magicLease = scope.Acquire(
|
|
"magic runtime",
|
|
() => _factory.CreateMagicRuntime(
|
|
_dependencies,
|
|
late,
|
|
itemLease.Resource),
|
|
_factory.Release);
|
|
Fault(InteractionRetainedUiCompositionPoint.MagicRuntimeCreated);
|
|
|
|
var uiLease = scope.Own(
|
|
"retained UI runtime lease",
|
|
_retainedUiLease,
|
|
_factory.Release);
|
|
RetainedUiComposition? retainedUi = null;
|
|
if (_dependencies.Options.RetailUi)
|
|
{
|
|
retainedUi = _factory.CreateRetainedUi(
|
|
_dependencies,
|
|
late,
|
|
_retainedUiLease,
|
|
_dependencies.Actions.CombatAttack,
|
|
itemLease.Resource,
|
|
magicLease.Resource,
|
|
Fault);
|
|
}
|
|
else
|
|
{
|
|
Fault(InteractionRetainedUiCompositionPoint.RetainedUiDisabled);
|
|
}
|
|
|
|
var result = new InteractionRetainedUiResult(
|
|
_dependencies.Actions.CombatAttack,
|
|
externalLease.Resource,
|
|
itemLease.Resource,
|
|
magicLease.Resource,
|
|
retainedUi,
|
|
late);
|
|
_publication.PublishInteractionRetainedUi(result);
|
|
lateLease.Transfer();
|
|
externalLease.Transfer();
|
|
itemLease.Transfer();
|
|
magicLease.Transfer();
|
|
uiLease.Transfer();
|
|
Fault(InteractionRetainedUiCompositionPoint.ResultPublished);
|
|
scope.Complete();
|
|
return result;
|
|
}
|
|
catch (Exception failure)
|
|
{
|
|
scope.RollbackAndThrow(failure);
|
|
throw new System.Diagnostics.UnreachableException();
|
|
}
|
|
}
|
|
|
|
public InteractionRetainedUiResult Compose(
|
|
GameWindowPlatformResult<GameWindowGraphics, IInputContext> platform,
|
|
HostInputCameraResult host,
|
|
ContentEffectsAudioResult content,
|
|
SettingsDevToolsResult settings,
|
|
WorldRenderResult world)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(platform);
|
|
ArgumentNullException.ThrowIfNull(host);
|
|
ArgumentNullException.ThrowIfNull(content);
|
|
ArgumentNullException.ThrowIfNull(settings);
|
|
ArgumentNullException.ThrowIfNull(world);
|
|
if (!ReferenceEquals(_dependencies.Graphics, platform.Graphics)
|
|
|| !ReferenceEquals(_dependencies.Input, platform.Input)
|
|
|| !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);
|
|
}
|