acdream/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
Erik 06512f0957 feat(ui): House tab ownership text — DisplayPurchaseTimeText + RuntimeHouseState
Derived the mechanism from the decomp before writing code: neither
gmHouseUI::PostInit @0x004a2710 nor gmMapUI::PostInit @0x004a1c70 sends a
HouseQuery, and six of gmHouseUI's seven Display* builders early-return on
m_pHouseData == 0. The only text a houseless character's House tab shows is
gmHouseUI::DisplayPurchaseTimeText @0x004a3110's expired branch (it doesn't
gate on m_pHouseData) — the local player's PropertyInt.HousePurchaseTimestamp
plus HouseSystem::HasPurchaseWaitPeriodExpired renders exactly "You may buy
another house immediately." for a fresh character. Exhaustive search of the
2013 EoR decomp, ACE, and the live DAT found zero support for a second
"You do not currently own a house." line the task brief described — this
commit ports what the decomp actually shows.

Ships:
- RuntimeHouseState: a minimal (no disposal, no construction-transaction
  Fault() point) Runtime owner per ISSUES #413's own sizing note, wired
  through GameEventWiring's existing HouseData/HouseStatus delegate holes,
  LiveSessionEventRouter, and GameRuntime.HouseOwner. Participates in
  RuntimeGenerationReset (new House stage) since a fresh login must not
  show a stale character's house state.
- HousePageController.Bindings.Lines/OnShown wired to real data; OnShown
  fires WorldSession.SendHouseQuery() on tab-open (AD-107: an acdream
  trigger, not a ported retail call site — filed in the divergence
  register).
- Fixed a real bug found along the way: HousePageController.Bind never
  wired UiTemplateListBox.TemplateResolver, so no row could ever render
  regardless of Lines content. Now reuses the Map tab's generic hotspot
  resolver.

Live-verified against a real local ACE server and the +Acdream character
(--session-config auto-select + a UI automation script): screenshot and
structural UI-tree dump both confirm the House tab renders exactly "You may
buy another house immediately." Graceful logout confirmed both launches.

ISSUES #413 narrowed to its one remaining piece: the six owned-house-only
Display* builders (DisplayBuyPayment/RentPayment/BuyTime/RentTimes/
Location/WarningText), unexercisable without a test character that owns a
house.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 03:38:16 +02:00

1281 lines
61 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 DatReaderWriter.DBObjs;
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,
// Batch C (Map/House toolbar panel): the same shape as ClientTime above —
// GameWindow's WorldTimeService is a stable for-the-window-lifetime
// service (unlike the per-session entity/world state Radar's deferred
// slots exist for), so a direct closure is enough; no DeferredXSource
// needed. gmMapUI::Update @0x004a1eb0 reads GameTime::current_game_time
// every 5s — MapPageController owns that cadence, this just supplies the
// current reading.
Func<AcDream.Core.World.DerethDateTime.Calendar> CurrentCalendar)
{
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);
// AD-98 filtering fidelity: re-wired unconditionally on every
// composition, same as the UiLocked assignment below — the lease can
// hand back a HOST from a previous session while d.TextureCache is a
// fresh instance for this one, so a stale resolver would keep
// resolving twins against a disposed TextureCache.
host.TextRenderer.LinearTwinResolver = d.TextureCache.GetOrCreateLinearUiTwin;
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);
// Review fix round F12 (2026-08-15): constructed ONCE per
// composition and captured by the ResolveText closure below,
// rather than a fresh DatStringResolver per lookup. The
// Heritage/Town pages' description composers each call
// ResolveText several times per Refresh, and CharacterCreation-
// UiController.ApplyProgressState forces a full refresh on
// every page switch (`_lastRevision = long.MinValue`) — so an
// uncached resolver meant several fresh allocations + DatLock
// acquisitions per click. DatStringResolver's own constructor
// does no DAT I/O (only .Resolve reads), so building it here
// outside the lock matches this file's existing pattern
// elsewhere (construct once, lock only around Resolve calls).
var characterCreationStrings = new DatStringResolver(d.Dats);
// CC5 review fix round F3 (2026-08-16): read the global
// SkillTable (portal.dat 0x0E000004 — the SAME file
// ChargenOptions.GlobalSkillCostsBySkillId's own doc comment and
// LiveSessionRuntimeFactory.CreateCharacterBindings already read)
// ONCE at composition time, under the DatLock DatCollection's
// thread-safety contract requires — mirrors LiveSkillCreditResolver's
// own constructor-time load. The resolver itself does no further
// DAT access per call (pure SkillFormula arithmetic), so the
// Summary page's GetSkillScore binding below needs no lock.
SkillTable? chargenSkillTable;
lock (d.DatLock)
chargenSkillTable = d.Dats.Get<SkillTable>(0x0E000004u);
var chargenSkillScoreResolver = new ChargenSkillScoreResolver(chargenSkillTable);
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),
// #409 follow-on ("Item 2"): world-object hover tooltip.
// Reuses the SAME world-hover pick cursorFeedback's own
// worldTargetProvider already calls (UIElement_SmartBoxWrapper::
// FindObject's 3D-raycast fallback — RetailWorldPicker's exact
// port) and the SAME ClientObjectTable name resolver
// ResolveWorldObjectName already uses elsewhere in this file.
WorldTooltip: new WorldTooltipRuntimeBindings(
HoverGuidAtCursor: () => late.Selection.PickAtCursor(includeSelf: true),
ResolveName: guid => d.Inventory.Objects.Get(guid)?.GetAppropriateName(),
Enabled: () => d.Character.Options.GetOptionBit(
CharacterOptionId.ShowTooltips)),
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),
// Batch C (overnight hover/UI round, 2026-08-17): HouseLines/
// HouseShown now wired to the minimal RuntimeHouseState
// owner (see its class doc) — HousePosition (the Map tab's
// house marker) is deferred to #413's remaining owned-house
// work, since it needs HouseData's Position field, not yet
// consumed here.
MapHouse: new MapHouseRuntimeBindings(
CurrentCalendar: d.CurrentCalendar,
PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u,
HouseLines: () => d.Runtime.HouseOwner.Lines,
// Not a ported retail call site — neither gmHouseUI::
// PostInit nor gmMapUI::PostInit sends an outbound
// HouseQuery; this is the acdream "fire when the House
// tab is shown" convenience HousePageController.Bindings.
// OnShown's own doc already documents.
HouseShown: () => late.Session.CurrentSession?.SendHouseQuery()),
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,
// Campaign LA gate round 2 finding 1: the SAME
// window-close path GameplayInputCommandController's
// Escape fallback uses (IGameplayWindowCommands.Close
// /GameplayWindowCommands wrap this same d.Window.Close
// delegate) — no separate exit path.
d.Window.Close)
: null,
// Campaign CC slice CC4: same late-bound generation-capturing
// seam as CharacterSelection above. RequestExit here is a
// plain presentation action (closing the chargen screen and
// letting character-management's own Tick keep re-drawing
// itself underneath — see CharacterCreationUiController's
// OnExit doc), NOT a Runtime command or a window-close.
CharacterCreation: d.Options.LiveCharacterSelector is null
? new CharacterCreationRuntimeBindings(
() => late.GameRuntime.CharacterCreation,
late.GameRuntime.CharacterCreationSelectHeritage,
late.GameRuntime.CharacterCreationSelectGender,
late.GameRuntime.CharacterCreationSelectTemplate,
late.GameRuntime.CharacterCreationSetAttribute,
late.GameRuntime.CharacterCreationSetAttributeLock,
late.GameRuntime.CharacterCreationTrainSkill,
late.GameRuntime.CharacterCreationSpecializeSkill,
late.GameRuntime.CharacterCreationUntrainSkill,
late.GameRuntime.CharacterCreationSelectStartArea,
late.GameRuntime.CharacterCreationFinish,
RequestExit: () => { },
SetAppearanceIndex: late.GameRuntime.CharacterCreationSetAppearanceIndex,
SetShade: late.GameRuntime.CharacterCreationSetShade,
ResolveText: key =>
{
lock (d.DatLock)
{
return characterCreationStrings.Resolve(
0x23000002u,
DatStringResolver.ComputeHash(key));
}
},
SetName: late.GameRuntime.CharacterCreationSetName,
AcknowledgeRejection: late.GameRuntime.CharacterCreationAcknowledgeRejection,
RandomizeCharacter: late.GameRuntime.CharacterCreationRandomizeCharacter,
RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance,
RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing,
GetSkillScore: chargenSkillScoreResolver.Resolve,
OpenOnStart: d.Options.OpenCharacterCreationOnStart)
: 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);
}