feat: port retail magic lifecycle and retained spell UI

Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 10:55:22 +02:00
parent 7b7ffcd278
commit 07be994d97
84 changed files with 17822 additions and 1051 deletions

View file

@ -3,12 +3,14 @@ using AcDream.App.Plugins;
using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Spells;
using AcDream.App.UI.Layout;
using AcDream.App.UI.Testing;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Settings;
@ -43,6 +45,26 @@ public sealed record CombatRuntimeBindings(
Func<GameplaySettings> Gameplay,
Action<GameplaySettings> SetGameplay);
public sealed record MagicRuntimeBindings(
Spellbook Spellbook,
SpellCastingController Casting,
ClientObjectTable Objects,
Func<uint> PlayerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> Components,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
Func<uint, uint> ResolveSpellIcon,
Func<uint, uint> ResolveComponentIcon,
SelectionState Selection,
Func<uint, int> SpellLevel,
Action<uint> SelectObject,
Action<uint> UseItem,
Action<int, int, uint> AddFavorite,
Action<int, uint> RemoveFavorite,
Action<uint> SendSpellbookFilter,
Action<uint, uint> SetDesiredComponent,
Func<double> ServerTime);
public sealed record JumpPowerbarRuntimeBindings(Func<JumpChargeSnapshot> Snapshot);
public sealed record FpsRuntimeBindings(
@ -116,6 +138,7 @@ public sealed record RetailUiRuntimeBindings(
ChatRuntimeBindings Chat,
RadarRuntimeBindings Radar,
CombatRuntimeBindings Combat,
MagicRuntimeBindings Magic,
JumpPowerbarRuntimeBindings JumpPowerbar,
FpsRuntimeBindings Fps,
ToolbarRuntimeBindings Toolbar,
@ -139,6 +162,7 @@ public sealed class RetailUiRuntime : IDisposable
private StackSplitQuantityState StackSplitQuantity => _bindings.StackSplitQuantity;
private readonly RetailWindowLayoutPersistence? _persistence;
private readonly RetailUiAutomationScriptRunner? _automation;
private readonly RetailPanelUiController _panelUi;
private GameplayConfirmationController? _gameplayConfirmationController;
private RetailItemConfirmationController? _itemConfirmationController;
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
@ -147,12 +171,19 @@ public sealed class RetailUiRuntime : IDisposable
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
{
_bindings = bindings;
_panelUi = new RetailPanelUiController(
bindings.Host.IsWindowVisible,
bindings.Host.ShowWindow,
bindings.Host.HideWindow);
MountFpsDisplay();
MountVitals();
MountRadar();
MountChat();
MountToolbar();
MountCombat();
MountSpellbook();
MountEffects();
MountIndicators();
MountJumpPowerbar();
MountDialogFactory();
MountCharacter();
@ -192,6 +223,11 @@ public sealed class RetailUiRuntime : IDisposable
public ToolbarController? ToolbarController { get; private set; }
public ToolbarInputController? ToolbarInputController { get; private set; }
public CombatUiController? CombatUiController { get; private set; }
public SpellcastingUiController? SpellcastingUiController { get; private set; }
public SpellbookWindowController? SpellbookWindowController { get; private set; }
public EffectsUiController? PositiveEffectsController { get; private set; }
public EffectsUiController? NegativeEffectsController { get; private set; }
public EffectsIndicatorController? EffectsIndicatorController { get; private set; }
public JumpPowerbarController? JumpPowerbarController { get; private set; }
public RetailFpsController? FpsController { get; private set; }
public SelectedObjectController? SelectedObjectController { get; private set; }
@ -216,6 +252,10 @@ public sealed class RetailUiRuntime : IDisposable
public void Tick(double deltaSeconds)
{
FpsController?.Tick();
SpellbookWindowController?.Tick();
SpellcastingUiController?.Tick();
PositiveEffectsController?.Tick();
NegativeEffectsController?.Tick();
JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
DialogFactory?.Tick();
@ -226,7 +266,33 @@ public sealed class RetailUiRuntime : IDisposable
public void Draw(System.Numerics.Vector2 screenSize) => Host.Draw(screenSize);
public bool HandleInputAction(AcDream.UI.Abstractions.Input.InputAction action)
=> ToolbarInputController?.Handle(action) == true;
{
if (SpellcastingUiController?.Handle(action) == true)
return true;
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel)
{
OpenSpellbook(SpellbookWindowPage.Spells);
return true;
}
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel)
{
OpenSpellbook(SpellbookWindowPage.Components);
return true;
}
return ToolbarInputController?.Handle(action) == true;
}
private void OpenSpellbook(SpellbookWindowPage page)
{
bool visible = Host.IsWindowVisible(WindowNames.Spellbook);
if (visible && SpellbookWindowController?.CurrentPage == page)
CloseWindow(WindowNames.Spellbook);
else
{
SpellbookWindowController?.ShowPage(page);
_panelUi.SetPanelVisibility(RetailPanelCatalog.Magic, visible: true);
}
}
public bool HandleConfirmationRequest(GameEvents.CharacterConfirmationRequest request)
=> _gameplayConfirmationController?.HandleRequest(request) == true;
@ -258,15 +324,22 @@ public sealed class RetailUiRuntime : IDisposable
public void RestoreNamedLayout(string profileName) => _persistence?.RestoreNamed(profileName);
public bool ToggleWindow(string name)
=> Host.ToggleWindow(name);
=> RetailPanelCatalog.TryGetPanelId(name, out uint panelId)
? _panelUi.TogglePanel(panelId)
: Host.ToggleWindow(name);
public void CloseWindow(string name)
=> Host.HideWindow(name);
{
if (RetailPanelCatalog.TryGetPanelId(name, out uint panelId))
_panelUi.SetPanelVisibility(panelId, visible: false);
else
Host.HideWindow(name);
}
public void SyncToolbarWindowButtons()
{
if (ToolbarController is null) return;
foreach (var (panelId, windowName) in RetailPanelCatalog.MountedPanels)
foreach (var (panelId, windowName) in RetailPanelCatalog.ToolbarPanels)
ToolbarController.SetPanelOpen(panelId, Host.IsWindowVisible(windowName));
}
@ -284,6 +357,7 @@ public sealed class RetailUiRuntime : IDisposable
private void OnWindowVisibilityChanged(string windowName, bool visible)
{
_panelUi.ObserveWindowVisibility(windowName, visible);
if (RetailPanelCatalog.TryGetPanelId(windowName, out uint panelId))
ToolbarController?.SetPanelOpen(panelId, visible);
}
@ -583,7 +657,28 @@ public sealed class RetailUiRuntime : IDisposable
return;
}
SpellcastingUiController? spellcasting = Layout.SpellcastingUiController.Bind(
layout,
_bindings.Magic.Spellbook,
_bindings.Magic.Casting,
_bindings.Magic.Objects,
_bindings.Magic.PlayerGuid,
_bindings.Magic.ResolveSpellIcon,
item => _bindings.Magic.ResolveDragIcon(
item.Type, item.IconId, item.IconUnderlayId,
item.IconOverlayId, item.Effects),
_bindings.Magic.UseItem,
_bindings.Magic.Selection,
_bindings.Magic.AddFavorite,
_bindings.Magic.RemoveFavorite);
if (spellcasting is null)
Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073.");
CombatUiController = controller;
SpellcastingUiController = spellcasting;
IRetainedPanelController controllerOwner = spellcasting is null
? controller
: new RetainedPanelControllerGroup(controller, spellcasting);
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
@ -601,10 +696,190 @@ public sealed class RetailUiRuntime : IDisposable
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
Controller = controllerOwner,
});
controller.SyncVisibility();
Console.WriteLine("[M2] retail combat bar from gmCombatUI LayoutDesc 0x21000073.");
Console.WriteLine(spellcasting is null
? "[M2] retail combat from LayoutDesc 0x21000073; magic binding unavailable."
: "[M3] retail combat + spell bar from LayoutDesc 0x21000073.");
}
private void MountSpellbook()
{
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
_bindings.Assets.Dats,
SpellbookWindowController.LayoutId,
SpellbookWindowController.RootId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine("[M3] spellbook: LayoutDesc 0x21000034 not found.");
return;
}
SpellbookWindowController? controller = Layout.SpellbookWindowController.Bind(
layout,
_bindings.Magic.Spellbook,
_bindings.Magic.Objects,
_bindings.Magic.PlayerGuid,
_bindings.Magic.Components,
_bindings.Magic.ResolveSpellIcon,
_bindings.Magic.ResolveComponentIcon,
_bindings.Magic.SpellLevel,
_bindings.Magic.SelectObject,
spellId => SpellcastingUiController?.AddFavorite(spellId),
_bindings.Magic.SendSpellbookFilter,
_bindings.Magic.SetDesiredComponent,
() => CloseWindow(WindowNames.Spellbook));
if (controller is null)
{
Console.WriteLine("[M3] spellbook: required controls missing in LayoutDesc 0x21000034.");
return;
}
SpellbookWindowController = controller;
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Spellbook,
Chrome = RetailWindowChrome.Imported,
Left = 18f,
Top = 18f,
Visible = false,
Resizable = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
});
_panelUi.Register(RetailPanelCatalog.Magic, WindowNames.Spellbook);
Console.WriteLine("[M3] retail spellbook/component book from LayoutDesc 0x21000034.");
}
private void MountEffects()
{
MountEffectsInstance(positive: true);
MountEffectsInstance(positive: false);
}
private void MountEffectsInstance(bool positive)
{
uint rootId = positive ? EffectsUiController.PositiveRootId : EffectsUiController.NegativeRootId;
ElementInfo? rootInfo;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
EffectsUiController.LayoutId,
rootId);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
new DatStringResolver(_bindings.Assets.Dats).Resolve);
}
if (rootInfo is null || layout is null)
{
Console.WriteLine($"[M3] effects: root 0x{rootId:X8} not found.");
return;
}
EffectsUiController? controller = Layout.EffectsUiController.Bind(
layout,
_bindings.Magic.Spellbook,
positive,
_bindings.Magic.ServerTime,
_bindings.Assets.ResolveSprite,
_bindings.Magic.ResolveSpellIcon,
close: () => CloseWindow(
positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects));
if (controller is null)
{
Console.WriteLine($"[M3] effects: list missing under root 0x{rootId:X8}.");
return;
}
if (positive) PositiveEffectsController = controller;
else NegativeEffectsController = controller;
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects,
Chrome = RetailWindowChrome.Imported,
Left = Math.Max(0f, Host.Root.Width - root.Width - 12f),
Top = 18f,
Visible = false,
Resizable = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
});
_panelUi.Register(
positive ? RetailPanelCatalog.PositiveEffects : RetailPanelCatalog.NegativeEffects,
positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects,
rootInfo);
}
private void MountIndicators()
{
ImportedLayout? layout = Import(EffectsIndicatorController.LayoutId);
if (layout is null)
{
Console.WriteLine("[M3] effects indicators: LayoutDesc 0x21000071 not found.");
return;
}
EffectsIndicatorController? controller = Layout.EffectsIndicatorController.Bind(
layout,
_bindings.Magic.Spellbook,
panelId => _panelUi.TogglePanel(panelId));
if (controller is null)
{
Console.WriteLine("[M3] effects indicators: authored Helpful/Harmful buttons missing.");
return;
}
EffectsIndicatorController = controller;
RetailWindowFrame.Mount(
Host.Root,
layout.Root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Indicators,
Chrome = RetailWindowChrome.Imported,
Left = 10f,
Top = 96f,
Visible = true,
Resizable = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
});
Console.WriteLine("[M3] retail Helpful/Harmful effect indicators from LayoutDesc 0x21000071.");
}
private void MountJumpPowerbar()
@ -825,6 +1100,7 @@ public sealed class RetailUiRuntime : IDisposable
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
ContentClickThrough = false,
});
_panelUi.Register(RetailPanelCatalog.Character, WindowNames.Character);
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
}
@ -906,6 +1182,7 @@ public sealed class RetailUiRuntime : IDisposable
Host.Root.Height - root.Top),
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
});
_panelUi.Register(RetailPanelCatalog.Inventory, WindowNames.Inventory);
uint contents, sideBag, mainPack;
IReadOnlyDictionary<uint, uint> paperdollEmptySprites;