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

@ -726,12 +726,16 @@ oracle, platform/host/content/settings phases, and the production world/render
phase. `WorldRenderCompositionPhase` owns Region/environment, the mandatory
modern-renderer foundation, immutable terrain-worker inputs, and WB/texture/
sampler construction; every Phase-4 GL constructor has retryable prefix
ownership. `GameWindow` is 3,522 raw lines after I.5. Checkpoints I.6L remain
active, with retained UI and live presentation composition next. The current
production App build is warning-free; the App gate passes 3,373 tests / 3
intentional skips and the complete Release suite passes 7,745 tests / 5
intentional skips. I.5's corrected-diff behavior, architecture, and
adversarial reviews are clean.
ownership. `GameWindow` is 3,522 raw lines after I.5. I.6a moves interaction
and retained-UI construction into `InteractionRetainedUiCompositionPhase`;
typed exact-owner sources now bridge the later session, selection, radar,
view-plane, diagnostics, inventory, and automation owners without callbacks to
the window. The old item-use and selection wrapper methods are deleted.
`GameWindow` is 3,246 raw lines after I.6a. I.6bL remain active, with live
presentation and landblock-publication composition next. The App gate passes
3,401 tests / 3 intentional skips and the complete Release suite passes 7,773
tests / 5 intentional skips. The solution retains only the 17 warnings tracked
by #228; the I.6a behavior, architecture, and adversarial reviews are clean.
### 4.4 Exit criteria

View file

@ -274,6 +274,12 @@ ledger only after the complete pipeline is live.
- I.6 (retained UI plus live-presentation composition) is active. Its detailed
two-commit ownership and failure-test plan is
[`2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md`](2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md).
I.6a is complete: Phase 5 now composes interaction and retained UI through
typed dependencies, exact-owner late bindings, and transactional rollback.
`GameWindow.cs` is 3,246 raw lines; the App gate passes 3,401 tests / 3
intentional skips and the complete Release suite passes 7,773 tests / 5
intentional skips. I.6b live presentation and landblock publication is the
active cut.
Checkpoint I remains active until I.9 closes the complete nine-phase pipeline.
## 6. Automated acceptance

View file

@ -1,6 +1,6 @@
# GameWindow Slice 8 — Checkpoint I.6 UI and Live Presentation
**Status:** Active
**Status:** I.6a complete; I.6b active
**Parent:** `2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md`
@ -73,8 +73,9 @@ Deactivation makes all callbacks inert before runtime teardown.
### 2.3 Ownership and publication
`InteractionRetainedUiResult` carries the stable owners required by later
phases. `InteractionRetainedUiCompositionOwner` owns optional retained-UI
input/runtime resources and the late authorities. Construction uses
phases. `InteractionUiLateBindings` owns the retained input-capture lease and
the late authorities; `RetailUiRuntimeLease` remains the sole owner of the
optional host/runtime resources. Construction uses
`CompositionAcquisitionScope`; the owner is published only after all mandatory
interaction resources and every enabled retained-UI resource are complete.
@ -82,13 +83,12 @@ interaction resources and every enabled retained-UI resource are complete.
needed by existing steady-state and reset code. Publication is write-only and
rejects replacement.
Normal shutdown and construction rollback share these stages:
Normal shutdown and construction rollback preserve these stages:
1. deactivate late sources and retained input;
2. detach retained UI device callbacks;
3. dispose mounted runtime;
4. dispose host/assets/probe resources;
5. dispose item/external-container/combat owners.
1. deactivate late sources and release retained input capture;
2. dispose the retained-UI lease, which detaches device callbacks, the mounted
runtime, and the host in its established order;
3. dispose item/external-container/combat owners.
The existing retained UI lease remains the sole host/runtime owner until its
full lifecycle is migrated; the phase owner controls it but never duplicates
@ -110,6 +110,22 @@ its resources.
- three independent self-review passes: retail behavior, architecture and
ownership, adversarial failure/reentrancy.
### 2.5 I.6a completion evidence
I.6a moved the complete Phase-5 body into
`InteractionRetainedUiCompositionPhase`. Exact-owner leases now guard retained
input capture plus the late session, selection, radar, view-plane, diagnostics,
inventory, and automation bindings. The old `GameWindow` item-use/pick/query
wrappers are deleted; shared Vitals identity, mouse-before-keyboard ordering,
direct contained-item use, and the disabled-retail-UI path remain unchanged.
The focused success/failure/rollback matrix passes 28 tests. The App Release
gate passes 3,401 tests / 3 intentional skips, the complete Release suite
passes 7,773 tests / 5 intentional skips, and the solution build has only the
17 warnings already tracked by #228. `GameWindow.cs` is 3,246 raw lines after
I.6a. Behavior, ownership, and adversarial self-reviews found no remaining
actionable issue. I.6b is the next active cut.
## 3. I.6b — live presentation and landblock publication
### 3.1 Owned construction

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);
}

View file

@ -24,10 +24,40 @@ internal sealed class DevToolsInputCaptureSource
internal sealed class RetainedUiInputCaptureSlot
{
public UiRoot? Root { get; set; }
public UiRoot? Root { get; private set; }
public IDisposable Bind(UiRoot root)
{
ArgumentNullException.ThrowIfNull(root);
if (Root is not null)
throw new InvalidOperationException("Retained UI input capture is already bound.");
Root = root;
return new Binding(this, root);
}
public bool WantCaptureMouse => Root?.WantsMouse ?? false;
public bool WantCaptureKeyboard => Root?.WantsKeyboard ?? false;
private void Unbind(UiRoot expected)
{
if (ReferenceEquals(Root, expected))
Root = null;
}
private sealed class Binding : IDisposable
{
private RetainedUiInputCaptureSlot? _owner;
private readonly UiRoot _expected;
public Binding(RetainedUiInputCaptureSlot owner, UiRoot expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
}
internal sealed class CompositeInputCaptureSource : IInputCaptureSource

View file

@ -51,12 +51,27 @@ internal interface IWorldSelectionQuery
Vector3? GetCombatCameraTargetPoint(uint serverGuid);
}
internal interface IRetainedUiSelectionQuery
{
bool ShouldShowHealth(uint serverGuid);
VividTargetInfo? ResolveVividTargetInfo(uint serverGuid);
bool IsWithinExternalContainerUseRange(uint serverGuid);
}
internal interface ISelectionViewPlaneSource
{
AcDream.App.Rendering.ICamera ApplyViewPlane(
AcDream.App.Rendering.ICamera camera);
}
/// <summary>
/// Read-only owner for retail world selection and interaction classification.
/// It consumes the canonical live-entity runtime but never mutates selection,
/// movement, inventory, or network state.
/// </summary>
internal sealed class WorldSelectionQuery : IWorldSelectionQuery
internal sealed class WorldSelectionQuery
: IWorldSelectionQuery,
IRetainedUiSelectionQuery
{
private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
private const uint StuckObjectFlag = 0x0004u;

View file

@ -9,3 +9,8 @@ internal interface ILiveWorldSessionSource
{
AcDream.Core.Net.WorldSession? CurrentSession { get; }
}
internal interface ILiveUiSessionTarget : ILiveInWorldSource, ILiveWorldSessionSource
{
AcDream.UI.Abstractions.ICommandBus Commands { get; }
}

View file

@ -162,7 +162,8 @@ internal sealed class LiveSessionController
: IDisposable,
ILiveSessionFramePhase,
ILiveInWorldSource,
ILiveWorldSessionSource
ILiveWorldSessionSource,
ILiveUiSessionTarget
{
private sealed class SessionScope(
WorldSession session,

View file

@ -39,6 +39,12 @@ internal sealed class DeferredRenderFrameDiagnosticsSource
_target = target;
}
public IDisposable BindOwned(IRenderFrameDiagnosticsSnapshotSource target)
{
Bind(target);
return new Binding(this, target);
}
public void Unbind(IRenderFrameDiagnosticsSnapshotSource target)
{
ArgumentNullException.ThrowIfNull(target);
@ -51,6 +57,23 @@ internal sealed class DeferredRenderFrameDiagnosticsSource
_deactivated = true;
_target = null;
}
private sealed class Binding : IDisposable
{
private DeferredRenderFrameDiagnosticsSource? _owner;
private readonly IRenderFrameDiagnosticsSnapshotSource _target;
public Binding(
DeferredRenderFrameDiagnosticsSource owner,
IRenderFrameDiagnosticsSnapshotSource target)
{
_owner = owner;
_target = target;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_target);
}
}
internal interface ICanonicalWorldEntityCountSource

View file

@ -19,7 +19,8 @@ public sealed class GameWindow :
IGameWindowHostInputCameraPublication,
IGameWindowContentEffectsAudioPublication,
IGameWindowSettingsDevToolsPublication,
IGameWindowWorldRenderPublication
IGameWindowWorldRenderPublication,
IGameWindowInteractionRetainedUiPublication
{
private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp()
@ -337,8 +338,12 @@ public sealed class GameWindow :
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
public readonly AcDream.Core.Items.ClientObjectTable Objects = new();
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts { get; private set; }
= System.Array.Empty<AcDream.Core.Items.ShortcutEntry>();
private readonly ShortcutSnapshotState _shortcutSnapshots = new();
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts
{
get => _shortcutSnapshots.Items;
private set => _shortcutSnapshots.Items = value;
}
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
// PlayerDescription so the Vitals HUD can render those bars.
// Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment
@ -356,6 +361,8 @@ public sealed class GameWindow :
private AcDream.App.UI.UiHost? _uiHost;
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
private readonly AcDream.App.UI.RetailUiRuntimeLease _retailUiLease = new();
private InteractionUiLateBindings? _interactionUiLateBindings;
private readonly DeferredRenderFrameDiagnosticsSource _uiFrameDiagnostics = new();
private readonly AcDream.App.Combat.CombatAttackOperationsSlot
_combatAttackOperations = new();
private readonly AcDream.App.Combat.CombatFeedbackSlot
@ -427,9 +434,7 @@ public sealed class GameWindow :
}
// Retail Default_CharacterOption (acclient.h:3434). PlayerDescription
// replaces this before any in-world drag can normally occur.
private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
_characterOptions1 =
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
private readonly PlayerCharacterOptionsState _characterOptions = new();
private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new();
private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new();
@ -882,6 +887,43 @@ public sealed class GameWindow :
SamplerCache value) =>
PublishCompositionOwner(ref _samplerCache, value, "sampler cache");
void IGameWindowInteractionRetainedUiPublication.PublishInteractionRetainedUi(
InteractionRetainedUiResult result)
{
ArgumentNullException.ThrowIfNull(result);
if (_combatAttackController is not null
|| _combatTargetController is not null
|| _externalContainerLifecycle is not null
|| _itemInteractionController is not null
|| _interactionUiLateBindings is not null
|| _uiHost is not null
|| _retailUiRuntime is not null
|| _retailChatVm is not null
|| _characterSheetProvider is not null
|| _magicRuntime is not null
|| _frameScreenshots is not null)
{
throw new InvalidOperationException(
"The GameWindow composition shell already owns interaction/UI state.");
}
_combatAttackController = result.CombatAttack;
_combatTargetController = result.CombatTarget;
_externalContainerLifecycle = result.ExternalContainerLifecycle;
_itemInteractionController = result.ItemInteraction;
_interactionUiLateBindings = result.LateBindings;
if (result.RetainedUi is { } retained)
{
_uiHost = retained.Host;
_retailUiRuntime = retained.Runtime;
_vitalsVm ??= retained.Vitals;
_retailChatVm = retained.Chat;
_characterSheetProvider = retained.CharacterSheet;
_magicRuntime = retained.Magic;
_frameScreenshots = retained.Screenshots;
}
}
private static void PublishCompositionOwner<T>(
ref T? destination,
T value,
@ -1011,6 +1053,9 @@ public sealed class GameWindow :
_framebufferResize,
Console.WriteLine),
this).Compose(platform, hostInputCamera, contentEffectsAudio);
Action<string>? compositionToast = settingsDevTools.DevTools is { } composedDevTools
? text => composedDevTools.Debug.AddToast(text)
: null;
const uint initialCenterLandblockId = 0xA9B4FFFFu;
WorldRenderResult worldRender = new WorldRenderCompositionPhase(
@ -1029,367 +1074,57 @@ public sealed class GameWindow :
$"loading world view centered on " +
$"0x{worldRender.TerrainBuild.InitialCenterLandblockId:X8}");
// Retail ClientCombatSystem attack-request owner. This exists independently
// of the retained UI so keyboard combat keeps the same press/hold/release
// semantics even when the retail renderer is disabled.
_combatAttackController = new AcDream.App.Combat.CombatAttackController(
Combat,
_combatAttackOperations);
_combatTargetController = new AcDream.App.Combat.CombatTargetController(
Combat,
_selection,
autoTarget: () => _runtimeSettings.Gameplay.AutoTarget,
selectClosestTarget: () => SelectClosestCombatTarget(showToast: false));
_externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController(
_externalContainers,
Objects,
guid => LiveSession?.SendNoLongerViewingContents(guid));
AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!;
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
Objects,
playerGuid: () => _playerServerGuid,
// ItemHolder::UseObject is the common policy owner, but world
// activation still has to pass through the application-level
// use adapter. It owns speculative turn/move, the close-range
// deferred send, and ACE's far-range MoveTo callback. Calling
// WorldSession directly here made keyboard R approach a corpse
// without completing the same open transaction as double-click.
sendUse: SendUse,
sendExamine: g => LiveSession?.SendAppraise(g),
sendUseWithTarget: (source, target) => LiveSession?.SendUseWithTarget(source, target),
sendWield: (item, mask) => LiveSession?.SendGetAndWieldItem(item, mask),
sendDrop: item => LiveSession?.SendDropItem(item),
sendGive: (target, item, amount) =>
LiveSession?.SendGiveObject(target, item, amount),
dragOnPlayerOpensSecureTrade: () =>
(_characterOptions1
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
.DragItemOnPlayerOpensSecureTrade) != 0,
toast: text => _debugVm?.AddToast(text),
readyForInventoryRequest: () => _liveSessionHost?.IsInWorld == true,
playerOnGround: GetDebugPlayerOnGround,
inNonCombatMode: () => Combat.CurrentMode
== AcDream.Core.Combat.CombatMode.NonCombat,
combatState: Combat,
sendChangeCombatMode: mode =>
LiveSession?.SendChangeCombatMode(mode),
isComponentPack: magicCatalog.IsComponentPack,
placeInBackpack: SendPickUp,
backpackContainerId: () =>
_retailUiRuntime?.InventoryPanelController?.CurrentOpenContainerId
?? _playerServerGuid,
// Retail ItemHolder::DetermineUseResult treats children of the
// current ground object as loot. Keep this late-bound because
// ViewContents can replace/close the external container while
// the retained controller remains alive.
groundObjectId: () => _externalContainers.CurrentContainerId,
sendSplitToWorld: (item, amount) =>
LiveSession?.SendStackableSplitTo3D(item, amount),
selectedObjectId: () => _selection.SelectedObjectId ?? 0u,
stackSplitQuantity: _stackSplitQuantity,
systemMessage: text => Chat.OnSystemMessage(text, 0x1Au),
sendPutItemInContainer: (item, container, placement) =>
LiveSession?.SendPutItemInContainer(item, container, placement),
sendSplitToContainer: (item, container, placement, amount) =>
LiveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
requestExternalContainer: guid => _externalContainers.RequestOpen(guid));
InteractionRetainedUiResult interactionUi =
new InteractionRetainedUiCompositionPhase(
new InteractionRetainedUiDependencies(
_options,
platform.Graphics,
_window!,
platform.Input,
shadersDir,
contentEffectsAudio.Dats,
_datLock,
worldRender.Foundation.TextureCache,
worldRender.Foundation.DebugFont,
_hostQuiescence,
_retainedInputCapture,
hostInputCamera.InputDispatcher,
_runtimeSettings,
Combat,
_combatAttackOperations,
_selection,
_externalContainers,
Objects,
contentEffectsAudio.MagicCatalog,
SpellBook,
Chat,
LocalPlayer,
ItemMana,
_stackSplitQuantity,
_uiRegistry,
_liveCombatModeCommands,
_localPlayerIdentity,
_playerControllerSlot,
_localPlayerMode,
_characterOptions,
_shortcutSnapshots,
viewPlane => new SelectionCameraSource(
hostInputCamera.CameraController,
_window!,
viewPlane),
_uiFrameDiagnostics,
settingsDevTools.DevTools?.Vitals,
compositionToast,
ClientTimerNow,
Console.WriteLine),
_retailUiLease,
this).Compose(
hostInputCamera,
contentEffectsAudio,
settingsDevTools,
worldRender);
AcDream.App.World.DeferredLiveEntityParentAcceptance? parentAcceptance = null;
// Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
if (_options.RetailUi)
{
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = _retailUiLease.AcquireHost(
() => new AcDream.App.UI.UiHost(
_gl,
shadersDir,
_debugFont,
_hostQuiescence));
_retainedInputCapture.Root = _uiHost.Root;
_uiHost.Root.UiLocked = _runtimeSettings.Gameplay.LockUI;
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
_itemInteractionController,
// Retail UpdateCursorState (0x00564630) keys target-mode
// valid/invalid off the SmartBox found object — the world
// entity under the cursor, self included.
worldTargetProvider: () => PickWorldGuidAtCursor(includeSelf: true) ?? 0u,
combatModeProvider: () => Combat.CurrentMode);
var retailCursorManager = new RetailCursorManager(_dats!, _datLock);
_characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider(
Objects, LocalPlayer,
playerGuid: () => _playerServerGuid,
activeToonName: () => _runtimeSettings.ActiveToonKey,
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
canSendRaise: () => _liveSessionHost?.IsInWorld == true,
sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost),
sendRaiseVital: (statId, cost) => LiveSession?.SendRaiseVital(statId, cost),
sendRaiseSkill: (statId, cost) => LiveSession?.SendRaiseSkill(statId, cost),
sendTrainSkill: (statId, credits) => LiveSession?.SendTrainSkill(statId, credits));
_magicRuntime = AcDream.App.Spells.MagicRuntime.Create(
magicCatalog,
SpellBook,
Objects,
selectedObject: () => _selection.SelectedObjectId,
localPlayerId: () => _playerServerGuid,
// SpellFormula::Randomize hashes the canonical account spelling
// delivered by CharacterList.
accountName: () => LiveSession?.Characters?.AccountName
?? _options.LiveUser
?? string.Empty,
stopCompletely: _combatAttackOperations.PrepareAttackRequest,
sendUntargeted: spellId => LiveSession?.SendCastUntargetedSpell(spellId),
sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId),
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
canSend: () => _liveSessionHost?.IsInWorld == true);
// Feed Silk input to the UiRoot tree so windows drag / close / select.
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
// same devices) is gated off via WantCaptureMouse/Keyboard above when the
// pointer is over a widget — no double-handling.
foreach (var m in _input!.Mice) _uiHost.WireMouse(m);
foreach (var kb in _input!.Keyboards) _uiHost.WireKeyboard(kb);
var cache = _textureCache!;
(uint, int, int) ResolveChrome(uint id)
{
uint t = cache.GetOrUploadRenderSurface(id, out int w, out int h);
return (t, w, h);
}
// Phase D.5.1 — icon composer for the toolbar shortcut slots.
// Constructed once here so the closure below can capture it; needs
// the same cache reference that ResolveChrome uses above.
var iconComposer = new AcDream.App.UI.IconComposer(_dats!, cache);
// Phase D.2b — optional retail stylesheet. controls.ini lives under
// the AC install (ACDREAM_AC_DIR); absent → source-verified fallback.
var controls = _options.AcDir is { } acDir
? AcDream.App.UI.ControlsIni.Load(System.IO.Path.Combine(acDir, "controls", "controls.ini"))
: AcDream.App.UI.ControlsIni.Parse(string.Empty);
// Phase D.2b — retail dat-font for the vitals numbers (Font 0x40000000,
// Latin-1, 16px, outline atlas). Passed into the importer so the meter
// number overlay renders through the dat-font two-pass blit; falls back to
// the debug font only if it fails to load. Under _datLock like other reads.
AcDream.App.UI.UiDatFont? vitalsDatFont;
lock (_datLock)
vitalsDatFont = AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!);
var datFontCache = new System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.App.UI.UiDatFont?>();
if (vitalsDatFont is not null)
datFontCache.TryAdd(AcDream.App.UI.UiDatFont.DefaultFontId, vitalsDatFont);
AcDream.App.UI.UiDatFont? ResolveDatFont(uint fontDid)
=> datFontCache.GetOrAdd(fontDid, id =>
{
lock (_datLock)
return AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!, id);
});
Console.WriteLine(vitalsDatFont 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.");
_uiHost.Root.Width = _window!.Size.X;
_uiHost.Root.Height = _window.Size.Y;
var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
Objects,
() => _visibleEntitiesByServerGuid,
() => LastSpawns,
playerGuid: () => _playerServerGuid,
playerYawRadians: () => _playerController?.Yaw ?? 0f,
playerCellId: () => _playerController?.CellId ?? 0u,
selectedGuid: () => _selection.SelectedObjectId,
coordinatesOnRadar: () => _runtimeSettings.Gameplay.CoordinatesOnRadar,
uiLocked: () => _runtimeSettings.Gameplay.LockUI,
playerEntities: () => _entitiesByServerGuid,
spatialQuery: () => _worldState);
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
_retailChatVm = retailChatVm;
AcDream.UI.Abstractions.Panels.Settings.SettingsStore? layoutStore =
_runtimeSettings.LayoutStore;
AcDream.App.UI.RetailUiPersistenceBindings? persistence = layoutStore is null
? null
: new AcDream.App.UI.RetailUiPersistenceBindings(
layoutStore,
CharacterKey: () => _runtimeSettings.ActiveToonKey,
ScreenSize: () => (_window.Size.X, _window.Size.Y));
void UiProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message);
if (_options.UiProbeEnabled
&& _options.AutomationArtifactDirectory is { } artifactDirectory)
{
_frameScreenshots = new AcDream.App.Diagnostics.FrameScreenshotController(
_gl!,
System.IO.Path.Combine(artifactDirectory, "screenshots"),
UiProbeLog);
_worldLifecycleAutomation =
new AcDream.App.Diagnostics.WorldLifecycleAutomationController(
() => _worldReveal?.Snapshot ?? default,
() => _worldReveal?.PortalMaterializationCount ?? 0,
CaptureWorldLifecycleResourceSnapshot,
_frameScreenshots,
artifactDirectory,
UiProbeLog);
}
var retailUiBindings = new AcDream.App.UI.RetailUiRuntimeBindings(
Host: _uiHost,
Assets: new AcDream.App.UI.RetailUiAssets(
_dats!, _datLock, ResolveChrome, ResolveDatFont,
vitalsDatFont, _debugFont, controls, iconComposer),
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
Chat: new AcDream.App.UI.ChatRuntimeBindings(
retailChatVm,
() => _liveSessionHost?.Commands
?? AcDream.UI.Abstractions.NullCommandBus.Instance),
Radar: new AcDream.App.UI.RadarRuntimeBindings(
radarSnapshotProvider.BuildSnapshot,
_selection,
_runtimeSettings.SetUiLocked),
Combat: new AcDream.App.UI.CombatRuntimeBindings(
Combat,
_combatAttackController,
() => _runtimeSettings.Gameplay,
_runtimeSettings.SetCombatGameplay),
Magic: new AcDream.App.UI.MagicRuntimeBindings(
SpellBook,
_magicRuntime.Casting,
Objects,
() => _playerServerGuid,
magicCatalog.Components,
(type, icon, under, over, effects) =>
iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) =>
iconComposer.GetDragIcon(type, icon, under, over, effects),
iconComposer.GetSpellIcon,
iconComposer.GetSpellComponentIcon,
_selection,
magicCatalog.GetSpellLevel,
guid => _selection.Select(
guid, AcDream.Core.Selection.SelectionChangeSource.Inventory),
UseItemByGuid,
(tab, position, spellId) =>
LiveSession?.SendAddSpellFavorite(spellId, position, tab),
(tab, spellId) =>
LiveSession?.SendRemoveSpellFavorite(spellId, tab),
filters => LiveSession?.SendSpellbookFilter(filters),
spellId => LiveSession?.SendRemoveSpell(spellId),
(componentId, amount) =>
LiveSession?.SendSetDesiredComponentLevel(componentId, amount),
ClientTimerNow),
JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings(
() => _playerController?.JumpCharge ?? default),
Fps: new AcDream.App.UI.FpsRuntimeBindings(
() => _renderFrameDiagnostics?.Snapshot.Fps ?? 60.0,
// Retail displays either the user degrade bias or the live
// distance-driven multiplier. acdream does not yet implement
// adaptive distance degradation (tracked by TS-15), so its
// effective multiplier is exactly 1.0.
() => 1.0,
() => _runtimeSettings.DisplayPreview.ShowFps),
VividTarget: new AcDream.App.UI.Layout.VividTargetRuntimeBindings(
_selection,
() => _playerServerGuid,
() => _runtimeSettings.Gameplay.VividTargetingIndicator,
ResolveVividTargetInfo,
GetSelectionCamera),
Indicators: new AcDream.App.UI.IndicatorRuntimeBindings(
SpellBook,
Objects,
() => _playerServerGuid,
() => LocalPlayer.GetAttribute(
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
() => LiveSession?.LinkStatus
?? AcDream.Core.Net.LinkStatusSnapshot.Disconnected,
ClientTimerNow,
() => LiveSession?.RequestLinkStatusPing(),
() => _window?.Close()),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects,
() => Shortcuts,
(type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) => iconComposer.GetDragIcon(type, icon, under, over, effects),
UseItemByGuid,
Combat,
ItemMana,
_liveCombatModeCommands.Toggle,
_itemInteractionController,
entry => LiveSession?.SendAddShortcut(entry),
index => LiveSession?.SendRemoveShortcut(index),
_selection,
handler => Combat.HealthChanged += handler,
handler => Combat.HealthChanged -= handler,
IsHealthBarTarget,
guid => Objects.Get(guid)?.GetAppropriateName(),
Combat.GetHealthPercent,
Combat.HasHealth,
guid => (uint)(Objects.Get(guid)?.StackSize ?? 0),
guid => LiveSession?.SendQueryHealth(guid),
guid => LiveSession?.SendQueryItemMana(guid),
() => _playerServerGuid,
(item, container, placement) =>
LiveSession?.SendPutItemInContainer(item, container, placement)),
Character: new AcDream.App.UI.CharacterRuntimeBindings(_characterSheetProvider),
Inventory: new AcDream.App.UI.InventoryRuntimeBindings(
Objects,
() => _playerServerGuid,
(type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) => iconComposer.GetDragIcon(type, icon, under, over, effects),
() => LocalPlayer.GetAttribute(
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
guid => LiveSession?.SendUse(guid),
(item, container, placement) =>
LiveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) =>
LiveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
(source, target, amount) =>
LiveSession?.SendStackableMerge(source, target, amount),
_itemInteractionController,
_selection),
ExternalContainer: new AcDream.App.UI.ExternalContainerRuntimeBindings(
_externalContainers,
Objects,
(type, icon, under, over, effects) =>
iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) =>
iconComposer.GetDragIcon(type, icon, under, over, effects),
_itemInteractionController,
_selection,
guid => LiveSession?.SendUse(guid),
(item, container, placement) =>
LiveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) =>
LiveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
IsWithinExternalContainerUseRange),
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
cursorFeedbackController,
retailCursorManager),
Confirmations: new AcDream.App.UI.ConfirmationRuntimeBindings(
(type, context, accepted) =>
LiveSession?.SendConfirmationResponse(type, context, accepted)),
StackSplitQuantity: _stackSplitQuantity,
Plugins: _uiRegistry,
Persistence: persistence,
Probe: new AcDream.App.UI.RetailUiProbeBindings(
_options.UiProbeEnabled,
_options.UiProbeScript,
_options.UiProbeDump,
UiProbeLog,
action => _inputDispatcher?.TryInvokeAutomationAction(action) == true,
(action, held) =>
_inputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
_worldLifecycleAutomation));
_retailUiRuntime = _retailUiLease.Mount(
() => AcDream.App.UI.RetailUiRuntime.CreateUninitialized(
retailUiBindings));
}
// Phase N.4 Task 12: construct LandblockSpawnAdapter under the feature flag
// and rebuild _worldState so it threads the adapter in. _worldState starts
// as an unadorned GpuWorldState (field initializer); here we replace it with
@ -1687,19 +1422,12 @@ public sealed class GameWindow :
_liveEntities,
Objects,
_retailSelectionScene,
() => _playerServerGuid,
() =>
{
var camera = GetSelectionCamera();
return new AcDream.App.Interaction.SelectionCameraSnapshot(
camera.View,
camera.Projection,
camera.Viewport);
},
() => _localPlayerIdentity.ServerGuid,
interactionUi.LateBindings.SelectionCamera.Snapshot,
() => new System.Numerics.Vector2(
_pointerPosition.X,
_pointerPosition.Y),
() => _playerController is { } player
() => _playerControllerSlot.Controller is { } player
? new AcDream.App.Interaction.PlayerInteractionPose(
player.CellId,
player.Position)
@ -1707,11 +1435,11 @@ public sealed class GameWindow :
_liveEntityMotionBindings.GetSetupCylinder,
setupId =>
{
if (_dats is null)
return null;
lock (_datLock)
{
if (!_dats.TryGet<DatReaderWriter.DBObjs.Setup>(setupId, out var setup)
if (!contentEffectsAudio.Dats.TryGet<DatReaderWriter.DBObjs.Setup>(
setupId,
out var setup)
|| setup.SelectionSphere is not { } sphere)
{
return null;
@ -1719,6 +1447,22 @@ public sealed class GameWindow :
return (sphere.Origin, sphere.Radius);
}
});
var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
Objects,
() => _liveEntities.WorldEntities,
() => _liveEntities.Snapshots,
playerGuid: () => _localPlayerIdentity.ServerGuid,
playerYawRadians: () => _playerControllerSlot.Controller?.Yaw ?? 0f,
playerCellId: () => _playerControllerSlot.Controller?.CellId ?? 0u,
selectedGuid: () => _selection.SelectedObjectId,
coordinatesOnRadar: () =>
_runtimeSettings.Gameplay.CoordinatesOnRadar,
uiLocked: () => _runtimeSettings.Gameplay.LockUI,
playerEntities: () => _liveEntities.MaterializedWorldEntities,
spatialQuery: () => _worldState);
interactionUi.LateBindings.AdoptLateOwnerBinding(
"radar snapshot",
interactionUi.LateBindings.Radar.Bind(radarSnapshotProvider));
if (_itemInteractionController is { } itemInteractions)
{
_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(
@ -1726,12 +1470,17 @@ public sealed class GameWindow :
_worldSelectionQuery,
itemInteractions,
new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport(
() => LiveSession),
() => interactionUi.LateBindings.Session.CurrentSession),
new AcDream.App.Interaction.PlayerInteractionMovementSink(
() => _playerController,
() => _playerControllerSlot.Controller,
_playerApproachCompletions),
text => _debugVm?.AddToast(text),
compositionToast,
_playerApproachCompletions);
interactionUi.LateBindings.AdoptLateOwnerBinding(
"world selection",
interactionUi.LateBindings.Selection.Bind(
_worldSelectionQuery,
_selectionInteractions));
}
if (_uiHost is { } retainedHost
&& _selectionInteractions is { } selectionInteractions)
@ -1898,6 +1647,9 @@ public sealed class GameWindow :
resourceDiagnostics);
_devToolsComposition?.LateBindings.FrameDiagnostics.Bind(
_renderFrameDiagnostics);
interactionUi.LateBindings.AdoptLateOwnerBinding(
"render-frame diagnostics",
_uiFrameDiagnostics.BindOwned(_renderFrameDiagnostics));
// Apply radii from the same immutable quality snapshot used for the
// window's MSAA, terrain anisotropy, and dispatcher A2C setup.
@ -2031,6 +1783,9 @@ public sealed class GameWindow :
sealedDungeonCells,
Console.WriteLine);
_liveSessionController = new AcDream.App.Net.LiveSessionController();
interactionUi.LateBindings.AdoptLateOwnerBinding(
"live session",
interactionUi.LateBindings.Session.Bind(_liveSessionController));
var localPhysicsTimestamps =
new AcDream.App.World.LiveSessionLocalPhysicsTimestampPublisher(
_localPlayerIdentity,
@ -2320,6 +2075,10 @@ public sealed class GameWindow :
new AcDream.App.Streaming.LocalPlayerTeleportPresentation(
portalTunnel)));
_localPlayerTeleportSink.Bind(_localPlayerTeleport);
interactionUi.LateBindings.AdoptLateOwnerBinding(
"selection view plane",
interactionUi.LateBindings.SelectionViewPlane.Bind(
_localPlayerTeleport));
var teleportRenderState =
new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
_localPlayerTeleport);
@ -2489,6 +2248,24 @@ public sealed class GameWindow :
worldScenePasses,
_renderRange,
worldSceneDiagnostics);
if (_frameScreenshots is { } screenshots
&& _options.AutomationArtifactDirectory is { } artifactDirectory)
{
void UiProbeLog(string message) =>
Console.WriteLine("[UI-PROBE] " + message);
_worldLifecycleAutomation =
new AcDream.App.Diagnostics.WorldLifecycleAutomationController(
() => _worldReveal?.Snapshot ?? default,
() => _worldReveal?.PortalMaterializationCount ?? 0,
CaptureWorldLifecycleResourceSnapshot,
screenshots,
artifactDirectory,
UiProbeLog);
interactionUi.LateBindings.AdoptLateOwnerBinding(
"world lifecycle automation",
interactionUi.LateBindings.Automation.Bind(
_worldLifecycleAutomation));
}
AcDream.App.Rendering.IRetainedGameplayUiFrame? retainedGameplayUi =
_options.RetailUi && _retailUiRuntime is not null
? new AcDream.App.Rendering.RetainedGameplayUiFrame(
@ -2738,8 +2515,7 @@ public sealed class GameWindow :
Chat.ResetSessionIdentity();
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = 0u;
_runtimeSettings.ResetActiveCharacterKey();
_characterOptions1 =
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
_characterOptions.Reset();
_localPlayerSkills.ResetSession();
_liveEntityNetworkUpdates?.ResetSessionState();
_liveEntityHydration?.ResetSessionState();
@ -2834,7 +2610,7 @@ public sealed class GameWindow :
OnConfirmationDone: done =>
_retailUiRuntime?.HandleConfirmationDone(done),
OnCharacterOptions: (options1, _) =>
_characterOptions1 =
_characterOptions.Options =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
ClientTime: ClientTimerNow);
@ -2998,9 +2774,6 @@ public sealed class GameWindow :
// EXPECTED-DIFF: local sidestep pacing now matches how remotes have
// always played (w6-cutover-map.md R3).
private bool GetDebugPlayerOnGround() =>
_playerMode && _playerController is not null && !_playerController.IsAirborne;
private void SyncToolbarWindowButtons()
{
_retailUiRuntime?.SyncToolbarWindowButtons();
@ -3023,62 +2796,6 @@ public sealed class GameWindow :
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
=> _framebufferResize.Resize(newSize);
/// <summary>
/// Item-target-mode world pick at the current cursor. The renderer supplies the
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs
/// retail's drawing-sphere broadphase followed by flat visual-polygon
/// intersection. <paramref name="includeSelf"/> allows item target-use to
/// pick the local player while plain selection excludes it.
/// </summary>
private uint? PickWorldGuidAtCursor(bool includeSelf)
=> _selectionInteractions?.PickAtCursor(includeSelf)
?? _worldSelectionQuery?.PickAtCursor(includeSelf);
// Contained/toolbar activation is not a world-selection interaction: it
// sends directly without speculative TurnToObject/MoveToObject.
private void UseItemByGuid(uint guid)
{
AcDream.Core.Net.WorldSession? session = LiveSession;
if (_liveSessionHost?.IsInWorld != true || session is null)
return;
uint sequence = session.NextGameActionSequence();
session.SendGameAction(
AcDream.Core.Net.Messages.InteractRequests.BuildUse(sequence, guid));
Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={sequence}");
}
private bool IsWithinExternalContainerUseRange(uint targetGuid)
=> _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true;
private void SendUse(uint guid)
=> _selectionInteractions?.SendUse(guid);
private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement)
=> _selectionInteractions?.SendPickup(itemGuid, destinationContainerId, placement);
private uint? SelectClosestCombatTarget(bool showToast)
=> _selectionInteractions?.SelectClosestCombatTarget(showToast);
private bool IsHealthBarTarget(uint guid)
=> _worldSelectionQuery?.ShouldShowHealth(guid) == true;
private (System.Numerics.Matrix4x4 View,
System.Numerics.Matrix4x4 Projection,
System.Numerics.Vector2 Viewport) GetSelectionCamera()
{
if (_cameraController is null || _window is null)
return (System.Numerics.Matrix4x4.Identity,
System.Numerics.Matrix4x4.Identity,
System.Numerics.Vector2.Zero);
var camera = _localPlayerTeleport?.ApplyViewPlane(_cameraController.Active)
?? _cameraController.Active;
return (camera.View, camera.Projection,
new System.Numerics.Vector2(_window.Size.X, _window.Size.Y));
}
private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid)
=> _worldSelectionQuery?.ResolveVividTargetInfo(guid);
// #107 (2026-06-10): memoized "this indoor spawn claim can never hydrate"
// check against the dat's LandBlockInfo.NumCells. Used by the auto-entry
// hold so a garbage claim doesn't stall login forever; the Resolve-head
@ -3260,6 +2977,14 @@ public sealed class GameWindow :
]),
new ResourceShutdownStage("session dependents",
[
new("interaction/UI late bindings", () =>
{
InteractionUiLateBindings? bindings = _interactionUiLateBindings;
if (bindings is null)
return;
bindings.Dispose();
_interactionUiLateBindings = null;
}),
new("mouse capture", () =>
{
AcDream.App.Input.CameraPointerInputController? pointer =
@ -3277,7 +3002,6 @@ public sealed class GameWindow :
throw new InvalidOperationException(
"The retained UI ownership lease did not complete disposal.");
_retailUiRuntime = null;
_retainedInputCapture.Root = null;
_uiHost = null;
}),
new("combat target", () =>

View file

@ -353,6 +353,7 @@ internal sealed class LocalPlayerTeleportPresentation
internal sealed class LocalPlayerTeleportController
: ILocalPlayerTeleportFramePhase,
ILocalPlayerTeleportNetworkSink,
AcDream.App.Interaction.ISelectionViewPlaneSource,
IDisposable
{
internal const float MaximumWorldHoldSeconds = 10f;

View file

@ -0,0 +1,332 @@
using System.Runtime.CompilerServices;
using AcDream.App.Combat;
using AcDream.App.Composition;
using AcDream.App.Diagnostics;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.World;
namespace AcDream.App.Tests.Composition;
public sealed class InteractionRetainedUiCompositionTests
{
private static readonly InteractionRetainedUiCompositionPoint[] UiPoints =
[
InteractionRetainedUiCompositionPoint.UiHostAcquired,
InteractionRetainedUiCompositionPoint.InputCaptureBound,
InteractionRetainedUiCompositionPoint.CursorAssetsCreated,
InteractionRetainedUiCompositionPoint.CharacterSheetCreated,
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
InteractionRetainedUiCompositionPoint.MouseInputWired,
InteractionRetainedUiCompositionPoint.KeyboardInputWired,
InteractionRetainedUiCompositionPoint.UiAssetsCreated,
InteractionRetainedUiCompositionPoint.UiProbeCreated,
InteractionRetainedUiCompositionPoint.UiRuntimeMounted,
InteractionRetainedUiCompositionPoint.InventoryContainerBound,
];
[Fact]
public void EnabledUiPublishesOneExactResultAfterFrozenConstructionOrder()
{
var fixture = new Fixture(retailUi: true);
InteractionRetainedUiResult result = fixture.Compose();
Assert.Same(result, fixture.Publication.Result);
Assert.Equal(
[
InteractionRetainedUiCompositionPoint.LateBindingsCreated,
InteractionRetainedUiCompositionPoint.CombatAttackCreated,
InteractionRetainedUiCompositionPoint.CombatTargetCreated,
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
.. UiPoints,
InteractionRetainedUiCompositionPoint.ResultPublished,
], fixture.Points);
Assert.NotNull(result.RetainedUi);
Assert.Empty(fixture.Factory.Releases);
}
[Fact]
public void DisabledUiAcquiresNoRetainedFrontendResource()
{
var fixture = new Fixture(retailUi: false);
InteractionRetainedUiResult result = fixture.Compose();
Assert.Null(result.RetainedUi);
Assert.Equal(
[
InteractionRetainedUiCompositionPoint.LateBindingsCreated,
InteractionRetainedUiCompositionPoint.CombatAttackCreated,
InteractionRetainedUiCompositionPoint.CombatTargetCreated,
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
InteractionRetainedUiCompositionPoint.RetainedUiDisabled,
InteractionRetainedUiCompositionPoint.ResultPublished,
], fixture.Points);
Assert.Equal(0, fixture.Factory.RetainedUiCalls);
}
[Theory]
[MemberData(nameof(EnabledFailurePoints))]
public void FaultAtEveryEnabledBoundaryStopsSuffixAndRollsBackUnpublishedPrefix(
int pointValue)
{
var point = (InteractionRetainedUiCompositionPoint)pointValue;
var fixture = new Fixture(retailUi: true, failurePoint: point);
Assert.Throws<InvalidOperationException>(fixture.Compose);
Assert.Equal(point, fixture.Points[^1]);
if (point == InteractionRetainedUiCompositionPoint.ResultPublished)
{
Assert.Empty(fixture.Factory.Releases);
Assert.NotNull(fixture.Publication.Result);
}
else
{
Assert.Null(fixture.Publication.Result);
Assert.Equal(ExpectedRollback(point), fixture.Factory.Releases);
}
}
public static TheoryData<int> EnabledFailurePoints()
{
var data = new TheoryData<int>();
foreach (InteractionRetainedUiCompositionPoint point in
Enum.GetValues<InteractionRetainedUiCompositionPoint>())
{
if (point != InteractionRetainedUiCompositionPoint.RetainedUiDisabled)
data.Add((int)point);
}
return data;
}
private static string[] ExpectedRollback(
InteractionRetainedUiCompositionPoint point)
{
var acquired = new List<string> { "late bindings" };
if (point >= InteractionRetainedUiCompositionPoint.CombatAttackCreated)
acquired.Add("combat attack");
if (point >= InteractionRetainedUiCompositionPoint.CombatTargetCreated)
acquired.Add("combat target");
if (point >= InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated)
acquired.Add("external container");
if (point >= InteractionRetainedUiCompositionPoint.ItemInteractionCreated)
acquired.Add("item interaction");
if (point >= InteractionRetainedUiCompositionPoint.UiHostAcquired)
acquired.Add("retained UI lease");
acquired.Reverse();
return acquired.ToArray();
}
[Fact]
public void PublicationFailureRollsBackCompleteUnpublishedPrefix()
{
var fixture = new Fixture(retailUi: true, publicationFailure: true);
Assert.Throws<InvalidOperationException>(fixture.Compose);
Assert.Equal(
[
"retained UI lease",
"item interaction",
"external container",
"combat target",
"combat attack",
"late bindings",
], fixture.Factory.Releases);
}
[Fact]
public void GameWindowUsesPhaseAndContainsNoRetainedUiConstructionBody()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
Assert.Contains("new InteractionRetainedUiCompositionPhase(", source);
Assert.DoesNotContain("new AcDream.App.UI.ItemInteractionController(", source);
Assert.DoesNotContain("_retailUiLease.AcquireHost(", source);
Assert.DoesNotContain("RetailUiRuntime.CreateUninitialized(", source);
Assert.DoesNotContain("private void UseItemByGuid(", source);
Assert.DoesNotContain("private uint? PickWorldGuidAtCursor(", source);
}
private sealed class Fixture
{
private readonly InteractionRetainedUiCompositionPoint? _failurePoint;
public Fixture(
bool retailUi,
InteractionRetainedUiCompositionPoint? failurePoint = null,
bool publicationFailure = false)
{
_failurePoint = failurePoint;
Factory = new FakeFactory();
Publication = new Publication(publicationFailure);
RuntimeOptions options = RuntimeOptions.Parse("dat", static _ => null)
with { RetailUi = retailUi };
Dependencies = new InteractionRetainedUiDependencies(
Options: options,
Gl: null!,
Window: null!,
Input: null!,
ShadersDirectory: "shaders",
Dats: null!,
DatLock: new object(),
TextureCache: null!,
DebugFont: null,
HostQuiescence: null!,
RetainedInputCapture: null!,
InputDispatcher: null,
Settings: null!,
Combat: null!,
CombatAttackOperations: null!,
Selection: null!,
ExternalContainers: null!,
Objects: null!,
MagicCatalog: null!,
Spellbook: null!,
Chat: null!,
LocalPlayer: null!,
ItemMana: null!,
StackSplitQuantity: null!,
UiRegistry: null,
CombatModeCommands: null!,
PlayerIdentity: null!,
PlayerController: null!,
PlayerMode: null!,
CharacterOptions: null!,
Shortcuts: null!,
SelectionCameraFactory: static _ => Stub<SelectionCameraSource>(),
FrameDiagnostics: new DeferredRenderFrameDiagnosticsSource(),
ExistingVitals: null,
Toast: null,
ClientTime: static () => 0d,
Log: static _ => { });
}
public InteractionRetainedUiDependencies Dependencies { get; }
public FakeFactory Factory { get; }
public Publication Publication { get; }
public List<InteractionRetainedUiCompositionPoint> Points { get; } = [];
public InteractionRetainedUiResult Compose() =>
new InteractionRetainedUiCompositionPhase(
Dependencies,
new RetailUiRuntimeLease(),
Publication,
Factory,
point =>
{
Points.Add(point);
if (_failurePoint == point)
throw new InvalidOperationException($"fault at {point}");
}).Compose();
}
private sealed class FakeFactory : IInteractionRetainedUiCompositionFactory
{
private readonly Dictionary<object, string> _names =
new(ReferenceEqualityComparer.Instance);
public List<string> Releases { get; } = [];
public int RetainedUiCalls { get; private set; }
public CombatAttackController CreateCombatAttack(
InteractionRetainedUiDependencies dependencies) =>
Resource<CombatAttackController>("combat attack");
public CombatTargetController CreateCombatTarget(
InteractionRetainedUiDependencies dependencies,
DeferredSelectionUiAuthority selection) =>
Resource<CombatTargetController>("combat target");
public ExternalContainerLifecycleController CreateExternalContainerLifecycle(
InteractionRetainedUiDependencies dependencies,
DeferredLiveSessionUiAuthority session) =>
Resource<ExternalContainerLifecycleController>("external container");
public ItemInteractionController CreateItemInteraction(
InteractionRetainedUiDependencies dependencies,
InteractionUiLateBindings lateBindings) =>
Resource<ItemInteractionController>("item interaction");
public RetainedUiComposition CreateRetainedUi(
InteractionRetainedUiDependencies dependencies,
InteractionUiLateBindings lateBindings,
RetailUiRuntimeLease lease,
CombatAttackController combatAttack,
ItemInteractionController itemInteraction,
Action<InteractionRetainedUiCompositionPoint> checkpoint)
{
RetainedUiCalls++;
_names.Add(lease, "retained UI lease");
foreach (InteractionRetainedUiCompositionPoint point in UiPoints)
checkpoint(point);
return new RetainedUiComposition(
Stub<UiHost>(),
Stub<RetailUiRuntime>(),
Stub<AcDream.UI.Abstractions.Panels.Vitals.VitalsVM>(),
Stub<AcDream.UI.Abstractions.Panels.Chat.ChatVM>(),
Stub<CharacterSheetProvider>(),
Stub<AcDream.App.Spells.MagicRuntime>(),
null);
}
public void Release(IDisposable resource)
{
string name = _names.TryGetValue(resource, out string? found)
? found
: resource switch
{
InteractionUiLateBindings => "late bindings",
RetailUiRuntimeLease => "retained UI lease",
_ => throw new InvalidOperationException(
$"Unknown test resource {resource.GetType().Name}"),
};
Releases.Add(name);
}
private T Resource<T>(string name) where T : class
{
T value = Stub<T>();
_names.Add(value, name);
return value;
}
}
private sealed class Publication(bool fail)
: IGameWindowInteractionRetainedUiPublication
{
public InteractionRetainedUiResult? Result { get; private set; }
public void PublishInteractionRetainedUi(InteractionRetainedUiResult result)
{
if (fail)
throw new InvalidOperationException("publication failed");
Result = result;
}
}
private static T Stub<T>() where T : class =>
(T)RuntimeHelpers.GetUninitializedObject(typeof(T));
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}

View file

@ -0,0 +1,223 @@
using System.Numerics;
using System.Runtime.CompilerServices;
using AcDream.App.Composition;
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.Core.Net;
using AcDream.Core.World;
using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.Composition;
public sealed class InteractionUiRuntimeSourcesTests
{
[Fact]
public void SessionAuthorityDefaultsBindUnbindRebindAndDeactivateExactly()
{
var source = new DeferredLiveSessionUiAuthority();
var first = new SessionTarget(true);
var second = new SessionTarget(false);
Assert.False(source.IsInWorld);
Assert.Same(NullCommandBus.Instance, source.Commands);
IDisposable stale = source.Bind(first);
Assert.True(source.IsInWorld);
Assert.Same(first.Commands, source.Commands);
Assert.Throws<InvalidOperationException>(() => source.Bind(second));
stale.Dispose();
IDisposable current = source.Bind(second);
stale.Dispose();
Assert.False(source.IsInWorld);
Assert.Same(second.Commands, source.Commands);
source.Deactivate();
current.Dispose();
Assert.Same(NullCommandBus.Instance, source.Commands);
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
}
[Fact]
public void SelectionAuthorityUsesRetailSafeDefaultsAndExactBindingLifetime()
{
var source = new DeferredSelectionUiAuthority();
var query = new SelectionQuery();
SelectionInteractionController interactions =
Stub<SelectionInteractionController>();
Assert.True(source.IsWithinExternalContainerUseRange(1u));
Assert.False(source.ShouldShowHealth(1u));
Assert.Null(source.ResolveVividTargetInfo(1u));
IDisposable binding = source.Bind(query, interactions);
Assert.False(source.IsWithinExternalContainerUseRange(1u));
Assert.True(source.ShouldShowHealth(1u));
Assert.Equal(3f, source.ResolveVividTargetInfo(1u)?.SelectionSphereRadius);
Assert.Throws<InvalidOperationException>(() => source.Bind(query, interactions));
binding.Dispose();
Assert.True(source.IsWithinExternalContainerUseRange(1u));
source.Deactivate();
Assert.Throws<ObjectDisposedException>(() => source.Bind(query, interactions));
}
[Fact]
public void RadarNeverCachesAnUnboundBootstrapSnapshot()
{
var source = new DeferredRadarSnapshotSource();
Assert.Same(UiRadarSnapshot.Empty, source.Snapshot());
var provider = new RadarSnapshotProvider(
new AcDream.Core.Items.ClientObjectTable(),
static () => new Dictionary<uint, WorldEntity>(),
static () => new Dictionary<uint, WorldSession.EntitySpawn>(),
static () => 0u,
static () => 0f,
static () => 0u,
static () => null,
static () => false,
static () => true);
IDisposable binding = source.Bind(provider);
Assert.True(source.Snapshot().UiLocked);
binding.Dispose();
Assert.False(source.Snapshot().UiLocked);
}
[Fact]
public void AutomationProxyIsInertUntilExactRuntimeBinds()
{
var source = new DeferredWorldLifecycleAutomationRuntime();
Assert.False(source.TryWriteCheckpoint("early", out string earlyError));
Assert.Contains("not bound", earlyError);
var target = new AutomationRuntime();
IDisposable binding = source.Bind(target);
Assert.True(source.IsWorldReady);
Assert.True(source.TryWriteCheckpoint("ready", out _));
Assert.Equal("ready", target.LastCheckpoint);
binding.Dispose();
Assert.False(source.IsWorldReady);
source.Deactivate();
Assert.Throws<ObjectDisposedException>(() => source.Bind(target));
}
[Fact]
public void ExpectedOwnerBindingReleasesOnce()
{
object target = new();
int releases = 0;
var binding = new ExpectedOwnerBinding<object>(target, value =>
{
Assert.Same(target, value);
releases++;
});
binding.Dispose();
binding.Dispose();
Assert.Equal(1, releases);
}
[Fact]
public void RetainedInputCaptureBindingCannotClearAReplacement()
{
var slot = new RetainedUiInputCaptureSlot();
var first = new UiRoot();
var second = new UiRoot();
IDisposable stale = slot.Bind(first);
Assert.Same(first, slot.Root);
Assert.Throws<InvalidOperationException>(() => slot.Bind(second));
stale.Dispose();
IDisposable current = slot.Bind(second);
stale.Dispose();
Assert.Same(second, slot.Root);
current.Dispose();
Assert.Null(slot.Root);
}
[Fact]
public void LateBindingCleanupRetriesOnlyTheFailedSuffix()
{
var bindings = new InteractionUiLateBindings();
var first = new RetryBinding(failures: 0);
var second = new RetryBinding(failures: 1);
bindings.AdoptLateOwnerBinding("first", first);
bindings.AdoptLateOwnerBinding("second", second);
Assert.Throws<AggregateException>(bindings.Dispose);
Assert.Equal(1, first.Calls);
Assert.Equal(1, second.Calls);
bindings.Dispose();
Assert.Equal(1, first.Calls);
Assert.Equal(2, second.Calls);
Assert.Throws<ObjectDisposedException>(() =>
bindings.AdoptLateOwnerBinding("late", new RetryBinding(0)));
}
private sealed class SessionTarget(bool inWorld) : ILiveUiSessionTarget
{
public bool IsInWorld { get; } = inWorld;
public WorldSession? CurrentSession => null;
public ICommandBus Commands { get; } = new LiveCommandBus();
}
private sealed class SelectionQuery : IRetainedUiSelectionQuery
{
public bool ShouldShowHealth(uint serverGuid) => true;
public VividTargetInfo? ResolveVividTargetInfo(uint serverGuid) =>
new(Vector3.Zero, 3f, 0u, 0u);
public bool IsWithinExternalContainerUseRange(uint serverGuid) => false;
}
private sealed class AutomationRuntime
: AcDream.App.UI.Testing.IRetailUiAutomationRuntime
{
public bool IsWorldReady => true;
public bool IsWorldViewportVisible => true;
public int PortalMaterializationCount => 2;
public string? LastCheckpoint { get; private set; }
public bool TryWriteCheckpoint(string name, out string error)
{
LastCheckpoint = name;
error = string.Empty;
return true;
}
public bool TryRequestScreenshot(string name, out string error)
{
error = string.Empty;
return true;
}
public bool IsScreenshotComplete(string name) => true;
}
private sealed class RetryBinding(int failures) : IDisposable
{
private int _failures = failures;
public int Calls { get; private set; }
public void Dispose()
{
Calls++;
if (_failures > 0)
{
_failures--;
throw new InvalidOperationException("retry");
}
}
}
private static T Stub<T>() where T : class =>
(T)RuntimeHelpers.GetUninitializedObject(typeof(T));
}

View file

@ -58,10 +58,9 @@ public sealed class GameWindowSlice8BoundaryTests
"this).Compose(platform, hostInputCamera);",
"new SettingsDevToolsCompositionPhase(",
"this).Compose(platform, hostInputCamera, contentEffectsAudio);",
"_uiHost = _retailUiLease.AcquireHost(",
"_uiHost.WireMouse(m)",
"_uiHost.WireKeyboard(kb)",
"_retailUiRuntime = _retailUiLease.Mount(",
"InteractionRetainedUiResult interactionUi =",
"new InteractionRetainedUiCompositionPhase(",
"this).Compose(",
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
"_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(",
"_retainedUiGameplayBinding =",
@ -220,7 +219,7 @@ public sealed class GameWindowSlice8BoundaryTests
"private AcDream.App.Net.LiveSessionHost");
string body = MethodBody(
"private void OnFramebufferResize(",
"private uint? PickWorldGuidAtCursor(");
"private (uint Claim, bool Unhydratable)? _spawnClaimRangeMemo;");
Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal);
Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal);