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