diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 06b361cb..d67026bb 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -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 diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md index 02f595aa..b433c0e3 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md @@ -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 diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md index 633fcf11..3e702404 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md @@ -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 diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs new file mode 100644 index 00000000..1fb3392e --- /dev/null +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -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 + SelectionCameraFactory, + DeferredRenderFrameDiagnosticsSource FrameDiagnostics, + VitalsVM? ExistingVitals, + Action? Toast, + Func ClientTime, + Action 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? 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? 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 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 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(); + 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? 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? 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? _faultInjection; + + public InteractionRetainedUiCompositionPhase( + InteractionRetainedUiDependencies dependencies, + RetailUiRuntimeLease retainedUiLease, + IGameWindowInteractionRetainedUiPublication publication, + IInteractionRetainedUiCompositionFactory? factory = null, + Action? 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); +} diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs new file mode 100644 index 00000000..add7a020 --- /dev/null +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -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; + +/// +/// 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. +/// +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( + target, + Release); + } + + public void Deactivate() + { + lock (_gate) + { + _deactivated = true; + _target = null; + } + } + + public bool TryUseItem(uint guid, Action? 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; + } + } +} + +/// +/// Selection operations mounted by retained UI before the Phase-6 world query +/// and interaction controller exist. +/// +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); + } +} + +/// Identity view plane until Phase 7 binds portal presentation. +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(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(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(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 _items = + Array.Empty(); + + public IReadOnlyList Items + { + get => _items; + set => _items = value ?? Array.Empty(); + } +} + +/// +/// 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. +/// +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(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 : IDisposable where T : class +{ + private Action? _release; + private readonly T _expected; + + public ExpectedOwnerBinding(T expected, Action 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); +} diff --git a/src/AcDream.App/Input/InputCaptureSources.cs b/src/AcDream.App/Input/InputCaptureSources.cs index 0c3ff8a3..ff9e34a5 100644 --- a/src/AcDream.App/Input/InputCaptureSources.cs +++ b/src/AcDream.App/Input/InputCaptureSources.cs @@ -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 diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index cf0e24ae..9d2bc30a 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -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); +} + /// /// 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. /// -internal sealed class WorldSelectionQuery : IWorldSelectionQuery +internal sealed class WorldSelectionQuery + : IWorldSelectionQuery, + IRetainedUiSelectionQuery { private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u; private const uint StuckObjectFlag = 0x0004u; diff --git a/src/AcDream.App/Net/ILiveInWorldSource.cs b/src/AcDream.App/Net/ILiveInWorldSource.cs index 1482777c..dd59d9f6 100644 --- a/src/AcDream.App/Net/ILiveInWorldSource.cs +++ b/src/AcDream.App/Net/ILiveInWorldSource.cs @@ -9,3 +9,8 @@ internal interface ILiveWorldSessionSource { AcDream.Core.Net.WorldSession? CurrentSession { get; } } + +internal interface ILiveUiSessionTarget : ILiveInWorldSource, ILiveWorldSessionSource +{ + AcDream.UI.Abstractions.ICommandBus Commands { get; } +} diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs index 5e0ebebf..e6ccb422 100644 --- a/src/AcDream.App/Net/LiveSessionController.cs +++ b/src/AcDream.App/Net/LiveSessionController.cs @@ -162,7 +162,8 @@ internal sealed class LiveSessionController : IDisposable, ILiveSessionFramePhase, ILiveInWorldSource, - ILiveWorldSessionSource + ILiveWorldSessionSource, + ILiveUiSessionTarget { private sealed class SessionScope( WorldSession session, diff --git a/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs b/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs index 3e647b2f..1088a671 100644 --- a/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs +++ b/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs @@ -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 diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 19a62173..76471bcf 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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(); /// Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source). - public IReadOnlyList Shortcuts { get; private set; } - = System.Array.Empty(); + private readonly ShortcutSnapshotState _shortcutSnapshots = new(); + public IReadOnlyList 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( ref T? destination, T value, @@ -1011,6 +1053,9 @@ public sealed class GameWindow : _framebufferResize, Console.WriteLine), this).Compose(platform, hostInputCamera, contentEffectsAudio); + Action? 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(); - 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(setupId, out var setup) + if (!contentEffectsAudio.Dats.TryGet( + 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 newSize) => _framebufferResize.Resize(newSize); - /// - /// 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. allows item target-use to - /// pick the local player while plain selection excludes it. - /// - 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", () => diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index e287f5dc..33f9326c 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -353,6 +353,7 @@ internal sealed class LocalPlayerTeleportPresentation internal sealed class LocalPlayerTeleportController : ILocalPlayerTeleportFramePhase, ILocalPlayerTeleportNetworkSink, + AcDream.App.Interaction.ISelectionViewPlaneSource, IDisposable { internal const float MaximumWorldHoldSeconds = 10f; diff --git a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs new file mode 100644 index 00000000..c411d6c7 --- /dev/null +++ b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs @@ -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(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 EnabledFailurePoints() + { + var data = new TheoryData(); + foreach (InteractionRetainedUiCompositionPoint point in + Enum.GetValues()) + { + if (point != InteractionRetainedUiCompositionPoint.RetainedUiDisabled) + data.Add((int)point); + } + return data; + } + + private static string[] ExpectedRollback( + InteractionRetainedUiCompositionPoint point) + { + var acquired = new List { "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(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(), + 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 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 _names = + new(ReferenceEqualityComparer.Instance); + + public List Releases { get; } = []; + public int RetainedUiCalls { get; private set; } + + public CombatAttackController CreateCombatAttack( + InteractionRetainedUiDependencies dependencies) => + Resource("combat attack"); + + public CombatTargetController CreateCombatTarget( + InteractionRetainedUiDependencies dependencies, + DeferredSelectionUiAuthority selection) => + Resource("combat target"); + + public ExternalContainerLifecycleController CreateExternalContainerLifecycle( + InteractionRetainedUiDependencies dependencies, + DeferredLiveSessionUiAuthority session) => + Resource("external container"); + + public ItemInteractionController CreateItemInteraction( + InteractionRetainedUiDependencies dependencies, + InteractionUiLateBindings lateBindings) => + Resource("item interaction"); + + public RetainedUiComposition CreateRetainedUi( + InteractionRetainedUiDependencies dependencies, + InteractionUiLateBindings lateBindings, + RetailUiRuntimeLease lease, + CombatAttackController combatAttack, + ItemInteractionController itemInteraction, + Action checkpoint) + { + RetainedUiCalls++; + _names.Add(lease, "retained UI lease"); + foreach (InteractionRetainedUiCompositionPoint point in UiPoints) + checkpoint(point); + return new RetainedUiComposition( + Stub(), + Stub(), + Stub(), + Stub(), + Stub(), + Stub(), + 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(string name) where T : class + { + T value = Stub(); + _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() 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."); + } +} diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs new file mode 100644 index 00000000..d22ad561 --- /dev/null +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -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(() => 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(() => source.Bind(first)); + } + + [Fact] + public void SelectionAuthorityUsesRetailSafeDefaultsAndExactBindingLifetime() + { + var source = new DeferredSelectionUiAuthority(); + var query = new SelectionQuery(); + SelectionInteractionController interactions = + Stub(); + + 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(() => source.Bind(query, interactions)); + + binding.Dispose(); + Assert.True(source.IsWithinExternalContainerUseRange(1u)); + source.Deactivate(); + Assert.Throws(() => 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(), + static () => new Dictionary(), + 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(() => source.Bind(target)); + } + + [Fact] + public void ExpectedOwnerBindingReleasesOnce() + { + object target = new(); + int releases = 0; + var binding = new ExpectedOwnerBinding(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(() => 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(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(() => + 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() where T : class => + (T)RuntimeHelpers.GetUninitializedObject(typeof(T)); +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index e0199082..2ff05b94 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -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);