fix(ui): restore retail spell tabs and spellbook

Bind the spellbook and component-book tabs as their authored stateful text controls, propagate Closed/Open state into the retained DAT child tree, and keep authored label colors live across state changes.

Match retail UIElement_Text zero-margin construction so the Magic favorite captions I through VIII are no longer clipped. Add a real portal.dat spellbook fixture plus controller, state-propagation, and text-layout regression coverage.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 15:54:11 +02:00
parent 07be994d97
commit ad30c37a48
15 changed files with 13492 additions and 38 deletions

View file

@ -19,7 +19,9 @@ active enchantment, then recall through the DAT-driven portal presentation.
2. **Done (automated):** authored Magic combat page, eight favorite tabs, 2. **Done (automated):** authored Magic combat page, eight favorite tabs,
equipped caster endowment, scoped retail keys, spellbook/component book, equipped caster endowment, scoped retail keys, spellbook/component book,
positive/negative active-enchantment panels, Helpful/Harmful indicator positive/negative active-enchantment panels, Helpful/Harmful indicator
buttons, and the retail one-active-main-panel lifecycle. buttons, and the retail one-active-main-panel lifecycle. Connected-gate
correction now also pins type-12 spellbook tabs, retail zero text margins,
and `PassToChildren` Closed/Open chrome through real-DAT fixtures.
3. **Pending user visual gate:** connected projectile/self-buff casts plus the 3. **Pending user visual gate:** connected projectile/self-buff casts plus the
complete retained magic UI interaction checklist. complete retained magic UI interaction checklist.
4. **Pending user visual gate:** two-client portal-out/materialization observer 4. **Pending user visual gate:** two-client portal-out/materialization observer

View file

@ -645,6 +645,10 @@ Helpful/Harmful indicators are mounted in the retained runtime. Main-panel
visibility now flows through retail's one-active `gmPanelUI` owner. M3 remains visibility now flows through retail's one-active `gmPanelUI` owner. M3 remains
active only for the connected single-client magic active only for the connected single-client magic
UI/casting visual gate and the final two-client portal observer gate. UI/casting visual gate and the final two-client portal observer gate.
The first connected gate exposed and corrected two retained-import defects:
spellbook tabs are authored stateful text controls (not buttons), and retail
text starts with zero margins. Real-DAT fixtures now pin spellbook binding,
Closed/Open child-state propagation, and Magic-tab strings I through VIII.
**Phases to ship:** **Phases to ship:**
- **F.4** — Spell cast state machine (buffs + recalls first, projectile - **F.4** — Spell cast state machine (buffs + recalls first, projectile

View file

@ -198,6 +198,43 @@ component item list 0x10000464
component scrollbar 0x10000465 component scrollbar 0x10000465
``` ```
The two top-level tabs are not `UIElement_Button` instances. They resolve from
base `0x1000043A` as `UIElement_Text` (type 12), with state 11 `Closed` and
state 12 `Open`. Both states set `PassToChildren`; the three child chrome
elements carry the left/center/right Closed/Open RenderSurface media.
Retail state and text construction used by these tabs:
```text
UIElement_Text::UIElement_Text @ 0x00468570
construct UIElement_Scrollable
bitField = 0x300 // OneLine bit 0x2 is clear
horizontalJustification = 2
verticalJustification = 4
marginUp/Down/Left/Right = 0
UIElement_Text::OnSetAttribute @ 0x0046A640
property 0x20 only:
SetOneLine(value)
// absent 0x20 therefore remains multi-line; do not force it true
UIElement::SetState @ 0x00464E70
next = desc.AccessStateDesc(stateId)
if next != current:
current = next
if next.PassToChildren:
for each child:
child.SetState(stateId)
diff old/new effective properties
call OnSetAttribute for changed properties
```
The Magic-combat favorite tab captions are authored text (`I` through `VIII`)
and intentionally omit property `0x20`. Their small rectangles still render
because retail begins with zero text margins. A generic four-pixel inset on
all sides leaves less than one DAT-font line and clips the captions completely;
that inset is not retail behavior.
Spellbook rows come only from the authoritative learned-spell set. Retail Spellbook rows come only from the authoritative learned-spell set. Retail
sorts by `CSpellBase::_display_order`; school and level buttons apply the sorts by `CSpellBase::_display_order`; school and level buttons apply the
server-persisted filter bitfield. Component rows use the installed component server-persisted filter bitfield. Component rows use the installed component

View file

@ -525,6 +525,7 @@ public static class DatWidgetFactory
// set DatFont afterward STILL override this (backward-compat guarantee). // set DatFont afterward STILL override this (backward-compat guarantee).
DatFont = elementFont, DatFont = elementFont,
}; };
t.ConfigureDatState(info);
// Font color from dat property 0x1B (ColorBaseProperty). // Font color from dat property 0x1B (ColorBaseProperty).
// When present, seed DefaultColor so controllers that read it don't have to hard-code colors. // When present, seed DefaultColor so controllers that read it don't have to hard-code colors.
@ -534,10 +535,7 @@ public static class DatWidgetFactory
t.DefaultColor = info.FontColor.Value; t.DefaultColor = info.FontColor.Value;
if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored) if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored)
{ t.LinesProvider = () => [new UiText.Line(authored, t.DefaultColor)];
UiText.Line[] line = [new UiText.Line(authored, t.DefaultColor)];
t.LinesProvider = () => line;
}
return t; return t;
} }

View file

@ -160,6 +160,13 @@ public static class LayoutImporter
} }
} }
// UIElement::SetState @ 0x00464E70 propagates a state's id only after the
// child tree exists. Re-applying the imported default here gives retained
// PassToChildren tabs their authored Open/Closed child media without turning
// spatial construction into controller-specific special cases.
if (w is IUiDatStateful stateful)
stateful.TrySetRetailState(stateful.ActiveRetailStateId);
return w; return w;
} }

View file

@ -14,7 +14,9 @@ public enum SpellbookWindowPage { Spells, Components }
/// <summary> /// <summary>
/// Binds retail's combined spellbook/component-book LayoutDesc 0x21000034. /// Binds retail's combined spellbook/component-book LayoutDesc 0x21000034.
/// The spell page mirrors gmSpellbookUI filters; the component page mirrors /// The spell page mirrors gmSpellbookUI filters; the component page mirrors
/// ComponentTracker and exposes the 0..5000 desired purchase level field. /// ComponentTracker and exposes the 0..5000 desired purchase level field. The
/// two authored tabs are stateful UIElement_Text controls, matching
/// <c>gmSpellbookUI::PostInit @ 0x0048B2B0</c> and the resolved retail layout.
/// </summary> /// </summary>
public sealed class SpellbookWindowController : IRetainedPanelController public sealed class SpellbookWindowController : IRetainedPanelController
{ {
@ -54,8 +56,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private readonly Action _close; private readonly Action _close;
private readonly UiElement _spellPage; private readonly UiElement _spellPage;
private readonly UiElement _componentPage; private readonly UiElement _componentPage;
private readonly UiButton _spellTab; private readonly UiElement _spellTab;
private readonly UiButton _componentTab; private readonly UiElement _componentTab;
private readonly UiButton _closeButton; private readonly UiButton _closeButton;
private readonly UiItemList _spellList; private readonly UiItemList _spellList;
private readonly UiItemList _componentList; private readonly UiItemList _componentList;
@ -82,8 +84,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
Action close, Action close,
UiElement spellPage, UiElement spellPage,
UiElement componentPage, UiElement componentPage,
UiButton spellTab, UiElement spellTab,
UiButton componentTab, UiElement componentTab,
UiButton closeButton, UiButton closeButton,
UiItemList spellList, UiItemList spellList,
UiItemList componentList) UiItemList componentList)
@ -108,8 +110,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_spellList = spellList; _spellList = spellList;
_componentList = componentList; _componentList = componentList;
spellTab.OnClick = () => ShowPage(SpellbookWindowPage.Spells); SetClick(spellTab, () => ShowPage(SpellbookWindowPage.Spells));
componentTab.OnClick = () => ShowPage(SpellbookWindowPage.Components); SetClick(componentTab, () => ShowPage(SpellbookWindowPage.Components));
closeButton.OnClick = close; closeButton.OnClick = close;
foreach ((uint id, uint mask) in FilterButtons) foreach ((uint id, uint mask) in FilterButtons)
{ {
@ -151,8 +153,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
{ {
if (layout.FindElement(SpellPageId) is not { } spellPage if (layout.FindElement(SpellPageId) is not { } spellPage
|| layout.FindElement(ComponentPageId) is not { } componentPage || layout.FindElement(ComponentPageId) is not { } componentPage
|| layout.FindElement(SpellTabId) is not UiButton spellTab || layout.FindElement(SpellTabId) is not { } spellTab
|| layout.FindElement(ComponentTabId) is not UiButton componentTab || layout.FindElement(ComponentTabId) is not { } componentTab
|| layout.FindElement(CloseId) is not UiButton closeButton || layout.FindElement(CloseId) is not UiButton closeButton
|| layout.FindElement(SpellListId) is not UiItemList spellList) || layout.FindElement(SpellListId) is not UiItemList spellList)
return null; return null;
@ -187,8 +189,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
CurrentPage = page; CurrentPage = page;
_spellPage.Visible = page == SpellbookWindowPage.Spells; _spellPage.Visible = page == SpellbookWindowPage.Spells;
_componentPage.Visible = page == SpellbookWindowPage.Components; _componentPage.Visible = page == SpellbookWindowPage.Components;
_spellTab.Selected = page == SpellbookWindowPage.Spells; SetTabOpen(_spellTab, page == SpellbookWindowPage.Spells);
_componentTab.Selected = page == SpellbookWindowPage.Components; SetTabOpen(_componentTab, page == SpellbookWindowPage.Components);
} }
private void ConfigureSpellList() private void ConfigureSpellList()
@ -449,9 +451,29 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_objects.ObjectRemoved -= OnObjectRemoved; _objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectMoved -= OnObjectMoved; _objects.ObjectMoved -= OnObjectMoved;
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced; _objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
_spellTab.OnClick = null; SetClick(_spellTab, null);
_componentTab.OnClick = null; SetClick(_componentTab, null);
_closeButton.OnClick = null; _closeButton.OnClick = null;
foreach ((UiButton button, _) in _filters) button.OnClick = null; foreach ((UiButton button, _) in _filters) button.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 SetTabOpen(UiElement element, bool open)
{
if (element is IUiDatStateful stateful
&& stateful.TrySetRetailState(open ? RetailUiStateIds.Open : RetailUiStateIds.Closed))
return;
if (element is UiButton button)
button.Selected = open;
}
} }

View file

@ -451,6 +451,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController
private static void SetSelected(UiElement element, bool selected) private static void SetSelected(UiElement element, bool selected)
{ {
if (element is IUiDatStateful stateful
&& stateful.TrySetRetailState(selected ? RetailUiStateIds.Open : RetailUiStateIds.Closed))
return;
if (element is UiButton button) if (element is UiButton button)
button.Selected = selected; button.Selected = selected;
else if (element is UiText text) else if (element is UiText text)

View file

@ -70,29 +70,35 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
public bool TrySetRetailState(uint stateId) public bool TrySetRetailState(uint stateId)
{ {
UiStateInfo? selectedState = null;
if (stateId == UiStateInfo.DirectStateId) if (stateId == UiStateInfo.DirectStateId)
{ {
if (!_info.States.ContainsKey(stateId) && !_info.StateMedia.ContainsKey("")) if (!_info.States.TryGetValue(stateId, out selectedState)
&& !_info.StateMedia.ContainsKey(""))
return false; return false;
ActiveState = ""; ActiveState = "";
return true;
} }
else if (_info.States.TryGetValue(stateId, out selectedState))
if (_info.States.TryGetValue(stateId, out var state))
{ {
ActiveState = state.Name; ActiveState = selectedState.Name;
return true;
} }
else
string stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (!string.IsNullOrEmpty(stateName) && _info.StateMedia.ContainsKey(stateName))
{ {
string stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (string.IsNullOrEmpty(stateName) || !_info.StateMedia.ContainsKey(stateName))
return false;
ActiveState = stateName; ActiveState = stateName;
return true;
} }
return false;
if (selectedState?.PassToChildren == true)
{
foreach (UiElement child in Children)
if (child is IUiDatStateful stateful)
stateful.TrySetRetailState(stateId);
}
return true;
} }
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param> /// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>

View file

@ -20,7 +20,7 @@ namespace AcDream.App.UI;
/// Display-only text remains click-through and cannot steal focus or window drag. /// Display-only text remains click-through and cannot steal focus or window drag.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class UiText : UiElement public sealed class UiText : UiElement, IUiDatStateful
{ {
/// <summary>Optional base-element click notice used by authored text tabs.</summary> /// <summary>Optional base-element click notice used by authored text tabs.</summary>
public Action? OnClick { get; set; } public Action? OnClick { get; set; }
@ -79,8 +79,12 @@ public sealed class UiText : UiElement
/// <summary>Highlight colour painted behind a selected character span.</summary> /// <summary>Highlight colour painted behind a selected character span.</summary>
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
/// <summary>Inner text inset from the view edges, px.</summary> /// <summary>
public float Padding { get; set; } = 4f; /// Uniform inner text inset in pixels. The retail default is zero:
/// <c>UIElement_Text::UIElement_Text @ 0x00468570</c> clears all four margins
/// before applying any authored margin properties.
/// </summary>
public float Padding { get; set; }
/// <summary>Retail property 0x20. Independent of horizontal/vertical /// <summary>Retail property 0x20. Independent of horizontal/vertical
/// justification; false permits the normal multi-line layout path.</summary> /// justification; false permits the normal multi-line layout path.</summary>
@ -156,7 +160,11 @@ public sealed class UiText : UiElement
private UiDatFont? _lastDatFont; private UiDatFont? _lastDatFont;
private float _lastLineHeight = 16f; private float _lastLineHeight = 16f;
private float _lastBaseY; // top Y of line 0 in local space private float _lastBaseY; // top Y of line 0 in local space
private float _lastPadding = 4f; private float _lastPadding;
private ElementInfo? _datInfo;
private uint _activeRetailStateId = UiStateInfo.DirectStateId;
private string _activeDatStateName = "";
// ── Selection state ────────────────────────────────────────────────── // ── Selection state ──────────────────────────────────────────────────
private Pos? _selAnchor; // where the drag started private Pos? _selAnchor; // where the drag started
@ -172,9 +180,105 @@ public sealed class UiText : UiElement
CapturesPointerDrag = false; CapturesPointerDrag = false;
} }
/// <summary>The text view draws its own lines + background; any dat sub-elements /// <summary>
/// (scroll indicators, caps) are not built as separate widgets by the importer.</summary> /// Text widgets normally reproduce their own scroll/edit chrome. Retail tab text,
public override bool ConsumesDatChildren => true; /// however, uses <c>PassToChildren</c> states whose child elements are the authored
/// Open/Closed cap sprites. Those children must remain in the retained tree so
/// <see cref="TrySetRetailState"/> can propagate the state exactly as retail does.
/// </summary>
public override bool ConsumesDatChildren
{
get
{
if (_datInfo is null) return true;
foreach (UiStateInfo state in _datInfo.States.Values)
if (state.PassToChildren)
return false;
return true;
}
}
public uint ActiveRetailStateId => _activeRetailStateId;
public override string ActiveCursorStateName => _activeDatStateName;
internal void ConfigureDatState(ElementInfo info)
{
_datInfo = info;
_activeRetailStateId = info.EffectiveDefaultStateId();
ApplyDatState(_activeRetailStateId, propagate: false);
}
public bool TrySetRetailState(uint stateId)
=> ApplyDatState(stateId, propagate: true);
private bool ApplyDatState(uint stateId, bool propagate)
{
if (_datInfo is null) return false;
UiStateInfo? state = null;
string stateName;
if (stateId == UiStateInfo.DirectStateId)
{
if (!_datInfo.States.TryGetValue(stateId, out state)
&& !_datInfo.StateMedia.ContainsKey(""))
return false;
stateName = "";
}
else if (_datInfo.States.TryGetValue(stateId, out state))
{
stateName = state.Name;
}
else
{
stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (string.IsNullOrEmpty(stateName)
|| !_datInfo.StateMedia.ContainsKey(stateName))
return false;
}
_activeRetailStateId = stateId;
_activeDatStateName = stateName;
BackgroundSprite = _datInfo.StateMedia.TryGetValue(stateName, out var media)
? media.File
: _datInfo.StateMedia.TryGetValue("", out var direct) ? direct.File : 0u;
if (_datInfo.TryGetEffectiveProperty(0x1Bu, out UiPropertyValue color, stateId)
&& TryColor(color, out Vector4 resolvedColor))
DefaultColor = resolvedColor;
if (propagate && state?.PassToChildren == true)
{
foreach (UiElement child in Children)
if (child is IUiDatStateful stateful)
stateful.TrySetRetailState(stateId);
}
return true;
}
private static bool TryColor(UiPropertyValue property, out Vector4 color)
{
UiPropertyValue? value = property.Kind == UiPropertyKind.Color
? property
: property.Kind == UiPropertyKind.Array
&& property.ArrayValue.Count > 0
&& property.ArrayValue[0].Kind == UiPropertyKind.Color
? property.ArrayValue[0]
: null;
if (value is null)
{
color = default;
return false;
}
UiColorValue c = value.ColorValue;
float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f;
color = new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
return true;
}
/// <summary> /// <summary>
/// Clamp a scroll offset to [0, max] where max = content-height - view-height /// Clamp a scroll offset to [0, max] where max = content-height - view-height

View file

@ -482,6 +482,50 @@ public class DatWidgetFactoryTests
Assert.Equal(gold, t.DefaultColor); Assert.Equal(gold, t.DefaultColor);
} }
[Fact]
public void BuildText_RetailConstructorDefaultsToZeroMargins()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20 };
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(0f, text.Padding);
Assert.False(text.OneLine);
}
[Fact]
public void BuildText_AuthoredLineTracksStateFontColor()
{
var info = new ElementInfo
{
Type = 12,
Width = 100,
Height = 20,
DefaultStateId = RetailUiStateIds.Closed,
DefaultStateName = "Closed",
};
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
direct.Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, 1, 2, 0, 1, 0),
};
var closed = new UiStateInfo { Id = RetailUiStateIds.Closed, Name = "Closed" };
closed.Properties.Values[0x1Bu] = Color(127, 127, 127);
var open = new UiStateInfo { Id = RetailUiStateIds.Open, Name = "Open" };
open.Properties.Values[0x1Bu] = Color(204, 204, 204);
info.States[UiStateInfo.DirectStateId] = direct;
info.States[RetailUiStateIds.Closed] = closed;
info.States[RetailUiStateIds.Open] = open;
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
info, NoTex, null, stringResolve: _ => "Spells"));
Assert.Equal(127f / 255f, text.LinesProvider()[0].Color.X, 5);
Assert.True(text.TrySetRetailState(RetailUiStateIds.Open));
Assert.Equal(204f / 255f, text.LinesProvider()[0].Color.X, 5);
}
[Fact] [Fact]
public void HorizontalScrollbar_PreservesNestedCombatMeterFillSprite() public void HorizontalScrollbar_PreservesNestedCombatMeterFillSprite()
{ {
@ -560,6 +604,13 @@ public class DatWidgetFactoryTests
private static UiPropertyValue Integer(int value) private static UiPropertyValue Integer(int value)
=> new() { Kind = UiPropertyKind.Integer, IntegerValue = value }; => new() { Kind = UiPropertyKind.Integer, IntegerValue = value };
private static UiPropertyValue Color(byte red, byte green, byte blue)
=> new()
{
Kind = UiPropertyKind.Color,
ColorValue = new UiColorValue(blue, green, red, 255),
};
/// <summary> /// <summary>
/// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must /// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must
/// stay at white (Vector4.One) — the backward-compatible default. /// stay at white (Vector4.One) — the backward-compatible default.

View file

@ -95,6 +95,12 @@ public static class FixtureLoader
public static ElementInfo LoadCombatInfos() public static ElementInfo LoadCombatInfos()
=> LoadInfos("combat_21000073.json"); => LoadInfos("combat_21000073.json");
public static ImportedLayout LoadSpellbook()
=> LayoutImporter.Build(LoadSpellbookInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadSpellbookInfos()
=> LoadInfos("spellbook_21000034.json");
public static ImportedLayout LoadPowerbar() public static ImportedLayout LoadPowerbar()
=> LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null); => LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null);

View file

@ -71,6 +71,54 @@ public class LayoutImporterTests
Assert.NotNull(tree.FindElement(0x20000002)); Assert.NotNull(tree.FindElement(0x20000002));
} }
[Fact]
public void BuildFromInfos_TextPassToChildren_PropagatesDefaultAndRuntimeState()
{
var tab = new ElementInfo
{
Id = 0x100002A9u,
Type = 12,
Width = 100,
Height = 25,
DefaultStateId = RetailUiStateIds.Closed,
DefaultStateName = "Closed",
};
tab.States[RetailUiStateIds.Closed] = new UiStateInfo
{ Id = RetailUiStateIds.Closed, Name = "Closed", PassToChildren = true };
tab.States[RetailUiStateIds.Open] = new UiStateInfo
{ Id = RetailUiStateIds.Open, Name = "Open", PassToChildren = true };
var cap = new ElementInfo { Id = 0x10000439u, Type = 3, Width = 17, Height = 25 };
cap.States[RetailUiStateIds.Closed] = new UiStateInfo
{
Id = RetailUiStateIds.Closed,
Name = "Closed",
Image = new UiImageMedia(0x06005D93u, 3),
};
cap.States[RetailUiStateIds.Open] = new UiStateInfo
{
Id = RetailUiStateIds.Open,
Name = "Open",
Image = new UiImageMedia(0x06005D92u, 3),
};
cap.StateMedia["Closed"] = (0x06005D93u, 3);
cap.StateMedia["Open"] = (0x06005D92u, 3);
tab.Children.Add(cap);
var root = new ElementInfo { Id = 1u, Type = 3, Width = 100, Height = 25 };
ImportedLayout layout = LayoutImporter.BuildFromInfos(root, [tab], NoTex, null);
UiText text = Assert.IsType<UiText>(layout.FindElement(tab.Id));
UiDatElement child = Assert.IsType<UiDatElement>(layout.FindElement(cap.Id));
Assert.Single(text.Children);
Assert.Equal(RetailUiStateIds.Closed, child.ActiveRetailStateId);
Assert.Equal(0x06005D93u, child.ActiveMedia().File);
Assert.True(text.TrySetRetailState(RetailUiStateIds.Open));
Assert.Equal(RetailUiStateIds.Open, child.ActiveRetailStateId);
Assert.Equal(0x06005D92u, child.ActiveMedia().File);
}
// ── Test 3: Meter consumes Type-3 slice children — child ids not in byId ──── // ── Test 3: Meter consumes Type-3 slice children — child ids not in byId ────
/// <summary> /// <summary>

View file

@ -21,6 +21,7 @@ public sealed class RetailLayoutFixtureGenerator
(0x21000023u, "inventory_21000023.json"), (0x21000023u, "inventory_21000023.json"),
(0x21000024u, "paperdoll_21000024.json"), (0x21000024u, "paperdoll_21000024.json"),
(0x2100002Eu, "character_2100002E.json"), (0x2100002Eu, "character_2100002E.json"),
(SpellbookWindowController.LayoutId, "spellbook_21000034.json"),
(0x2100006Cu, "vitals_2100006C.json"), (0x2100006Cu, "vitals_2100006C.json"),
(EffectsIndicatorController.LayoutId, "indicators_21000071.json"), (EffectsIndicatorController.LayoutId, "indicators_21000071.json"),
(0x21000072u, "powerbar_21000072.json"), (0x21000072u, "powerbar_21000072.json"),
@ -116,6 +117,21 @@ public sealed class RetailLayoutFixtureGenerator
Assert.Equal("High", labels.High); Assert.Equal("High", labels.High);
Assert.Equal("Medium", labels.Medium); Assert.Equal("Medium", labels.Medium);
Assert.Equal("Low", labels.Low); Assert.Equal("Low", labels.Low);
var strings = new DatStringResolver(dats);
uint[] tabIds =
{
0x100000A3u, 0x100000A4u, 0x100000A5u, 0x100000A6u,
0x100000A7u, 0x100000A8u, 0x100000A9u, 0x100005C2u,
};
string[] expected = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII"];
for (int i = 0; i < tabIds.Length; i++)
{
uint id = tabIds[i];
ElementInfo tab = FindInfo(info, id)!;
Assert.NotNull(tab);
Assert.True(tab.TryGetEffectiveProperty(0x17u, out UiPropertyValue label));
Assert.Equal(expected[i], strings.Resolve(label.StringInfoValue));
}
} }
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
{ {
@ -129,4 +145,12 @@ public sealed class RetailLayoutFixtureGenerator
private static string FixtureDirectory([CallerFilePath] string thisFile = "") private static string FixtureDirectory([CallerFilePath] string thisFile = "")
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures"); => Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures");
private static ElementInfo? FindInfo(ElementInfo root, uint id)
{
if (root.Id == id) return root;
foreach (ElementInfo child in root.Children)
if (FindInfo(child, id) is { } match) return match;
return null;
}
} }

View file

@ -0,0 +1,56 @@
using AcDream.App.Spells;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.UI.Layout;
public sealed class SpellbookWindowControllerTests
{
[Fact]
public void RetailFixture_BindsTextTabs_AndPropagatesOpenClosedState()
{
ImportedLayout layout = FixtureLoader.LoadSpellbook();
var closed = new List<uint>();
using SpellbookWindowController? controller = SpellbookWindowController.Bind(
layout,
new Spellbook(),
new ClientObjectTable(),
() => 1u,
new Dictionary<uint, SpellComponentDescriptor>(),
spellId => spellId,
iconId => iconId,
_ => 1,
_ => { },
_ => { },
_ => { },
(_, _) => { },
() => closed.Add(1u));
Assert.NotNull(controller);
UiText spellTab = Assert.IsType<UiText>(layout.FindElement(SpellbookWindowController.SpellTabId));
UiText componentTab = Assert.IsType<UiText>(layout.FindElement(SpellbookWindowController.ComponentTabId));
UiElement spellPage = Assert.IsAssignableFrom<UiElement>(layout.FindElement(SpellbookWindowController.SpellPageId));
UiElement componentPage = Assert.IsAssignableFrom<UiElement>(layout.FindElement(SpellbookWindowController.ComponentPageId));
Assert.Equal(RetailUiStateIds.Open, spellTab.ActiveRetailStateId);
Assert.Equal(RetailUiStateIds.Closed, componentTab.ActiveRetailStateId);
Assert.True(spellPage.Visible);
Assert.False(componentPage.Visible);
Assert.Equal(3, spellTab.Children.Count);
Assert.All(spellTab.Children, child =>
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
componentTab.OnEvent(new UiEvent(0, componentTab, UiEventType.Click));
Assert.Equal(SpellbookWindowPage.Components, controller!.CurrentPage);
Assert.Equal(RetailUiStateIds.Closed, spellTab.ActiveRetailStateId);
Assert.Equal(RetailUiStateIds.Open, componentTab.ActiveRetailStateId);
Assert.False(spellPage.Visible);
Assert.True(componentPage.Visible);
Assert.All(componentTab.Children, child =>
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
Assert.Empty(closed);
}
}

File diff suppressed because it is too large Load diff