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

@ -4,6 +4,7 @@ using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.Items;
using AcDream.Core.Textures;
using AcDream.Core.Spells;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
@ -35,6 +36,8 @@ public sealed class IconComposer
private readonly TextureCache _cache;
private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new();
private readonly Dictionary<(uint, uint, uint), ComposedIcon> _dragByTuple = new();
private readonly Dictionary<uint, uint> _spellIcons = new();
private readonly Dictionary<uint, uint> _componentIcons = new();
private sealed record ComposedIcon(byte[] Rgba, int Width, int Height, uint Texture);
@ -298,4 +301,71 @@ public sealed class IconComposer
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
layers.Add((decoded.Rgba8, decoded.Width, decoded.Height));
}
/// <summary>
/// Retail ClientMagicSystem::CompositeSpellIcon (0x00567550): power-level
/// backing, spell art, reversed/normal recolor, then self/fellow overlay.
/// </summary>
public uint GetSpellIcon(uint spellId)
{
if (_spellIcons.TryGetValue(spellId, out uint cached)) return cached;
DatReaderWriter.DBObjs.SpellTable? table =
_dats.Get<DatReaderWriter.DBObjs.SpellTable>(0x0E00000Eu);
if (table is null || !table.Spells.TryGetValue(spellId, out var spell)) return 0u;
uint power = spell.Components.Count == 0
? 0u
: RetailSpellFormula.DeterminePowerLevelOfComponent(spell.Components[0]);
uint powerBacking = RetailDataIdResolver.Resolve(_dats, power, 0x10000006u);
var layers = new List<(byte[] rgba, int w, int h)>();
AddLayer(layers, powerBacking);
AddLayer(layers, spell.Icon);
if (layers.Count == 0) return 0u;
var composed = Compose(layers);
uint tintIndex = (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.Reversed) != 0
? 1u : 2u;
uint tintDid = RetailDataIdResolver.Resolve(_dats, tintIndex, 0x10000007u);
if (TryDecode(tintDid, out DecodedTexture tint))
ReplaceWhiteFromSurface(composed.rgba, composed.w, composed.h,
tint.Rgba8, tint.Width, tint.Height);
uint overlayIndex = (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.FellowshipSpell) != 0
? 4u
: (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.SelfTargeted) != 0
? 3u : 0u;
if (overlayIndex != 0u)
{
uint overlayDid = RetailDataIdResolver.Resolve(_dats, overlayIndex, 0x10000007u);
if (TryDecode(overlayDid, out DecodedTexture overlay))
composed = Compose([
(composed.rgba, composed.w, composed.h),
(overlay.Rgba8, overlay.Width, overlay.Height)]);
}
uint texture = _cache.UploadRgba8(composed.rgba, composed.w, composed.h, nearest: true);
_spellIcons[spellId] = texture;
return texture;
}
/// <summary>
/// Retail ClientMagicSystem::CompositeSpellComponentIcon (0x00567720).
/// Components use their raw DAT art with pure white replaced by black.
/// </summary>
public uint GetSpellComponentIcon(uint iconId)
{
if (iconId == 0u) return 0u;
if (_componentIcons.TryGetValue(iconId, out uint cached)) return cached;
if (!TryDecode(iconId, out DecodedTexture icon)) return 0u;
byte[] rgba = (byte[])icon.Rgba8.Clone();
for (int i = 0; i + 3 < rgba.Length; i += 4)
{
if (rgba[i] != 255 || rgba[i + 1] != 255 || rgba[i + 2] != 255 || rgba[i + 3] != 255)
continue;
rgba[i] = rgba[i + 1] = rgba[i + 2] = 0;
}
uint texture = _cache.UploadRgba8(rgba, icon.Width, icon.Height, nearest: true);
_componentIcons[iconId] = texture;
return texture;
}
}

View file

@ -125,6 +125,18 @@ public sealed class ItemInteractionController : IDisposable
public bool CanMakeInventoryRequest =>
_readyForInventoryRequest() && _busyCount == 0 && !_autoWield.IsBusy;
/// <summary>
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
/// request issued by another retained controller has been sent. The
/// corresponding UseDone releases it. Retail spell casts complete through
/// Item__UseDone too; AttackDone belongs only to the combat attack owner.
/// </summary>
public void IncrementBusyCount()
{
_busyCount++;
StateChanged?.Invoke();
}
/// <summary>
/// Raised for retail confirmation/auxiliary actions whose retained panel is
/// owned outside this controller (PK/NPK altar, volatile rare, trade, salvage).
@ -514,6 +526,14 @@ public sealed class ItemInteractionController : IDisposable
StateChanged?.Invoke();
}
/// <summary>Session teardown drops every outstanding client UI busy reference.</summary>
public void ClearBusy()
{
if (_busyCount == 0) return;
_busyCount = 0;
StateChanged?.Invoke();
}
private bool ConsumeUseThrottle()
{
long now = _nowMs();

View file

@ -20,7 +20,8 @@ public sealed class CombatUiController : IRetainedPanelController
{
public const uint LayoutId = 0x21000073u;
public const uint BasicPanelId = 0x1000005Cu;
public const uint AdvancedPanelId = 0x10000061u;
public const uint SpellcastingPanelId = 0x10000061u;
public const uint AdvancedPanelId = SpellcastingPanelId;
public const uint PowerControlId = 0x1000004Fu;
public const uint SpeedLabelId = 0x10000051u;
public const uint PowerLabelId = 0x10000052u;
@ -36,7 +37,7 @@ public sealed class CombatUiController : IRetainedPanelController
private readonly UiElement _root;
private readonly UiElement _basicPanel;
private readonly UiElement _advancedPanel;
private readonly UiElement _spellcastingPanel;
private readonly UiScrollbar _powerControl;
private readonly UiButton _high;
private readonly UiButton _medium;
@ -54,7 +55,7 @@ public sealed class CombatUiController : IRetainedPanelController
private CombatUiController(
ImportedLayout layout,
UiElement basicPanel,
UiElement advancedPanel,
UiElement spellcastingPanel,
UiScrollbar powerControl,
UiButton high,
UiButton medium,
@ -71,7 +72,7 @@ public sealed class CombatUiController : IRetainedPanelController
{
_root = layout.Root;
_basicPanel = basicPanel;
_advancedPanel = advancedPanel;
_spellcastingPanel = spellcastingPanel;
_powerControl = powerControl;
_high = high;
_medium = medium;
@ -85,11 +86,10 @@ public sealed class CombatUiController : IRetainedPanelController
_setGameplay = setGameplay;
_setWindowVisible = setWindowVisible;
// PlayerModule::AdvancedCombatUI false selects gmCombatUI's authored
// basic page. The advanced page belongs to the separate advanced-
// combat flow and stays hidden in the basic panel.
// Retail layout 0x21000073 contains two sibling pages: gmCombatUI's
// physical-attack controls and gmSpellcastingUI's favorite-spell bar.
_basicPanel.Visible = true;
_advancedPanel.Visible = false;
_spellcastingPanel.Visible = false;
_powerControl.SetScalarPosition(_attacks.DesiredPower);
_powerControl.ScalarChanged = _attacks.SetDesiredPower;
@ -140,7 +140,7 @@ public sealed class CombatUiController : IRetainedPanelController
ArgumentNullException.ThrowIfNull(setWindowVisible);
if (layout.FindElement(BasicPanelId) is not { } basic
|| layout.FindElement(AdvancedPanelId) is not { } advanced
|| layout.FindElement(SpellcastingPanelId) is not { } spellcasting
|| layout.FindElement(PowerControlId) is not UiScrollbar power
|| layout.FindElement(HighButtonId) is not UiButton high
|| layout.FindElement(MediumButtonId) is not UiButton medium
@ -151,7 +151,7 @@ public sealed class CombatUiController : IRetainedPanelController
return null;
return new CombatUiController(
layout, basic, advanced, power, high, medium, low,
layout, basic, spellcasting, power, high, medium, low,
repeatAttacks, autoTarget, keepInView,
combat, attacks, gameplay, setGameplay, labels, setWindowVisible);
}
@ -166,7 +166,9 @@ public sealed class CombatUiController : IRetainedPanelController
private void OnCombatModeChanged(CombatMode mode)
{
bool visible = CombatInputPlanner.SupportsTargetedAttack(mode);
bool visible = mode is CombatMode.Melee or CombatMode.Missile or CombatMode.Magic;
_basicPanel.Visible = mode is CombatMode.Melee or CombatMode.Missile;
_spellcastingPanel.Visible = mode == CombatMode.Magic;
if (_root is IUiDatStateful stateful)
{
if (mode == CombatMode.Melee)

View file

@ -77,6 +77,8 @@ public static class DatWidgetFactory
{
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
1 => BuildButton(info, resolve, elementFont, stringResolve), // UIElement_Button
EffectsIndicatorController.RetailClassId => BuildButton(
info, resolve, elementFont, stringResolve), // gmUIElement_EffectsIndicator
6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf

View file

@ -0,0 +1,86 @@
using System;
using System.Linq;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Port of retail <c>gmUIElement_EffectsIndicator::PostInit @ 0x004E6930</c>
/// and <c>Update @ 0x004E6A50</c>: the authored Helpful and Harmful buttons
/// reflect whether matching enchantments exist and send panel visibility notices
/// for gmPanelUI slots 4 and 5.
/// </summary>
public sealed class EffectsIndicatorController : IRetainedPanelController
{
public const uint LayoutId = 0x21000071u;
public const uint RetailClassId = 0x10000002u;
public const uint HelpfulButtonId = 0x100000F5u;
public const uint HarmfulButtonId = 0x100000F6u;
private readonly Spellbook _spellbook;
private readonly UiButton _helpful;
private readonly UiButton _harmful;
private readonly Action<uint> _togglePanel;
private bool _disposed;
private EffectsIndicatorController(
Spellbook spellbook,
UiButton helpful,
UiButton harmful,
Action<uint> togglePanel)
{
_spellbook = spellbook;
_helpful = helpful;
_harmful = harmful;
_togglePanel = togglePanel;
_helpful.OnClick = () => _togglePanel(RetailPanelCatalog.PositiveEffects);
_harmful.OnClick = () => _togglePanel(RetailPanelCatalog.NegativeEffects);
_spellbook.EnchantmentsChanged += Update;
Update();
}
public static EffectsIndicatorController? Bind(
ImportedLayout layout,
Spellbook spellbook,
Action<uint> togglePanel)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(togglePanel);
if (layout.FindElement(HelpfulButtonId) is not UiButton helpful
|| layout.FindElement(HarmfulButtonId) is not UiButton harmful)
return null;
return new EffectsIndicatorController(spellbook, helpful, harmful, togglePanel);
}
private void Update()
{
bool helpful = HasMatchingEffect(beneficial: true);
bool harmful = HasMatchingEffect(beneficial: false);
_helpful.TrySetRetailState(helpful
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
_harmful.TrySetRetailState(harmful
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
}
private bool HasMatchingEffect(bool beneficial)
// Retail SpellEffectMatchesUIType @ 0x004B76C0 supplies this helpful/harmful split.
=> _spellbook.ActiveEnchantmentSnapshot.Any(record =>
// Retail CountSpellsInList @ 0x00593CD0 counts every raw mult/add
// node. Unlike gmEffectsUI's list, indicator totals are not dueled
// by spell category first.
record.Bucket is 1u or 2u
&& _spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata)
&& metadata.IsBeneficial == beneficial);
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.EnchantmentsChanged -= Update;
_helpful.OnClick = null;
_harmful.OnClick = null;
}
}

View file

@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>Retail gmEffectsUI positive/negative instance binding.</summary>
public sealed class EffectsUiController : IRetainedPanelController
{
public const uint LayoutId = 0x2100001Bu;
// gmPanelUI's 0x10000184/185 are panel-slot IDs, not roots in this LayoutDesc.
// The authored positive/negative gmEffectsUI instances inherit template 0x10000122.
public const uint PositiveRootId = 0x1000011Fu;
public const uint NegativeRootId = 0x10000121u;
public const uint CloseId = 0x100000FCu;
public const uint ListId = 0x10000123u;
public const uint InfoTextId = 0x10000126u;
private readonly Spellbook _spellbook;
private readonly bool _positive;
private readonly Func<double> _serverTime;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly UiItemList _list;
private readonly UiText? _info;
private readonly UiButton? _close;
private readonly Dictionary<uint, UiCatalogSlot> _rows = new();
private uint? _selectedSpellId;
private double _lastDurationUpdate = double.NaN;
private bool _disposed;
internal uint? SelectedSpellId => _selectedSpellId;
private EffectsUiController(
ImportedLayout layout,
Spellbook spellbook,
bool positive,
Func<double> serverTime,
Func<uint, uint> resolveSpellIcon,
UiItemList list,
Action? close)
{
_spellbook = spellbook;
_positive = positive;
_serverTime = serverTime;
_resolveSpellIcon = resolveSpellIcon;
_list = list;
_info = layout.FindElement(InfoTextId) as UiText;
_close = layout.FindElement(CloseId) as UiButton;
if (_close is not null) _close.OnClick = close;
_list.Columns = 1;
_list.CellWidth = Math.Max(1f, _list.Width);
_list.CellHeight = 32f;
ConfigureInfo();
_spellbook.EnchantmentsChanged += Rebuild;
Rebuild();
}
public static EffectsUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
bool positive,
Func<double> serverTime,
Func<uint, (uint Texture, int Width, int Height)> spriteResolve,
Func<uint, uint> resolveSpellIcon,
Action? close = null)
{
UiElement? host = layout.FindElement(ListId);
if (host is null) return null;
UiItemList list;
if (host is UiItemList itemList)
list = itemList;
else
{
list = new UiItemList(spriteResolve)
{
Width = host.Width,
Height = host.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
};
host.AddChild(list);
}
return new EffectsUiController(
layout, spellbook, positive, serverTime, resolveSpellIcon, list, close);
}
public void Tick()
{
double now = _serverTime();
if (!double.IsFinite(now)) return;
if (double.IsFinite(_lastDurationUpdate)
&& now >= _lastDurationUpdate
&& now - _lastDurationUpdate < 1.0)
return;
_lastDurationUpdate = now;
foreach (ActiveEnchantmentRecord enchantment in VisibleEnchantments())
if (_rows.TryGetValue(enchantment.Identity, out UiCatalogSlot? row))
row.Detail = FormatRemaining(enchantment, now);
}
private void Rebuild()
{
_lastDurationUpdate = double.NaN;
_rows.Clear();
ActiveEnchantmentRecord[] enchantments = VisibleEnchantments().ToArray();
using (_list.DeferLayout())
{
_list.Flush();
foreach (ActiveEnchantmentRecord enchantment in enchantments)
{
_spellbook.TryGetMetadata(enchantment.SpellId, out SpellMetadata? metadata);
uint identity = enchantment.Identity;
var row = new UiCatalogSlot
{
EntryId = enchantment.SpellId,
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(enchantment.SpellId),
Label = metadata?.Name ?? $"Spell {enchantment.SpellId}",
Detail = FormatRemaining(enchantment, _serverTime()),
ShowLabel = true,
SpriteResolve = _list.SpriteResolve,
};
row.Clicked = () => Select(enchantment.SpellId);
_rows[identity] = row;
_list.AddItem(row);
}
}
if (_selectedSpellId is uint selected
&& !_rows.Values.Any(row => row.EntryId == selected))
_selectedSpellId = null;
SyncSelection();
}
private IEnumerable<ActiveEnchantmentRecord> VisibleEnchantments()
=> _spellbook.EnchantmentsInEffectSnapshot
.Where(record =>
{
if (!_spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata))
return false;
return metadata.IsBeneficial == _positive;
})
.OrderBy(record => _spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata)
? metadata.Name : record.SpellId.ToString(CultureInfo.InvariantCulture),
StringComparer.OrdinalIgnoreCase);
private static string FormatRemaining(ActiveEnchantmentRecord enchantment, double now)
{
if (enchantment.Duration < 0) return "Permanent";
double remaining = Math.Max(0, enchantment.StartTime + enchantment.Duration - now);
if (!double.IsFinite(remaining)) return "--:--";
remaining = Math.Min(remaining, TimeSpan.MaxValue.TotalSeconds);
TimeSpan time = TimeSpan.FromSeconds(remaining);
return time.TotalHours >= 1
? $"{(int)time.TotalHours}:{time.Minutes:00}:{time.Seconds:00}"
: $"{time.Minutes}:{time.Seconds:00}";
}
private void Select(uint spellId)
{
// Retail gmEffectsUI::SetSelectedSpell @ 0x004B8290 stores the
// token's spell stat, not its enchantment layer identity.
_selectedSpellId = _selectedSpellId == spellId ? null : spellId;
SyncSelection();
}
private void SyncSelection()
{
foreach (UiCatalogSlot row in _rows.Values)
row.Selected = row.EntryId == _selectedSpellId;
}
private void ConfigureInfo()
{
if (_info is null) return;
_info.LinesProvider = () =>
{
if (_selectedSpellId is not uint spellId)
return Array.Empty<UiText.Line>();
ActiveEnchantmentRecord? selected = _spellbook.ActiveEnchantmentSnapshot
.Cast<ActiveEnchantmentRecord?>()
.FirstOrDefault(record => record?.SpellId == spellId);
if (selected is null || !_spellbook.TryGetMetadata(selected.Value.SpellId, out SpellMetadata metadata))
return Array.Empty<UiText.Line>();
return
[
new UiText.Line(metadata.Name, _info.DefaultColor),
new UiText.Line(metadata.Description, _info.DefaultColor),
];
};
}
public void OnShown() => Rebuild();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.EnchantmentsChanged -= Rebuild;
if (_close is not null) _close.OnClick = null;
}
}

View file

@ -131,8 +131,9 @@ public sealed class ElementInfo
public string DefaultStateName = "";
/// <summary>
/// Resolved child elements (populated by the importer in Task 5).
/// Children come from the derived element's own tree, not the base element's.
/// Resolved child elements. The importer ports retail child-table incorporation:
/// base-only children remain, same-ID children merge recursively, and derived-only
/// children append after the retained inherited entries.
/// </summary>
public List<ElementInfo> Children = new();
@ -264,7 +265,9 @@ public static class ElementReader
/// base entries are the default; derived entries override (or add) per state name key.
/// </description></item>
/// <item><description>
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
/// <see cref="ElementInfo.Children"/>: are not combined by this scalar merge helper;
/// <see cref="LayoutImporter"/> separately ports retail's recursive child-table
/// incorporation.
/// </description></item>
/// </list>
/// </para>
@ -306,10 +309,9 @@ public static class ElementReader
FontColor = derived.FontColor ?? base_.FontColor,
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// Children come from the derived element's own tree, not the base prototype's.
// Defensive copy: prevent a later mutation of either the merged result or the input
// from corrupting the other. Safe because derived.Children is fully
// populated by the recursive importer BEFORE Merge is called and never mutated after).
// This helper merges one element snapshot only. LayoutImporter separately
// incorporates the child tables after the scalar/state merge.
// Defensive copy prevents later mutation of either input.
Children = new List<ElementInfo>(derived.Children),
};
// Start with base StateMedia as defaults, then let derived entries override.

View file

@ -295,11 +295,9 @@ public static class LayoutImporter
// ── Inheritance resolution ────────────────────────────────────────────────
/// <summary>True when a pure-container leaf should inherit its base's subtree (the
/// gmInventoryUI sub-window mount): the derived element has no own children, no own
/// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors
/// (close button / title), elements with their own children, and childless style
/// prototypes (vitals/chat/toolbar text — the base prototype has no children).</summary>
/// <summary>True when a pure-container inheritor needs the mounted-base Z-layer
/// correction. Child inheritance itself is unconditional and follows retail
/// <c>ElementDesc::Incorporate</c> (0x0069B5A0).</summary>
internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
=> derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
@ -316,7 +314,7 @@ public static class LayoutImporter
// Read this element's own fields + media (no inheritance, no children yet).
var self = ToInfo(d);
var result = self;
List<ElementInfo>? baseChildren = null;
ElementInfo? baseInfo = null;
// Apply BaseElement / BaseLayoutId inheritance if present.
if (d.BaseElement != 0 && d.BaseLayoutId != 0
@ -327,32 +325,23 @@ public static class LayoutImporter
if (baseDesc is not null)
{
// Recurse the base chain (already guarded by the HashSet add above).
var baseInfo = Resolve(dats, baseDesc, baseChain);
baseInfo = Resolve(dats, baseDesc, baseChain);
// Derived fields override the base; children are attached below.
result = ElementReader.Merge(baseInfo, self);
baseChildren = baseInfo.Children; // capture for the sub-window mount
}
}
// Resolve + attach children. Each child gets a FRESH base-chain set:
// the cycle guard is per-element, not shared across siblings.
foreach (var kv in d.Children)
{
var child = Resolve(dats, kv.Value, new HashSet<(uint, uint)>());
SetOriginalParentSize(child, result.Width, result.Height);
result.Children.Add(child);
}
// Retail LayoutDesc::InqFullDesc (0x0069A520) recursively resolves the base,
// then ElementDesc::Incorporate (0x0069B5A0) merges the complete child table:
// base-only children remain, same-ID children incorporate recursively, and
// derived-only children append after the retained base entries.
IncorporateChildren(dats, result, baseInfo?.Children, d);
// Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
// from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
// (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window);
// inert for media-bearing inheritors (close button/title) and childless style prototypes
// (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec.
if (baseChildren is not null
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count))
// A pure-container sub-window mount needs one additional layer correction.
// Child-table incorporation has already happened above for every inheritor.
if (baseInfo is not null
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseInfo.Children.Count))
{
result.Children.AddRange(baseChildren);
// The mounted slot's layer WITHIN THE FRAME is its OWN ZLevel, not the mounted
// sub-window root's. The gm*UI sub-window roots carry ZLevel 1000 (their standalone
// top-window layer); ElementReader.Merge's zero-wins-base rule made the slot (own
@ -372,6 +361,57 @@ public static class LayoutImporter
return result;
}
private static void IncorporateChildren(
DatCollection dats,
ElementInfo result,
IReadOnlyList<ElementInfo>? baseChildren,
ElementDesc derived)
{
baseChildren ??= Array.Empty<ElementInfo>();
var baseById = baseChildren.ToDictionary(child => child.Id);
int retainedBaseCount = baseChildren.Count(child =>
!derived.Children.ContainsKey(child.Id));
foreach (ElementInfo baseChild in baseChildren)
{
bool hasOverlay = derived.Children.TryGetValue(
baseChild.Id, out ElementDesc? overlay);
ElementInfo child = hasOverlay
? IncorporateResolvedChild(dats, baseChild, overlay!)
: baseChild;
// Base-only descendants retain the base layout's design parent size;
// UiLayoutPolicy then performs retail's base-to-derived parent resize.
if (hasOverlay)
SetOriginalParentSize(child, result.Width, result.Height);
result.Children.Add(child);
}
// Every new child receives a fresh base-chain set. Retail offsets its read order
// by the count of inherited children not replaced by a same-ID overlay.
foreach (var pair in derived.Children)
{
if (baseById.ContainsKey(pair.Key)) continue;
ElementInfo child = Resolve(dats, pair.Value, new HashSet<(uint, uint)>());
child.ReadOrder += checked((uint)retainedBaseCount);
SetOriginalParentSize(child, result.Width, result.Height);
result.Children.Add(child);
}
}
private static ElementInfo IncorporateResolvedChild(
DatCollection dats,
ElementInfo baseChild,
ElementDesc derivedChild)
{
// ElementDesc::Incorporate consumes the partial child directly. It does not
// independently re-resolve that child's BaseElement when the inherited table
// already contains the same identity.
ElementInfo self = ToInfo(derivedChild);
ElementInfo result = ElementReader.Merge(baseChild, self);
IncorporateChildren(dats, result, baseChild.Children, derivedChild);
return result;
}
/// <summary>
/// Read an <see cref="ElementDesc"/>'s own scalar fields + state media into a
/// fresh <see cref="ElementInfo"/>. No inheritance is applied; children are not

View file

@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained-window port of retail <c>gmPanelUI::RecvNotice_SetPanelVisibility</c>
/// at <c>0x004BC6F0</c>. The original owns one active child panel and an optional
/// deferred child; this owner applies the same lifecycle to registered retained
/// windows without making the toolbar authoritative for non-toolbar panels.
/// </summary>
public sealed class RetailPanelUiController
{
public const uint RestorePreviousPropertyId = 0x10000049u;
private readonly Func<string, bool> _isVisible;
private readonly Func<string, bool> _show;
private readonly Func<string, bool> _hide;
private readonly Dictionary<uint, PanelEntry> _byPanel = new();
private readonly Dictionary<string, uint> _byWindow = new(StringComparer.Ordinal);
private uint? _activePanel;
private uint? _deferredPanel;
private bool _applying;
public RetailPanelUiController(
Func<string, bool> isVisible,
Func<string, bool> show,
Func<string, bool> hide)
{
_isVisible = isVisible ?? throw new ArgumentNullException(nameof(isVisible));
_show = show ?? throw new ArgumentNullException(nameof(show));
_hide = hide ?? throw new ArgumentNullException(nameof(hide));
}
public uint? ActivePanelId => _activePanel;
/// <param name="restorePrevious">
/// Effective DAT bool property <c>0x10000049</c>. Retail retains the previous
/// panel only when the newly shown panel has this property and the previous
/// panel does not.
/// </param>
public void Register(uint panelId, string windowName, bool restorePrevious = false)
{
if (panelId == 0) throw new ArgumentOutOfRangeException(nameof(panelId));
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
if (_byPanel.ContainsKey(panelId) || _byWindow.ContainsKey(windowName))
throw new InvalidOperationException(
$"Panel {panelId} or retained window '{windowName}' is already registered.");
_byPanel.Add(panelId, new PanelEntry(windowName, restorePrevious));
_byWindow.Add(windowName, panelId);
if (_isVisible(windowName))
ObserveWindowVisibility(windowName, visible: true);
}
/// <summary>
/// Registers a panel directly from its fully inherited retail root so the
/// authored deferred-panel behavior cannot be dropped at the mount seam.
/// </summary>
public void Register(uint panelId, string windowName, ElementInfo authoredRoot)
{
ArgumentNullException.ThrowIfNull(authoredRoot);
Register(
panelId,
windowName,
authoredRoot.TryGetEffectiveBool(
RestorePreviousPropertyId,
out bool restorePrevious)
&& restorePrevious);
}
public bool IsPanelVisible(uint panelId)
=> _byPanel.TryGetValue(panelId, out PanelEntry entry)
&& _isVisible(entry.WindowName);
public bool TogglePanel(uint panelId)
{
bool visible = !IsPanelVisible(panelId);
return SetPanelVisibility(panelId, visible) && visible;
}
public bool SetPanelVisibility(uint panelId, bool visible)
{
if (!_byPanel.TryGetValue(panelId, out PanelEntry requested)) return false;
_applying = true;
try
{
if (visible)
{
if (_activePanel == panelId)
return _show(requested.WindowName);
uint? previousId = _activePanel;
PanelEntry? previous = previousId is uint id
&& _byPanel.TryGetValue(id, out PanelEntry found)
&& _isVisible(found.WindowName)
? found
: null;
_deferredPanel = requested.RestorePrevious
&& previous is { RestorePrevious: false }
? previousId
: null;
_activePanel = panelId;
if (previous is not null)
_hide(previous.Value.WindowName);
return _show(requested.WindowName);
}
if (_activePanel != panelId)
{
if (_deferredPanel == panelId) _deferredPanel = null;
return _hide(requested.WindowName);
}
bool hidden = _hide(requested.WindowName);
_activePanel = null;
if (_deferredPanel is not uint deferredId
|| !_byPanel.TryGetValue(deferredId, out PanelEntry deferred))
return hidden;
_deferredPanel = null;
_activePanel = deferredId;
return _show(deferred.WindowName) || hidden;
}
finally
{
_applying = false;
}
}
/// <summary>
/// Feeds visibility changes made by persistence or another retained-window
/// owner back through the canonical panel lifecycle.
/// </summary>
public void ObserveWindowVisibility(string windowName, bool visible)
{
if (_applying || !_byWindow.TryGetValue(windowName, out uint panelId)) return;
SetPanelVisibility(panelId, visible);
}
private readonly record struct PanelEntry(string WindowName, bool RestorePrevious);
}

View file

@ -0,0 +1,457 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Numerics;
using AcDream.App.Spells;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
public enum SpellbookWindowPage { Spells, Components }
/// <summary>
/// Binds retail's combined spellbook/component-book LayoutDesc 0x21000034.
/// The spell page mirrors gmSpellbookUI filters; the component page mirrors
/// ComponentTracker and exposes the 0..5000 desired purchase level field.
/// </summary>
public sealed class SpellbookWindowController : IRetainedPanelController
{
public const uint LayoutId = 0x21000034u;
public const uint RootId = 0x100002A8u;
public const uint SpellTabId = 0x100002A9u;
public const uint ComponentTabId = 0x100002AAu;
public const uint CloseId = 0x100002ABu;
public const uint SpellPageId = 0x100002ACu;
public const uint ComponentPageId = 0x100002ADu;
public const uint SpellListId = 0x10000295u;
public const uint ComponentListId = 0x10000464u;
public const uint ComponentScrollbarId = 0x10000465u;
private static readonly (uint Id, uint Mask)[] FilterButtons =
[
(0x10000298u, 0x0001u), (0x10000299u, 0x0002u),
(0x1000029Au, 0x0004u), (0x1000029Bu, 0x0008u),
(0x100005C0u, 0x2000u),
(0x1000029Cu, 0x0010u), (0x1000029Du, 0x0020u),
(0x1000029Eu, 0x0040u), (0x1000029Fu, 0x0080u),
(0x100002A0u, 0x0100u), (0x100002A1u, 0x0200u),
(0x100002A2u, 0x0400u), (0x1000054Eu, 0x0800u),
];
private readonly Spellbook _spellbook;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly IReadOnlyDictionary<uint, SpellComponentDescriptor> _components;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly Func<uint, uint> _resolveComponentIcon;
private readonly Func<uint, int> _spellLevel;
private readonly Action<uint> _selectObject;
private readonly Action<uint> _addFavorite;
private readonly Action<uint> _sendFilter;
private readonly Action<uint, uint> _setDesiredComponent;
private readonly Action _close;
private readonly UiElement _spellPage;
private readonly UiElement _componentPage;
private readonly UiButton _spellTab;
private readonly UiButton _componentTab;
private readonly UiButton _closeButton;
private readonly UiItemList _spellList;
private readonly UiItemList _componentList;
private readonly List<(UiButton Button, uint Mask)> _filters = new();
private uint? _selectedSpell;
private bool _spellsDirty;
private bool _componentsDirty;
private bool _componentEditActive;
private bool _disposed;
private SpellbookWindowController(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint> playerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
Func<uint, uint> resolveSpellIcon,
Func<uint, uint> resolveComponentIcon,
Func<uint, int> spellLevel,
Action<uint> selectObject,
Action<uint> addFavorite,
Action<uint> sendFilter,
Action<uint, uint> setDesiredComponent,
Action close,
UiElement spellPage,
UiElement componentPage,
UiButton spellTab,
UiButton componentTab,
UiButton closeButton,
UiItemList spellList,
UiItemList componentList)
{
_spellbook = spellbook;
_objects = objects;
_playerGuid = playerGuid;
_components = components;
_resolveSpellIcon = resolveSpellIcon;
_resolveComponentIcon = resolveComponentIcon;
_spellLevel = spellLevel;
_selectObject = selectObject;
_addFavorite = addFavorite;
_sendFilter = sendFilter;
_setDesiredComponent = setDesiredComponent;
_close = close;
_spellPage = spellPage;
_componentPage = componentPage;
_spellTab = spellTab;
_componentTab = componentTab;
_closeButton = closeButton;
_spellList = spellList;
_componentList = componentList;
spellTab.OnClick = () => ShowPage(SpellbookWindowPage.Spells);
componentTab.OnClick = () => ShowPage(SpellbookWindowPage.Components);
closeButton.OnClick = close;
foreach ((uint id, uint mask) in FilterButtons)
{
if (layout.FindElement(id) is not UiButton button) continue;
uint filterMask = mask;
button.OnClick = () => ToggleFilter(filterMask);
_filters.Add((button, mask));
}
ConfigureSpellList();
ConfigureComponentList(layout);
_spellbook.SpellbookChanged += OnSpellbookChanged;
_spellbook.DesiredComponentsChanged += OnDesiredComponentsChanged;
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
_objects.ObjectRemoved += OnObjectRemoved;
_objects.ObjectMoved += OnObjectMoved;
_objects.ContainerContentsReplaced += OnContainerContentsReplaced;
ShowPage(SpellbookWindowPage.Spells);
RebuildAll();
}
public SpellbookWindowPage CurrentPage { get; private set; }
public static SpellbookWindowController? Bind(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint> playerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
Func<uint, uint> resolveSpellIcon,
Func<uint, uint> resolveComponentIcon,
Func<uint, int> spellLevel,
Action<uint> selectObject,
Action<uint> addFavorite,
Action<uint> sendFilter,
Action<uint, uint> setDesiredComponent,
Action close)
{
if (layout.FindElement(SpellPageId) is not { } spellPage
|| layout.FindElement(ComponentPageId) is not { } componentPage
|| layout.FindElement(SpellTabId) is not UiButton spellTab
|| layout.FindElement(ComponentTabId) is not UiButton componentTab
|| layout.FindElement(CloseId) is not UiButton closeButton
|| layout.FindElement(SpellListId) is not UiItemList spellList)
return null;
UiElement? componentHost = layout.FindElement(ComponentListId);
if (componentHost is null) return null;
UiItemList componentList;
if (componentHost is UiItemList itemList)
componentList = itemList;
else
{
componentList = new UiItemList(spellList.SpriteResolve)
{
Width = componentHost.Width,
Height = componentHost.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
};
componentHost.AddChild(componentList);
}
return new SpellbookWindowController(
layout, spellbook, objects, playerGuid, components,
resolveSpellIcon, resolveComponentIcon,
spellLevel, selectObject,
addFavorite, sendFilter, setDesiredComponent, close,
spellPage, componentPage, spellTab, componentTab, closeButton,
spellList, componentList);
}
public void ShowPage(SpellbookWindowPage page)
{
CurrentPage = page;
_spellPage.Visible = page == SpellbookWindowPage.Spells;
_componentPage.Visible = page == SpellbookWindowPage.Components;
_spellTab.Selected = page == SpellbookWindowPage.Spells;
_componentTab.Selected = page == SpellbookWindowPage.Components;
}
private void ConfigureSpellList()
{
_spellList.Columns = 6;
_spellList.CellWidth = 32f;
_spellList.CellHeight = 32f;
}
private void ConfigureComponentList(ImportedLayout layout)
{
_componentList.Columns = 1;
_componentList.CellWidth = Math.Max(1f, _componentList.Width);
_componentList.CellHeight = 32f;
if (layout.FindElement(ComponentScrollbarId) is UiScrollbar scrollbar)
scrollbar.Model = _componentList.Scroll;
}
private void ToggleFilter(uint mask)
{
uint filters = _spellbook.SpellbookFilters ^ mask;
_spellbook.SetSpellbookFilters(filters);
_sendFilter(filters);
}
private void RebuildAll()
{
RebuildSpells();
RebuildComponents();
_spellsDirty = false;
_componentsDirty = false;
}
private void RebuildSpells()
{
uint effective = _spellbook.SpellbookFilters;
foreach ((UiButton button, uint mask) in _filters)
button.Selected = (effective & mask) != 0;
SpellMetadata[] spells = _spellbook.LearnedSpells
.Select(id => _spellbook.TryGetMetadata(id, out SpellMetadata metadata) ? metadata : null)
.Where(metadata => metadata is not null && IsVisible(metadata, effective))
.OrderBy(metadata => metadata!.SortKey)
.ThenBy(metadata => metadata!.Name, StringComparer.OrdinalIgnoreCase)
.Cast<SpellMetadata>()
.ToArray();
using (_spellList.DeferLayout())
{
_spellList.Flush();
foreach (SpellMetadata metadata in spells)
{
uint spellId = metadata.SpellId;
var slot = new UiCatalogSlot
{
EntryId = spellId,
CatalogIconTexture = _resolveSpellIcon(spellId),
Label = metadata.Name,
SpriteResolve = _spellList.SpriteResolve,
};
slot.Clicked = () => SelectSpell(spellId);
// gmSpellbookUI double-click publishes AddSpellShortcut; the open
// gmSpellcastingUI tab chooses the server favorite-list destination.
slot.DoubleClicked = () => { SelectSpell(spellId); _addFavorite(spellId); };
_spellList.AddItem(slot);
}
}
SyncSpellSelection();
}
private void RebuildComponents()
{
uint player = _playerGuid();
var packs = _objects.Objects
.Where(item => (item.IsComponentPack || _components.ContainsKey(item.WeenieClassId))
&& IsOwnedByPlayer(item, player))
.GroupBy(item => item.WeenieClassId)
.Select(group => new
{
ComponentId = group.Key,
First = group.First(),
Quantity = group.Sum(item => Math.Max(1, item.StackSize)),
})
.OrderBy(group => _components.TryGetValue(group.ComponentId, out var descriptor) ? descriptor.Category : uint.MaxValue)
.ThenBy(group => group.First.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
float width = Math.Max(96f, _componentList.Width);
_componentList.CellWidth = width;
using (_componentList.DeferLayout())
{
_componentList.Flush();
uint? category = null;
foreach (var packGroup in packs)
{
ClientObject pack = packGroup.First;
uint componentId = packGroup.ComponentId;
_components.TryGetValue(componentId, out SpellComponentDescriptor? descriptor);
uint nextCategory = descriptor?.Category ?? 8u;
if (category != nextCategory)
{
category = nextCategory;
_componentList.AddItem(new UiCatalogSlot
{
EntryId = uint.MaxValue,
Label = ComponentCategoryName(nextCategory),
ShowLabel = true,
SpriteResolve = _componentList.SpriteResolve,
});
}
uint desired = _spellbook.DesiredComponents.TryGetValue(componentId, out uint amount) ? amount : 0u;
var row = new UiCatalogSlot
{
EntryId = componentId,
CatalogIconTexture = _resolveComponentIcon(
pack.IconId != 0 ? pack.IconId : descriptor?.IconId ?? 0u),
Label = string.IsNullOrWhiteSpace(pack.Name) ? descriptor?.Name ?? $"Component {componentId}" : pack.Name,
Detail = packGroup.Quantity.ToString(CultureInfo.InvariantCulture),
ShowLabel = true,
SpriteResolve = _componentList.SpriteResolve,
};
var desiredField = new UiField
{
Left = width - 47f,
Top = 4f,
Width = 43f,
Height = 24f,
MaxCharacters = 4,
ClearOnSubmit = false,
RecordHistory = false,
SelectAllOnFocus = true,
RightAligned = true,
CharacterFilter = char.IsAsciiDigit,
BackgroundColor = new Vector4(0f, 0f, 0f, 0.65f),
};
desiredField.SetText(desired.ToString(CultureInfo.InvariantCulture));
desiredField.OnFocusGained = () => _componentEditActive = true;
uint committed = desired;
void Commit(string value)
{
if (!uint.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out uint parsed)
|| parsed > 5000u)
{
desiredField.SetText(committed.ToString(CultureInfo.InvariantCulture));
return;
}
if (parsed == committed) return;
committed = parsed;
_spellbook.SetDesiredComponent(componentId, parsed);
_setDesiredComponent(componentId, parsed);
}
desiredField.OnSubmit = Commit;
desiredField.OnFocusLost = value =>
{
Commit(value);
_componentEditActive = false;
};
row.AddChild(desiredField);
row.Clicked = () => _selectObject(pack.ObjectId);
_componentList.AddItem(row);
}
}
}
private static string ComponentCategoryName(uint category) => category switch
{
0u => "Scarabs",
1u => "Herbs",
2u => "Powdered Gems",
3u => "Alchemical Substances",
4u => "Talismans",
5u => "Tapers",
6u => "Prismatic Components",
_ => "Other Components",
};
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
{
if (playerGuid == 0) return item.ContainerId != 0 || item.WielderId != 0;
ClientObject current = item;
for (int depth = 0; depth < 16; depth++)
{
if (current.WielderId == playerGuid || current.ContainerId == playerGuid) return true;
if (current.ContainerId == 0 || _objects.Get(current.ContainerId) is not { } parent) return false;
current = parent;
}
return false;
}
private bool IsVisible(SpellMetadata metadata, uint filters)
{
uint school = metadata.School switch
{
"Creature Enchantment" => 0x0001u,
"Item Enchantment" => 0x0002u,
"Life Magic" => 0x0004u,
"War Magic" => 0x0008u,
"Void Magic" => 0x2000u,
_ => 0u,
};
int spellLevel = _spellLevel(metadata.SpellId);
if (spellLevel is < 1 or > 8) return false;
uint level = 0x10u << (spellLevel - 1);
return school != 0 && (filters & school) != 0 && (filters & level) != 0;
}
private void SelectSpell(uint spellId)
{
_selectedSpell = spellId;
SyncSpellSelection();
}
private void SyncSpellSelection()
{
for (int i = 0; i < _spellList.GetNumUIItems(); i++)
if (_spellList.GetItem(i) is UiCatalogSlot slot)
slot.Selected = slot.EntryId == _selectedSpell;
}
public void Tick()
{
if (_spellsDirty)
{
_spellsDirty = false;
RebuildSpells();
}
if (_componentsDirty && !_componentEditActive)
{
_componentsDirty = false;
RebuildComponents();
}
}
private void OnSpellbookChanged() => _spellsDirty = true;
private void OnDesiredComponentsChanged() => _componentsDirty = true;
private void OnObjectChanged(ClientObject item)
{
if (IsComponent(item)) _componentsDirty = true;
}
private void OnObjectRemoved(ClientObject item)
{
if (IsComponent(item)) _componentsDirty = true;
}
private void OnObjectMoved(ClientObjectMove _) => _componentsDirty = true;
private void OnContainerContentsReplaced(uint _) => _componentsDirty = true;
private bool IsComponent(ClientObject item)
=> item.IsComponentPack || _components.ContainsKey(item.WeenieClassId);
public void OnShown() => RebuildAll();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.SpellbookChanged -= OnSpellbookChanged;
_spellbook.DesiredComponentsChanged -= OnDesiredComponentsChanged;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
_spellTab.OnClick = null;
_componentTab.OnClick = null;
_closeButton.OnClick = null;
foreach ((UiButton button, _) in _filters) button.OnClick = null;
}
}

View file

@ -0,0 +1,472 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.Spells;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Spells;
using AcDream.Core.Selection;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retail gmSpellcastingUI binding for the authored magic page inside combat
/// LayoutDesc 0x21000073. Favorites remain server-persisted; casting emits one
/// request through <see cref="SpellCastingController"/>.
/// </summary>
public sealed class SpellcastingUiController : IRetainedPanelController
{
public const uint PageId = 0x10000061u;
public const uint SpellNameId = 0x1000048Bu;
public const uint EndowmentId = 0x100000B1u;
public const uint CastButtonId = 0x100000B2u;
public const uint FavoriteListId = 0x100000B6u;
private static readonly uint[] TabIds =
[
0x100000A3u, 0x100000A4u, 0x100000A5u, 0x100000A6u,
0x100000A7u, 0x100000A8u, 0x100000A9u, 0x100005C2u,
];
private static readonly uint[] GroupIds =
[
0x100000AAu, 0x100000ABu, 0x100000ACu, 0x100000ADu,
0x100000AEu, 0x100000AFu, 0x100000B0u, 0x100005C3u,
];
private readonly Spellbook _spellbook;
private readonly SpellCastingController _casting;
private readonly SelectionState _selection;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly Func<ClientObject, uint> _resolveItemDragIcon;
private readonly Action<uint> _useItem;
private readonly Action<int, int, uint>? _addFavorite;
private readonly Action<int, uint>? _removeFavorite;
private readonly UiElement[] _tabs;
private readonly UiElement[] _groups;
private readonly UiItemList?[] _lists;
private readonly UiButton _cast;
private readonly UiText? _spellName;
private readonly UiElement _endowmentHost;
private readonly UiCatalogSlot _endowmentSlot;
private int _activeTab;
private readonly uint?[] _selected = new uint?[8];
private readonly bool[] _endowmentSelected = new bool[8];
private uint _endowmentItemId;
private uint _endowmentSpellId;
private bool _disposed;
private bool _favoritesDirty;
private bool _endowmentDirty;
private SpellcastingUiController(
ImportedLayout layout,
Spellbook spellbook,
SpellCastingController casting,
ClientObjectTable objects,
Func<uint> playerGuid,
Func<uint, uint> resolveSpellIcon,
Func<ClientObject, uint> resolveItemDragIcon,
Action<uint> useItem,
SelectionState selection,
Action<int, int, uint>? addFavorite,
Action<int, uint>? removeFavorite,
UiElement[] tabs,
UiElement[] groups,
UiItemList?[] lists,
UiButton cast,
UiElement endowmentHost)
{
_spellbook = spellbook;
_casting = casting;
_selection = selection;
_objects = objects;
_playerGuid = playerGuid;
_resolveSpellIcon = resolveSpellIcon;
_resolveItemDragIcon = resolveItemDragIcon;
_useItem = useItem;
_addFavorite = addFavorite;
_removeFavorite = removeFavorite;
_tabs = tabs;
_groups = groups;
_lists = lists;
_cast = cast;
_spellName = layout.FindElement(SpellNameId) as UiText;
_endowmentHost = endowmentHost;
foreach (UiElement child in _endowmentHost.Children) child.Visible = false;
_endowmentSlot = new UiCatalogSlot
{
Left = 0f,
Top = 0f,
Width = endowmentHost.Width,
Height = endowmentHost.Height,
SpriteResolve = lists.FirstOrDefault(list => list is not null)?.SpriteResolve,
};
_endowmentSlot.Clicked = SelectEndowment;
_endowmentSlot.DoubleClicked = () => { SelectEndowment(); CastSelected(); };
_endowmentHost.AddChild(_endowmentSlot);
for (int i = 0; i < tabs.Length; i++)
{
int index = i;
SetClick(tabs[i], () => SelectTab(index));
}
_cast.OnClick = CastSelected;
ConfigureSpellName();
_spellbook.SpellbookChanged += OnSpellbookChanged;
_selection.Changed += OnSelectionChanged;
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
_objects.ObjectRemoved += OnObjectChanged;
_objects.Cleared += OnObjectsCleared;
UpdateEndowment();
SelectTab(0);
Rebuild();
}
public int ActiveTab => _activeTab;
public static SpellcastingUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
SpellCastingController casting,
ClientObjectTable objects,
Func<uint> playerGuid,
Func<uint, uint> resolveSpellIcon,
Func<ClientObject, uint> resolveItemDragIcon,
Action<uint> useItem,
SelectionState selection,
Action<int, int, uint>? addFavorite,
Action<int, uint>? removeFavorite)
{
if (layout.FindElement(CastButtonId) is not UiButton cast
|| layout.FindElement(EndowmentId) is not { } endowmentHost)
return null;
var tabs = new UiElement[8];
var groups = new UiElement[8];
var lists = new UiItemList?[8];
for (int i = 0; i < 8; i++)
{
if (layout.FindElement(TabIds[i]) is not { } tab
|| layout.FindElement(GroupIds[i]) is not { } group)
return null;
tabs[i] = tab;
groups[i] = group;
lists[i] = Descendants(group).OfType<UiItemList>().FirstOrDefault();
}
return new SpellcastingUiController(
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
resolveItemDragIcon, useItem, selection,
addFavorite, removeFavorite,
tabs, groups, lists, cast, endowmentHost);
}
public void AddFavorite(uint spellId)
{
IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
if (spells.Contains(spellId))
{
SelectSpell(spellId);
return;
}
int position = spells.Count;
_addFavorite?.Invoke(_activeTab, position, spellId);
_spellbook.SetFavorite(_activeTab, position, spellId);
SelectSpell(spellId);
}
public bool Handle(InputAction action)
{
if (action is >= InputAction.UseSpellSlot_1 and <= InputAction.UseSpellSlot_9)
{
int index = (int)action - (int)InputAction.UseSpellSlot_1;
IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
if (index < spells.Count)
{
SelectSpell(spells[index]);
CastSelected();
}
return true;
}
switch (action)
{
case InputAction.CombatPrevSpellTab: SelectTab((_activeTab + 7) % 8); return true;
case InputAction.CombatNextSpellTab: SelectTab((_activeTab + 1) % 8); return true;
case InputAction.CombatFirstSpellTab: SelectTab(0); return true;
case InputAction.CombatLastSpellTab: SelectTab(7); return true;
case InputAction.CombatPrevSpell: MoveSelection(-1, false); return true;
case InputAction.CombatNextSpell: MoveSelection(1, false); return true;
case InputAction.CombatFirstSpell: MoveSelection(0, true); return true;
case InputAction.CombatLastSpell: MoveSelection(-1, true); return true;
case InputAction.CombatCastCurrentSpell: CastSelected(); return true;
default: return false;
}
}
private void SelectTab(int tab)
{
_activeTab = Math.Clamp(tab, 0, 7);
for (int i = 0; i < 8; i++)
{
SetSelected(_tabs[i], i == _activeTab);
_groups[i].Visible = i == _activeTab;
}
IReadOnlyList<uint> favorites = _spellbook.GetFavorites(_activeTab);
if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u)
_selected[_activeTab] = null;
else if (_selected[_activeTab] is not uint selected || !favorites.Contains(selected))
{
_selected[_activeTab] = favorites.Count == 0 ? null : favorites[0];
_endowmentSelected[_activeTab] = favorites.Count == 0 && _endowmentItemId != 0u;
}
SyncSelection();
UpdateCastAvailability();
}
private void MoveSelection(int delta, bool edge)
{
IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
int count = spells.Count + (_endowmentItemId != 0u ? 1 : 0);
if (count == 0) return;
int current = _endowmentSelected[_activeTab]
? 0
: (_selected[_activeTab] is uint id ? spells.IndexOf(id) : -1)
+ (_endowmentItemId != 0u ? 1 : 0);
int next = edge ? (delta < 0 ? count - 1 : 0) : (current + delta + count) % count;
if (_endowmentItemId != 0u && next == 0) SelectEndowment();
else SelectSpell(spells[next - (_endowmentItemId != 0u ? 1 : 0)]);
}
private void SelectSpell(uint spellId)
{
_endowmentSelected[_activeTab] = false;
_selected[_activeTab] = spellId;
SyncSelection();
UpdateCastAvailability();
}
private void CastSelected()
{
if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u)
_useItem(_endowmentItemId);
else if (_selected[_activeTab] is uint spellId)
_casting.Cast(spellId);
}
private void SelectEndowment()
{
if (_endowmentItemId == 0u) return;
_endowmentSelected[_activeTab] = true;
_selected[_activeTab] = null;
SyncSelection();
UpdateCastAvailability();
}
private void Rebuild()
{
for (int tab = 0; tab < 8; tab++)
{
UiItemList? list = _lists[tab];
if (list is null) continue;
IReadOnlyList<uint> favorites = _spellbook.GetFavorites(tab);
using (list.DeferLayout())
{
list.Flush();
list.Columns = 9;
list.CellWidth = 32f;
list.CellHeight = 32f;
foreach (uint spellId in favorites)
{
uint id = spellId;
int position = list.GetNumUIItems();
_spellbook.TryGetMetadata(id, out SpellMetadata? metadata);
var slot = new UiCatalogSlot
{
EntryId = id,
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(id),
Label = metadata?.Name ?? $"Spell {id}",
SpriteResolve = list.SpriteResolve,
CatalogDragPayload = new SpellFavoriteDragPayload(tab, position, id),
DragBegan = payload => BeginFavoriteDrag((SpellFavoriteDragPayload)payload),
DragEnded = payload => EndFavoriteDrag((SpellFavoriteDragPayload)payload),
Dropped = payload =>
{
if (payload is SpellFavoriteDragPayload favorite)
DropFavorite(favorite, tab, position);
},
};
slot.Clicked = () => SelectSpell(id);
slot.DoubleClicked = () => { SelectSpell(id); CastSelected(); };
list.AddItem(slot);
}
}
}
SelectTab(_activeTab);
}
private void BeginFavoriteDrag(SpellFavoriteDragPayload payload)
=> _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId);
private void EndFavoriteDrag(SpellFavoriteDragPayload payload)
=> _spellbook.RemoveFavorite(payload.SourceTab, payload.SpellId);
private void DropFavorite(SpellFavoriteDragPayload payload, int targetTab, int targetPosition)
{
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
_spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId);
_selected[targetTab] = payload.SpellId;
}
private void OnSpellbookChanged() => _favoritesDirty = true;
private void OnObjectChanged(ClientObject _) => _endowmentDirty = true;
private void OnObjectsCleared() => _endowmentDirty = true;
public void Tick()
{
if (_endowmentDirty)
{
_endowmentDirty = false;
UpdateEndowment();
SelectTab(_activeTab);
}
if (_favoritesDirty)
{
_favoritesDirty = false;
Rebuild();
}
}
private void SyncSelection()
{
for (int tab = 0; tab < 8; tab++)
{
UiItemList? list = _lists[tab];
if (list is null) continue;
for (int i = 0; i < list.GetNumUIItems(); i++)
if (list.GetItem(i) is UiCatalogSlot slot)
slot.Selected = slot.EntryId == _selected[tab];
_endowmentSlot.Selected = _endowmentItemId != 0u
&& _endowmentSelected[_activeTab];
}
}
private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability();
private void UpdateCastAvailability()
=> _cast.Enabled = _endowmentSelected[_activeTab]
? _endowmentItemId != 0u
: _selected[_activeTab] is uint spellId
&& _casting.IsTargetReady(spellId);
private void ConfigureSpellName()
{
if (_spellName is null) return;
_spellName.OneLine = true;
_spellName.Centered = true;
_spellName.Padding = 0;
_spellName.LinesProvider = () =>
{
uint? chosenSpell = _endowmentSelected[_activeTab]
? (_endowmentSpellId == 0u ? null : _endowmentSpellId)
: _selected[_activeTab];
string name = chosenSpell is uint spellId
&& _spellbook.TryGetMetadata(spellId, out SpellMetadata metadata)
? metadata.Name : string.Empty;
return [new UiText.Line(name, _spellName.DefaultColor)];
};
}
private void UpdateEndowment()
{
ClientObject? endowment = _objects.GetEquippedBy(_playerGuid())
.FirstOrDefault(item =>
(item.CurrentlyEquippedLocation & EquipMask.Held) != 0
&& (item.Type & ItemType.Caster) != 0
&& item.SpellId.GetValueOrDefault() != 0u);
_endowmentItemId = endowment?.ObjectId ?? 0u;
_endowmentSpellId = endowment?.SpellId ?? 0u;
_endowmentHost.Visible = endowment is not null;
_endowmentSlot.EntryId = _endowmentItemId;
_endowmentSlot.CatalogIconTexture = _endowmentSpellId == 0u
? 0u : _resolveSpellIcon(_endowmentSpellId);
_endowmentSlot.CatalogOverlayTexture = endowment is null
? 0u : _resolveItemDragIcon(endowment);
string spellName = _spellbook.TryGetMetadata(_endowmentSpellId, out SpellMetadata metadata)
? metadata.Name : $"Spell {_endowmentSpellId}";
_endowmentSlot.Label = endowment is null
? string.Empty : $"{endowment.GetAppropriateName()} ({spellName})";
for (int tab = 0; tab < 8; tab++)
{
if (_endowmentItemId != 0u && _selected[tab] is null)
_endowmentSelected[tab] = true;
else if (_endowmentItemId == 0u && _endowmentSelected[tab])
_endowmentSelected[tab] = false;
}
}
private static IEnumerable<UiElement> Descendants(UiElement root)
{
foreach (UiElement child in root.Children)
{
yield return child;
foreach (UiElement nested in Descendants(child)) yield return nested;
}
}
public void OnShown() => SyncSelection();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.SpellbookChanged -= OnSpellbookChanged;
_selection.Changed -= OnSelectionChanged;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
_objects.ObjectRemoved -= OnObjectChanged;
_objects.Cleared -= OnObjectsCleared;
foreach (UiElement tab in _tabs) SetClick(tab, null);
_cast.OnClick = null;
}
private static void SetClick(UiElement element, Action? action)
{
element.ClickThrough = action is null;
switch (element)
{
case UiButton button: button.OnClick = action; break;
case UiText text: text.OnClick = action; break;
case UiDatElement dat: dat.OnClick = action; break;
}
}
private static void SetSelected(UiElement element, bool selected)
{
if (element is UiButton button)
button.Selected = selected;
else if (element is UiText text)
text.DefaultColor = selected
? new System.Numerics.Vector4(1f, 1f, 1f, 1f)
: new System.Numerics.Vector4(0.65f, 0.65f, 0.65f, 1f);
}
}
public sealed record SpellFavoriteDragPayload(int SourceTab, int SourcePosition, uint SpellId);
internal static class FavoriteListExtensions
{
public static int IndexOf(this IReadOnlyList<uint> values, uint value)
{
for (int i = 0; i < values.Count; i++) if (values[i] == value) return i;
return -1;
}
}

View file

@ -1,24 +1,36 @@
namespace AcDream.App.UI;
/// <summary>
/// Retail toolbar panel ids carried by DAT property <c>0x10000029</c>.
/// Retail reference: <c>gmToolbarUI::PostInit @ 0x004BEA80</c> discovers the
/// seven buttons and reads this property from each one.
/// Only panels mounted in the retained runtime are mapped; other authored
/// toolbar buttons remain present but ghosted until their panel ships.
/// Retail <c>gmPanelUI</c> panel ids carried by DAT property <c>0x10000029</c>.
/// The toolbar exposes only a subset of these ids; Helpful/Harmful effects use
/// the authored <c>gmUIElement_EffectsIndicator</c> buttons instead.
/// </summary>
public static class RetailPanelCatalog
{
public const uint PositiveEffects = 4u;
public const uint NegativeEffects = 5u;
public const uint Inventory = 7u;
public const uint Character = 11u;
public const uint Magic = 13u;
private static readonly (uint PanelId, string WindowName)[] Mounted =
{
(PositiveEffects, WindowNames.PositiveEffects),
(NegativeEffects, WindowNames.NegativeEffects),
(Inventory, WindowNames.Inventory),
(Character, WindowNames.Character),
(Magic, WindowNames.Spellbook),
};
private static readonly (uint PanelId, string WindowName)[] Toolbar =
{
(Inventory, WindowNames.Inventory),
(Character, WindowNames.Character),
(Magic, WindowNames.Spellbook),
};
public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted;
public static IReadOnlyList<(uint PanelId, string WindowName)> ToolbarPanels => Toolbar;
public static bool TryGetWindowName(uint panelId, out string windowName)
{

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;

View file

@ -0,0 +1,97 @@
using System;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Icon-list cell for non-weenie catalog entries such as spells and components.
/// It reuses the retail UIItemList geometry without pretending a spell id is an
/// object GUID and without participating in item drag/drop.
/// </summary>
public sealed class UiCatalogSlot : UiItemSlot
{
public uint EntryId { get; set; }
public uint CatalogIconTexture { get; set; }
/// <summary>Optional second 32x32 layer (retail spell endowment item icon).</summary>
public uint CatalogOverlayTexture { get; set; }
public string Label { get; set; } = string.Empty;
public string? Detail { get; set; }
public bool ShowLabel { get; init; }
public new Action? Clicked { get; set; }
public new Action? DoubleClicked { get; set; }
public object? CatalogDragPayload { get; init; }
public Action<object>? DragBegan { get; init; }
public Action<object>? DragEnded { get; init; }
public Action<object>? Dropped { get; init; }
public override string? GetTooltipText() => string.IsNullOrWhiteSpace(Label) ? null : Label;
public override bool IsDragSource => CatalogDragPayload is not null;
public override object? GetDragPayload() => CatalogDragPayload;
public override (uint tex, int w, int h)? GetDragGhost() =>
CatalogDragPayload is not null && CatalogIconTexture != 0u
? (CatalogIconTexture, 32, 32)
: null;
internal override void SetDragSourceActive(bool active, object? payload)
{
if (!active && payload is not null) DragEnded?.Invoke(payload);
}
public override bool OnEvent(in UiEvent e)
{
switch (e.Type)
{
case UiEventType.MouseDown:
return true;
case UiEventType.Click:
Clicked?.Invoke();
return true;
case UiEventType.DoubleClick:
DoubleClicked?.Invoke();
return true;
case UiEventType.DragBegin:
if (e.Payload is not null) DragBegan?.Invoke(e.Payload);
return true;
case UiEventType.DragEnter:
case UiEventType.DragOver:
return true;
case UiEventType.DropReleased:
if (e.Payload is not null) Dropped?.Invoke(e.Payload);
return true;
default:
return false;
}
}
protected override void OnDraw(UiRenderContext ctx)
{
float iconSize = ShowLabel ? MathF.Min(32f, Height) : Width;
if (CatalogIconTexture != 0)
ctx.DrawSprite(CatalogIconTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
else if (SpriteResolve is not null && EmptySprite != 0)
{
var (texture, _, _) = SpriteResolve(EmptySprite);
if (texture != 0)
ctx.DrawSprite(texture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
}
if (CatalogOverlayTexture != 0)
ctx.DrawSprite(CatalogOverlayTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
if (Selected && SpriteResolve is not null && SelectedSprite != 0)
{
var (texture, _, _) = SpriteResolve(SelectedSprite);
if (texture != 0)
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
if (ShowLabel)
{
ctx.DrawString(Label, iconSize + 4f, 3f, new Vector4(0.92f, 0.88f, 0.70f, 1f));
if (!string.IsNullOrWhiteSpace(Detail))
ctx.DrawString(Detail!, MathF.Max(iconSize + 4f, Width - 48f), 3f, Vector4.One);
}
}
}

View file

@ -61,6 +61,7 @@ public sealed class UiField : UiElement
private int _caret;
private int? _selAnchor; // selection fixed end (null = no selection); span = [min,max] with _caret
public string Text => _text;
public bool IsFocused => _focused;
public int CaretPos => _caret;
private readonly List<string> _history = new();

View file

@ -10,7 +10,7 @@ namespace AcDream.App.UI;
/// pre-composited icon texture (set by the controller). Holds the bound weenie
/// guid (retail UIElement_UIItem::itemID, +0x5FC).
/// </summary>
public sealed class UiItemSlot : UiElement
public class UiItemSlot : UiElement
{
public UiItemSlot() { ClickThrough = false; }

View file

@ -22,6 +22,9 @@ namespace AcDream.App.UI;
/// </summary>
public sealed class UiText : UiElement
{
/// <summary>Optional base-element click notice used by authored text tabs.</summary>
public Action? OnClick { get; set; }
public override bool HandlesClick => OnClick is not null || base.HandlesClick;
/// <summary>Dat element id for imported UIElement_Text widgets. 0 for synthesized text.</summary>
public uint ElementId { get; set; }
@ -360,6 +363,11 @@ public sealed class UiText : UiElement
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && OnClick is not null)
{
OnClick();
return true;
}
switch (e.Type)
{
case UiEventType.Scroll:

View file

@ -12,4 +12,8 @@ public static class WindowNames
public const string Radar = "radar";
public const string Combat = "combat";
public const string JumpPowerbar = "jump-powerbar";
public const string Spellbook = "spellbook";
public const string Indicators = "indicators";
public const string PositiveEffects = "effects-positive";
public const string NegativeEffects = "effects-negative";
}