refactor(app): compose interaction and retained UI startup
This commit is contained in:
parent
f663b04a54
commit
aa6ffa5176
15 changed files with 2157 additions and 467 deletions
|
|
@ -726,12 +726,16 @@ oracle, platform/host/content/settings phases, and the production world/render
|
||||||
phase. `WorldRenderCompositionPhase` owns Region/environment, the mandatory
|
phase. `WorldRenderCompositionPhase` owns Region/environment, the mandatory
|
||||||
modern-renderer foundation, immutable terrain-worker inputs, and WB/texture/
|
modern-renderer foundation, immutable terrain-worker inputs, and WB/texture/
|
||||||
sampler construction; every Phase-4 GL constructor has retryable prefix
|
sampler construction; every Phase-4 GL constructor has retryable prefix
|
||||||
ownership. `GameWindow` is 3,522 raw lines after I.5. Checkpoints I.6–L remain
|
ownership. `GameWindow` is 3,522 raw lines after I.5. I.6a moves interaction
|
||||||
active, with retained UI and live presentation composition next. The current
|
and retained-UI construction into `InteractionRetainedUiCompositionPhase`;
|
||||||
production App build is warning-free; the App gate passes 3,373 tests / 3
|
typed exact-owner sources now bridge the later session, selection, radar,
|
||||||
intentional skips and the complete Release suite passes 7,745 tests / 5
|
view-plane, diagnostics, inventory, and automation owners without callbacks to
|
||||||
intentional skips. I.5's corrected-diff behavior, architecture, and
|
the window. The old item-use and selection wrapper methods are deleted.
|
||||||
adversarial reviews are clean.
|
`GameWindow` is 3,246 raw lines after I.6a. I.6b–L 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
|
### 4.4 Exit criteria
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,12 @@ ledger only after the complete pipeline is live.
|
||||||
- I.6 (retained UI plus live-presentation composition) is active. Its detailed
|
- I.6 (retained UI plus live-presentation composition) is active. Its detailed
|
||||||
two-commit ownership and failure-test plan is
|
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).
|
[`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.
|
Checkpoint I remains active until I.9 closes the complete nine-phase pipeline.
|
||||||
|
|
||||||
## 6. Automated acceptance
|
## 6. Automated acceptance
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# GameWindow Slice 8 — Checkpoint I.6 UI and Live Presentation
|
# 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`
|
**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
|
### 2.3 Ownership and publication
|
||||||
|
|
||||||
`InteractionRetainedUiResult` carries the stable owners required by later
|
`InteractionRetainedUiResult` carries the stable owners required by later
|
||||||
phases. `InteractionRetainedUiCompositionOwner` owns optional retained-UI
|
phases. `InteractionUiLateBindings` owns the retained input-capture lease and
|
||||||
input/runtime resources and the late authorities. Construction uses
|
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
|
`CompositionAcquisitionScope`; the owner is published only after all mandatory
|
||||||
interaction resources and every enabled retained-UI resource are complete.
|
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
|
needed by existing steady-state and reset code. Publication is write-only and
|
||||||
rejects replacement.
|
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;
|
1. deactivate late sources and release retained input capture;
|
||||||
2. detach retained UI device callbacks;
|
2. dispose the retained-UI lease, which detaches device callbacks, the mounted
|
||||||
3. dispose mounted runtime;
|
runtime, and the host in its established order;
|
||||||
4. dispose host/assets/probe resources;
|
3. dispose item/external-container/combat owners.
|
||||||
5. dispose item/external-container/combat owners.
|
|
||||||
|
|
||||||
The existing retained UI lease remains the sole host/runtime owner until its
|
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
|
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
|
- three independent self-review passes: retail behavior, architecture and
|
||||||
ownership, adversarial failure/reentrancy.
|
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. I.6b — live presentation and landblock publication
|
||||||
|
|
||||||
### 3.1 Owned construction
|
### 3.1 Owned construction
|
||||||
|
|
|
||||||
832
src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
Normal file
832
src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
Normal 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);
|
||||||
|
}
|
||||||
479
src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
Normal file
479
src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -24,10 +24,40 @@ internal sealed class DevToolsInputCaptureSource
|
||||||
|
|
||||||
internal sealed class RetainedUiInputCaptureSlot
|
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 WantCaptureMouse => Root?.WantsMouse ?? false;
|
||||||
public bool WantCaptureKeyboard => Root?.WantsKeyboard ?? 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
|
internal sealed class CompositeInputCaptureSource : IInputCaptureSource
|
||||||
|
|
|
||||||
|
|
@ -51,12 +51,27 @@ internal interface IWorldSelectionQuery
|
||||||
Vector3? GetCombatCameraTargetPoint(uint serverGuid);
|
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>
|
/// <summary>
|
||||||
/// Read-only owner for retail world selection and interaction classification.
|
/// Read-only owner for retail world selection and interaction classification.
|
||||||
/// It consumes the canonical live-entity runtime but never mutates selection,
|
/// It consumes the canonical live-entity runtime but never mutates selection,
|
||||||
/// movement, inventory, or network state.
|
/// movement, inventory, or network state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class WorldSelectionQuery : IWorldSelectionQuery
|
internal sealed class WorldSelectionQuery
|
||||||
|
: IWorldSelectionQuery,
|
||||||
|
IRetainedUiSelectionQuery
|
||||||
{
|
{
|
||||||
private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
||||||
private const uint StuckObjectFlag = 0x0004u;
|
private const uint StuckObjectFlag = 0x0004u;
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,8 @@ internal interface ILiveWorldSessionSource
|
||||||
{
|
{
|
||||||
AcDream.Core.Net.WorldSession? CurrentSession { get; }
|
AcDream.Core.Net.WorldSession? CurrentSession { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal interface ILiveUiSessionTarget : ILiveInWorldSource, ILiveWorldSessionSource
|
||||||
|
{
|
||||||
|
AcDream.UI.Abstractions.ICommandBus Commands { get; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,8 @@ internal sealed class LiveSessionController
|
||||||
: IDisposable,
|
: IDisposable,
|
||||||
ILiveSessionFramePhase,
|
ILiveSessionFramePhase,
|
||||||
ILiveInWorldSource,
|
ILiveInWorldSource,
|
||||||
ILiveWorldSessionSource
|
ILiveWorldSessionSource,
|
||||||
|
ILiveUiSessionTarget
|
||||||
{
|
{
|
||||||
private sealed class SessionScope(
|
private sealed class SessionScope(
|
||||||
WorldSession session,
|
WorldSession session,
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@ internal sealed class DeferredRenderFrameDiagnosticsSource
|
||||||
_target = target;
|
_target = target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IDisposable BindOwned(IRenderFrameDiagnosticsSnapshotSource target)
|
||||||
|
{
|
||||||
|
Bind(target);
|
||||||
|
return new Binding(this, target);
|
||||||
|
}
|
||||||
|
|
||||||
public void Unbind(IRenderFrameDiagnosticsSnapshotSource target)
|
public void Unbind(IRenderFrameDiagnosticsSnapshotSource target)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(target);
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
|
|
@ -51,6 +57,23 @@ internal sealed class DeferredRenderFrameDiagnosticsSource
|
||||||
_deactivated = true;
|
_deactivated = true;
|
||||||
_target = null;
|
_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
|
internal interface ICanonicalWorldEntityCountSource
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ public sealed class GameWindow :
|
||||||
IGameWindowHostInputCameraPublication,
|
IGameWindowHostInputCameraPublication,
|
||||||
IGameWindowContentEffectsAudioPublication,
|
IGameWindowContentEffectsAudioPublication,
|
||||||
IGameWindowSettingsDevToolsPublication,
|
IGameWindowSettingsDevToolsPublication,
|
||||||
IGameWindowWorldRenderPublication
|
IGameWindowWorldRenderPublication,
|
||||||
|
IGameWindowInteractionRetainedUiPublication
|
||||||
{
|
{
|
||||||
private static double ClientTimerNow() =>
|
private static double ClientTimerNow() =>
|
||||||
System.Diagnostics.Stopwatch.GetTimestamp()
|
System.Diagnostics.Stopwatch.GetTimestamp()
|
||||||
|
|
@ -337,8 +338,12 @@ public sealed class GameWindow :
|
||||||
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
|
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
|
||||||
public readonly AcDream.Core.Items.ClientObjectTable Objects = new();
|
public readonly AcDream.Core.Items.ClientObjectTable Objects = new();
|
||||||
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
|
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
|
||||||
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts { get; private set; }
|
private readonly ShortcutSnapshotState _shortcutSnapshots = new();
|
||||||
= System.Array.Empty<AcDream.Core.Items.ShortcutEntry>();
|
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts
|
||||||
|
{
|
||||||
|
get => _shortcutSnapshots.Items;
|
||||||
|
private set => _shortcutSnapshots.Items = value;
|
||||||
|
}
|
||||||
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
|
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
|
||||||
// PlayerDescription so the Vitals HUD can render those bars.
|
// PlayerDescription so the Vitals HUD can render those bars.
|
||||||
// Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment
|
// 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.UiHost? _uiHost;
|
||||||
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
|
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
|
||||||
private readonly AcDream.App.UI.RetailUiRuntimeLease _retailUiLease = new();
|
private readonly AcDream.App.UI.RetailUiRuntimeLease _retailUiLease = new();
|
||||||
|
private InteractionUiLateBindings? _interactionUiLateBindings;
|
||||||
|
private readonly DeferredRenderFrameDiagnosticsSource _uiFrameDiagnostics = new();
|
||||||
private readonly AcDream.App.Combat.CombatAttackOperationsSlot
|
private readonly AcDream.App.Combat.CombatAttackOperationsSlot
|
||||||
_combatAttackOperations = new();
|
_combatAttackOperations = new();
|
||||||
private readonly AcDream.App.Combat.CombatFeedbackSlot
|
private readonly AcDream.App.Combat.CombatFeedbackSlot
|
||||||
|
|
@ -427,9 +434,7 @@ public sealed class GameWindow :
|
||||||
}
|
}
|
||||||
// Retail Default_CharacterOption (acclient.h:3434). PlayerDescription
|
// Retail Default_CharacterOption (acclient.h:3434). PlayerDescription
|
||||||
// replaces this before any in-world drag can normally occur.
|
// replaces this before any in-world drag can normally occur.
|
||||||
private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
|
private readonly PlayerCharacterOptionsState _characterOptions = new();
|
||||||
_characterOptions1 =
|
|
||||||
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
|
|
||||||
private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new();
|
private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new();
|
||||||
|
|
||||||
private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new();
|
private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new();
|
||||||
|
|
@ -882,6 +887,43 @@ public sealed class GameWindow :
|
||||||
SamplerCache value) =>
|
SamplerCache value) =>
|
||||||
PublishCompositionOwner(ref _samplerCache, value, "sampler cache");
|
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>(
|
private static void PublishCompositionOwner<T>(
|
||||||
ref T? destination,
|
ref T? destination,
|
||||||
T value,
|
T value,
|
||||||
|
|
@ -1011,6 +1053,9 @@ public sealed class GameWindow :
|
||||||
_framebufferResize,
|
_framebufferResize,
|
||||||
Console.WriteLine),
|
Console.WriteLine),
|
||||||
this).Compose(platform, hostInputCamera, contentEffectsAudio);
|
this).Compose(platform, hostInputCamera, contentEffectsAudio);
|
||||||
|
Action<string>? compositionToast = settingsDevTools.DevTools is { } composedDevTools
|
||||||
|
? text => composedDevTools.Debug.AddToast(text)
|
||||||
|
: null;
|
||||||
|
|
||||||
const uint initialCenterLandblockId = 0xA9B4FFFFu;
|
const uint initialCenterLandblockId = 0xA9B4FFFFu;
|
||||||
WorldRenderResult worldRender = new WorldRenderCompositionPhase(
|
WorldRenderResult worldRender = new WorldRenderCompositionPhase(
|
||||||
|
|
@ -1029,367 +1074,57 @@ public sealed class GameWindow :
|
||||||
$"loading world view centered on " +
|
$"loading world view centered on " +
|
||||||
$"0x{worldRender.TerrainBuild.InitialCenterLandblockId:X8}");
|
$"0x{worldRender.TerrainBuild.InitialCenterLandblockId:X8}");
|
||||||
|
|
||||||
// Retail ClientCombatSystem attack-request owner. This exists independently
|
InteractionRetainedUiResult interactionUi =
|
||||||
// of the retained UI so keyboard combat keeps the same press/hold/release
|
new InteractionRetainedUiCompositionPhase(
|
||||||
// semantics even when the retail renderer is disabled.
|
new InteractionRetainedUiDependencies(
|
||||||
_combatAttackController = new AcDream.App.Combat.CombatAttackController(
|
_options,
|
||||||
Combat,
|
platform.Graphics,
|
||||||
_combatAttackOperations);
|
_window!,
|
||||||
_combatTargetController = new AcDream.App.Combat.CombatTargetController(
|
platform.Input,
|
||||||
Combat,
|
shadersDir,
|
||||||
_selection,
|
contentEffectsAudio.Dats,
|
||||||
autoTarget: () => _runtimeSettings.Gameplay.AutoTarget,
|
_datLock,
|
||||||
selectClosestTarget: () => SelectClosestCombatTarget(showToast: false));
|
worldRender.Foundation.TextureCache,
|
||||||
_externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController(
|
worldRender.Foundation.DebugFont,
|
||||||
_externalContainers,
|
_hostQuiescence,
|
||||||
Objects,
|
_retainedInputCapture,
|
||||||
guid => LiveSession?.SendNoLongerViewingContents(guid));
|
hostInputCamera.InputDispatcher,
|
||||||
|
_runtimeSettings,
|
||||||
AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!;
|
Combat,
|
||||||
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
|
_combatAttackOperations,
|
||||||
Objects,
|
_selection,
|
||||||
playerGuid: () => _playerServerGuid,
|
_externalContainers,
|
||||||
// ItemHolder::UseObject is the common policy owner, but world
|
Objects,
|
||||||
// activation still has to pass through the application-level
|
contentEffectsAudio.MagicCatalog,
|
||||||
// use adapter. It owns speculative turn/move, the close-range
|
SpellBook,
|
||||||
// deferred send, and ACE's far-range MoveTo callback. Calling
|
Chat,
|
||||||
// WorldSession directly here made keyboard R approach a corpse
|
LocalPlayer,
|
||||||
// without completing the same open transaction as double-click.
|
ItemMana,
|
||||||
sendUse: SendUse,
|
_stackSplitQuantity,
|
||||||
sendExamine: g => LiveSession?.SendAppraise(g),
|
_uiRegistry,
|
||||||
sendUseWithTarget: (source, target) => LiveSession?.SendUseWithTarget(source, target),
|
_liveCombatModeCommands,
|
||||||
sendWield: (item, mask) => LiveSession?.SendGetAndWieldItem(item, mask),
|
_localPlayerIdentity,
|
||||||
sendDrop: item => LiveSession?.SendDropItem(item),
|
_playerControllerSlot,
|
||||||
sendGive: (target, item, amount) =>
|
_localPlayerMode,
|
||||||
LiveSession?.SendGiveObject(target, item, amount),
|
_characterOptions,
|
||||||
dragOnPlayerOpensSecureTrade: () =>
|
_shortcutSnapshots,
|
||||||
(_characterOptions1
|
viewPlane => new SelectionCameraSource(
|
||||||
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
|
hostInputCamera.CameraController,
|
||||||
.DragItemOnPlayerOpensSecureTrade) != 0,
|
_window!,
|
||||||
toast: text => _debugVm?.AddToast(text),
|
viewPlane),
|
||||||
readyForInventoryRequest: () => _liveSessionHost?.IsInWorld == true,
|
_uiFrameDiagnostics,
|
||||||
playerOnGround: GetDebugPlayerOnGround,
|
settingsDevTools.DevTools?.Vitals,
|
||||||
inNonCombatMode: () => Combat.CurrentMode
|
compositionToast,
|
||||||
== AcDream.Core.Combat.CombatMode.NonCombat,
|
ClientTimerNow,
|
||||||
combatState: Combat,
|
Console.WriteLine),
|
||||||
sendChangeCombatMode: mode =>
|
_retailUiLease,
|
||||||
LiveSession?.SendChangeCombatMode(mode),
|
this).Compose(
|
||||||
isComponentPack: magicCatalog.IsComponentPack,
|
hostInputCamera,
|
||||||
placeInBackpack: SendPickUp,
|
contentEffectsAudio,
|
||||||
backpackContainerId: () =>
|
settingsDevTools,
|
||||||
_retailUiRuntime?.InventoryPanelController?.CurrentOpenContainerId
|
worldRender);
|
||||||
?? _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));
|
|
||||||
|
|
||||||
AcDream.App.World.DeferredLiveEntityParentAcceptance? parentAcceptance = null;
|
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
|
// Phase N.4 Task 12: construct LandblockSpawnAdapter under the feature flag
|
||||||
// and rebuild _worldState so it threads the adapter in. _worldState starts
|
// and rebuild _worldState so it threads the adapter in. _worldState starts
|
||||||
// as an unadorned GpuWorldState (field initializer); here we replace it with
|
// as an unadorned GpuWorldState (field initializer); here we replace it with
|
||||||
|
|
@ -1687,19 +1422,12 @@ public sealed class GameWindow :
|
||||||
_liveEntities,
|
_liveEntities,
|
||||||
Objects,
|
Objects,
|
||||||
_retailSelectionScene,
|
_retailSelectionScene,
|
||||||
() => _playerServerGuid,
|
() => _localPlayerIdentity.ServerGuid,
|
||||||
() =>
|
interactionUi.LateBindings.SelectionCamera.Snapshot,
|
||||||
{
|
|
||||||
var camera = GetSelectionCamera();
|
|
||||||
return new AcDream.App.Interaction.SelectionCameraSnapshot(
|
|
||||||
camera.View,
|
|
||||||
camera.Projection,
|
|
||||||
camera.Viewport);
|
|
||||||
},
|
|
||||||
() => new System.Numerics.Vector2(
|
() => new System.Numerics.Vector2(
|
||||||
_pointerPosition.X,
|
_pointerPosition.X,
|
||||||
_pointerPosition.Y),
|
_pointerPosition.Y),
|
||||||
() => _playerController is { } player
|
() => _playerControllerSlot.Controller is { } player
|
||||||
? new AcDream.App.Interaction.PlayerInteractionPose(
|
? new AcDream.App.Interaction.PlayerInteractionPose(
|
||||||
player.CellId,
|
player.CellId,
|
||||||
player.Position)
|
player.Position)
|
||||||
|
|
@ -1707,11 +1435,11 @@ public sealed class GameWindow :
|
||||||
_liveEntityMotionBindings.GetSetupCylinder,
|
_liveEntityMotionBindings.GetSetupCylinder,
|
||||||
setupId =>
|
setupId =>
|
||||||
{
|
{
|
||||||
if (_dats is null)
|
|
||||||
return null;
|
|
||||||
lock (_datLock)
|
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)
|
|| setup.SelectionSphere is not { } sphere)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -1719,6 +1447,22 @@ public sealed class GameWindow :
|
||||||
return (sphere.Origin, sphere.Radius);
|
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)
|
if (_itemInteractionController is { } itemInteractions)
|
||||||
{
|
{
|
||||||
_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(
|
_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(
|
||||||
|
|
@ -1726,12 +1470,17 @@ public sealed class GameWindow :
|
||||||
_worldSelectionQuery,
|
_worldSelectionQuery,
|
||||||
itemInteractions,
|
itemInteractions,
|
||||||
new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport(
|
new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport(
|
||||||
() => LiveSession),
|
() => interactionUi.LateBindings.Session.CurrentSession),
|
||||||
new AcDream.App.Interaction.PlayerInteractionMovementSink(
|
new AcDream.App.Interaction.PlayerInteractionMovementSink(
|
||||||
() => _playerController,
|
() => _playerControllerSlot.Controller,
|
||||||
_playerApproachCompletions),
|
_playerApproachCompletions),
|
||||||
text => _debugVm?.AddToast(text),
|
compositionToast,
|
||||||
_playerApproachCompletions);
|
_playerApproachCompletions);
|
||||||
|
interactionUi.LateBindings.AdoptLateOwnerBinding(
|
||||||
|
"world selection",
|
||||||
|
interactionUi.LateBindings.Selection.Bind(
|
||||||
|
_worldSelectionQuery,
|
||||||
|
_selectionInteractions));
|
||||||
}
|
}
|
||||||
if (_uiHost is { } retainedHost
|
if (_uiHost is { } retainedHost
|
||||||
&& _selectionInteractions is { } selectionInteractions)
|
&& _selectionInteractions is { } selectionInteractions)
|
||||||
|
|
@ -1898,6 +1647,9 @@ public sealed class GameWindow :
|
||||||
resourceDiagnostics);
|
resourceDiagnostics);
|
||||||
_devToolsComposition?.LateBindings.FrameDiagnostics.Bind(
|
_devToolsComposition?.LateBindings.FrameDiagnostics.Bind(
|
||||||
_renderFrameDiagnostics);
|
_renderFrameDiagnostics);
|
||||||
|
interactionUi.LateBindings.AdoptLateOwnerBinding(
|
||||||
|
"render-frame diagnostics",
|
||||||
|
_uiFrameDiagnostics.BindOwned(_renderFrameDiagnostics));
|
||||||
|
|
||||||
// Apply radii from the same immutable quality snapshot used for the
|
// Apply radii from the same immutable quality snapshot used for the
|
||||||
// window's MSAA, terrain anisotropy, and dispatcher A2C setup.
|
// window's MSAA, terrain anisotropy, and dispatcher A2C setup.
|
||||||
|
|
@ -2031,6 +1783,9 @@ public sealed class GameWindow :
|
||||||
sealedDungeonCells,
|
sealedDungeonCells,
|
||||||
Console.WriteLine);
|
Console.WriteLine);
|
||||||
_liveSessionController = new AcDream.App.Net.LiveSessionController();
|
_liveSessionController = new AcDream.App.Net.LiveSessionController();
|
||||||
|
interactionUi.LateBindings.AdoptLateOwnerBinding(
|
||||||
|
"live session",
|
||||||
|
interactionUi.LateBindings.Session.Bind(_liveSessionController));
|
||||||
var localPhysicsTimestamps =
|
var localPhysicsTimestamps =
|
||||||
new AcDream.App.World.LiveSessionLocalPhysicsTimestampPublisher(
|
new AcDream.App.World.LiveSessionLocalPhysicsTimestampPublisher(
|
||||||
_localPlayerIdentity,
|
_localPlayerIdentity,
|
||||||
|
|
@ -2320,6 +2075,10 @@ public sealed class GameWindow :
|
||||||
new AcDream.App.Streaming.LocalPlayerTeleportPresentation(
|
new AcDream.App.Streaming.LocalPlayerTeleportPresentation(
|
||||||
portalTunnel)));
|
portalTunnel)));
|
||||||
_localPlayerTeleportSink.Bind(_localPlayerTeleport);
|
_localPlayerTeleportSink.Bind(_localPlayerTeleport);
|
||||||
|
interactionUi.LateBindings.AdoptLateOwnerBinding(
|
||||||
|
"selection view plane",
|
||||||
|
interactionUi.LateBindings.SelectionViewPlane.Bind(
|
||||||
|
_localPlayerTeleport));
|
||||||
var teleportRenderState =
|
var teleportRenderState =
|
||||||
new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
|
new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
|
||||||
_localPlayerTeleport);
|
_localPlayerTeleport);
|
||||||
|
|
@ -2489,6 +2248,24 @@ public sealed class GameWindow :
|
||||||
worldScenePasses,
|
worldScenePasses,
|
||||||
_renderRange,
|
_renderRange,
|
||||||
worldSceneDiagnostics);
|
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 =
|
AcDream.App.Rendering.IRetainedGameplayUiFrame? retainedGameplayUi =
|
||||||
_options.RetailUi && _retailUiRuntime is not null
|
_options.RetailUi && _retailUiRuntime is not null
|
||||||
? new AcDream.App.Rendering.RetainedGameplayUiFrame(
|
? new AcDream.App.Rendering.RetainedGameplayUiFrame(
|
||||||
|
|
@ -2738,8 +2515,7 @@ public sealed class GameWindow :
|
||||||
Chat.ResetSessionIdentity();
|
Chat.ResetSessionIdentity();
|
||||||
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = 0u;
|
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = 0u;
|
||||||
_runtimeSettings.ResetActiveCharacterKey();
|
_runtimeSettings.ResetActiveCharacterKey();
|
||||||
_characterOptions1 =
|
_characterOptions.Reset();
|
||||||
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
|
|
||||||
_localPlayerSkills.ResetSession();
|
_localPlayerSkills.ResetSession();
|
||||||
_liveEntityNetworkUpdates?.ResetSessionState();
|
_liveEntityNetworkUpdates?.ResetSessionState();
|
||||||
_liveEntityHydration?.ResetSessionState();
|
_liveEntityHydration?.ResetSessionState();
|
||||||
|
|
@ -2834,7 +2610,7 @@ public sealed class GameWindow :
|
||||||
OnConfirmationDone: done =>
|
OnConfirmationDone: done =>
|
||||||
_retailUiRuntime?.HandleConfirmationDone(done),
|
_retailUiRuntime?.HandleConfirmationDone(done),
|
||||||
OnCharacterOptions: (options1, _) =>
|
OnCharacterOptions: (options1, _) =>
|
||||||
_characterOptions1 =
|
_characterOptions.Options =
|
||||||
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
||||||
options1,
|
options1,
|
||||||
ClientTime: ClientTimerNow);
|
ClientTime: ClientTimerNow);
|
||||||
|
|
@ -2998,9 +2774,6 @@ public sealed class GameWindow :
|
||||||
// EXPECTED-DIFF: local sidestep pacing now matches how remotes have
|
// EXPECTED-DIFF: local sidestep pacing now matches how remotes have
|
||||||
// always played (w6-cutover-map.md R3).
|
// always played (w6-cutover-map.md R3).
|
||||||
|
|
||||||
private bool GetDebugPlayerOnGround() =>
|
|
||||||
_playerMode && _playerController is not null && !_playerController.IsAirborne;
|
|
||||||
|
|
||||||
private void SyncToolbarWindowButtons()
|
private void SyncToolbarWindowButtons()
|
||||||
{
|
{
|
||||||
_retailUiRuntime?.SyncToolbarWindowButtons();
|
_retailUiRuntime?.SyncToolbarWindowButtons();
|
||||||
|
|
@ -3023,62 +2796,6 @@ public sealed class GameWindow :
|
||||||
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
|
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
|
||||||
=> _framebufferResize.Resize(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"
|
// #107 (2026-06-10): memoized "this indoor spawn claim can never hydrate"
|
||||||
// check against the dat's LandBlockInfo.NumCells. Used by the auto-entry
|
// 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
|
// 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 ResourceShutdownStage("session dependents",
|
||||||
[
|
[
|
||||||
|
new("interaction/UI late bindings", () =>
|
||||||
|
{
|
||||||
|
InteractionUiLateBindings? bindings = _interactionUiLateBindings;
|
||||||
|
if (bindings is null)
|
||||||
|
return;
|
||||||
|
bindings.Dispose();
|
||||||
|
_interactionUiLateBindings = null;
|
||||||
|
}),
|
||||||
new("mouse capture", () =>
|
new("mouse capture", () =>
|
||||||
{
|
{
|
||||||
AcDream.App.Input.CameraPointerInputController? pointer =
|
AcDream.App.Input.CameraPointerInputController? pointer =
|
||||||
|
|
@ -3277,7 +3002,6 @@ public sealed class GameWindow :
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"The retained UI ownership lease did not complete disposal.");
|
"The retained UI ownership lease did not complete disposal.");
|
||||||
_retailUiRuntime = null;
|
_retailUiRuntime = null;
|
||||||
_retainedInputCapture.Root = null;
|
|
||||||
_uiHost = null;
|
_uiHost = null;
|
||||||
}),
|
}),
|
||||||
new("combat target", () =>
|
new("combat target", () =>
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,7 @@ internal sealed class LocalPlayerTeleportPresentation
|
||||||
internal sealed class LocalPlayerTeleportController
|
internal sealed class LocalPlayerTeleportController
|
||||||
: ILocalPlayerTeleportFramePhase,
|
: ILocalPlayerTeleportFramePhase,
|
||||||
ILocalPlayerTeleportNetworkSink,
|
ILocalPlayerTeleportNetworkSink,
|
||||||
|
AcDream.App.Interaction.ISelectionViewPlaneSource,
|
||||||
IDisposable
|
IDisposable
|
||||||
{
|
{
|
||||||
internal const float MaximumWorldHoldSeconds = 10f;
|
internal const float MaximumWorldHoldSeconds = 10f;
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -58,10 +58,9 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"this).Compose(platform, hostInputCamera);",
|
"this).Compose(platform, hostInputCamera);",
|
||||||
"new SettingsDevToolsCompositionPhase(",
|
"new SettingsDevToolsCompositionPhase(",
|
||||||
"this).Compose(platform, hostInputCamera, contentEffectsAudio);",
|
"this).Compose(platform, hostInputCamera, contentEffectsAudio);",
|
||||||
"_uiHost = _retailUiLease.AcquireHost(",
|
"InteractionRetainedUiResult interactionUi =",
|
||||||
"_uiHost.WireMouse(m)",
|
"new InteractionRetainedUiCompositionPhase(",
|
||||||
"_uiHost.WireKeyboard(kb)",
|
"this).Compose(",
|
||||||
"_retailUiRuntime = _retailUiLease.Mount(",
|
|
||||||
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
|
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
|
||||||
"_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(",
|
"_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(",
|
||||||
"_retainedUiGameplayBinding =",
|
"_retainedUiGameplayBinding =",
|
||||||
|
|
@ -220,7 +219,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"private AcDream.App.Net.LiveSessionHost");
|
"private AcDream.App.Net.LiveSessionHost");
|
||||||
string body = MethodBody(
|
string body = MethodBody(
|
||||||
"private void OnFramebufferResize(",
|
"private void OnFramebufferResize(",
|
||||||
"private uint? PickWorldGuidAtCursor(");
|
"private (uint Claim, bool Unhydratable)? _spawnClaimRangeMemo;");
|
||||||
|
|
||||||
Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal);
|
Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal);
|
Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue