fix(ui): share retail tab state binding
Replace the character window's synthesized tab sprites, labels, colors, and hit panels with the same DAT-authored Open/Closed state path used by Spellbook/Components. Preserve controller-owned page selection while letting UIElement state propagation own tab chrome and typography. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
1249ad03de
commit
15bb61e05d
5 changed files with 179 additions and 285 deletions
|
|
@ -34,9 +34,10 @@ namespace AcDream.App.UI.Layout;
|
|||
/// line-1 label = "Experience To Raise:", line-1 value = raise cost,
|
||||
/// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp.</para>
|
||||
///
|
||||
/// <para>Tab button states: Attributes = "Open", Skills = "Closed", Titles = "Closed".
|
||||
/// Source: UIStateId.Open (0x0C) / UIStateId.Closed (0x0B) — set on the UiButton children
|
||||
/// inside each tab group container.</para>
|
||||
/// <para>Tab states: Attributes = "Open", Skills = "Closed", Titles = "Closed".
|
||||
/// Source: <c>UIElement::SetState @ 0x00464E70</c> and UIStateId.Open (0x0C) /
|
||||
/// UIStateId.Closed (0x0B). The imported Type-12 tab owns its authored font color and
|
||||
/// propagates the state to its three chrome children through PassToChildren.</para>
|
||||
///
|
||||
/// <para>Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable
|
||||
/// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection
|
||||
|
|
@ -73,26 +74,10 @@ public static class CharacterStatController
|
|||
public const uint FooterStateCId = 0x10000247u; // State-C footer container (hide inactive)
|
||||
|
||||
// ── Tab bar element ids (LayoutDesc 0x2100002E root) ────────────────────
|
||||
// In the imported tree each tab group (0x10000228/229/538) resolves to a UiText with
|
||||
// ConsumesDatChildren=true, so the three button children (left-cap, label, right-cap)
|
||||
// are consumed at import time and not present in the widget tree.
|
||||
//
|
||||
// WHY this is NOT fixed in Fix 5 (importer series):
|
||||
// The Fix 5 meter patch only builds NON-Type-3 children of UiMeter. The tab group children
|
||||
// are children of UiText (Type 12), not UiMeter (Type 7). Flipping UiText.ConsumesDatChildren
|
||||
// globally is not safe — the chat transcript UiText and other Type-12 elements also have
|
||||
// children in some layouts (scroll decorators etc.), and building them as live UiText widgets
|
||||
// would risk invisible nodes stealing focus/clicks (the exact problem ConsumesDatChildren was
|
||||
// designed to prevent). Additionally, the page-visibility pass in Bind() depends on
|
||||
// tab group Children.Count==0 to skip them — adding children to the tab groups would break
|
||||
// that pass and hide ALL tab content.
|
||||
//
|
||||
// Therefore: the controller injects 3 sprite-drawing root children per tab (absolute coords),
|
||||
// using the known RenderSurface ids from the retail UI layout dump (2026-06-25):
|
||||
// Closed (inactive) state: left=0x06005D93, center=0x06005D95, right=0x06005D97
|
||||
// Open (active) state: left=0x06005D92, center=0x06005D94, right=0x06005D96
|
||||
// Source: state_id 11=Closed, 12=Open per gmTabUI::SetActive(bool); sprite ids confirmed
|
||||
// from retail UI layout dump nodes 0x10000439, 0x100000E9, 0x10000215 in layout 0x2100002E.
|
||||
// These are imported Type-12 UIElement_Text tabs. Their Closed/Open states carry
|
||||
// the caption color and PassToChildren=true; their three retained children carry
|
||||
// the authored left/center/right chrome. Character and Spellbook therefore share
|
||||
// RetailTabBinding and the generic IUiDatStateful path rather than synthesizing art.
|
||||
public const uint TabAttribId = 0x10000228u; // Attributes tab group
|
||||
public const uint TabSkillsId = 0x10000229u; // Skills tab group
|
||||
public const uint TabTitlesId = 0x10000538u; // Titles tab group
|
||||
|
|
@ -104,21 +89,6 @@ public static class CharacterStatController
|
|||
public const uint SkillsPageId = 0x1000022Cu;
|
||||
public const uint TitlesPageId = 0x10000539u;
|
||||
|
||||
// Tab button piece widths and shared height (from dump node rects).
|
||||
private const float TabLeftCapW = 17f; // left-cap button width
|
||||
private const float TabCenterW = 58f; // center label button width
|
||||
private const float TabRightCapW = 17f; // right-cap button width
|
||||
private const float TabH = 25f; // button height
|
||||
|
||||
// Tab sprite ids per state (Open = active, Closed = inactive).
|
||||
// Source: retail UI layout dump nodes 0x10000439/0x100000E9/0x10000215 in 0x2100002E.
|
||||
private const uint TabOpenLeft = 0x06005D92u;
|
||||
private const uint TabOpenCenter = 0x06005D94u;
|
||||
private const uint TabOpenRight = 0x06005D96u;
|
||||
private const uint TabClosedLeft = 0x06005D93u;
|
||||
private const uint TabClosedCenter = 0x06005D95u;
|
||||
private const uint TabClosedRight = 0x06005D97u;
|
||||
|
||||
// ── Footer element ids (gmStatManagementUI struct fields) ────────────────
|
||||
// Source: acclient.h / DisplayDefaultFooter (0x0049cde0)
|
||||
public const uint FooterTitleId = 0x1000024eu; // GetFooterTitleLabel
|
||||
|
|
@ -134,11 +104,6 @@ public static class CharacterStatController
|
|||
public const uint RaiseOneId = 0x10000246u; // raise × 1
|
||||
public const uint RaiseTenId = 0x100005EBu; // raise × 10
|
||||
|
||||
// ── UIStateId string keys (DatReaderWriter UIStateId enum.ToString()) ────
|
||||
// State names as returned by UIStateId.ToString() — used as ActiveState keys on UiButton.
|
||||
private const string StateOpen = "Open"; // UIStateId.Open = 0x0C — active tab
|
||||
private const string StateClosed = "Closed"; // UIStateId.Closed = 0x0B — inactive tab
|
||||
|
||||
private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
|
||||
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
|
||||
|
||||
|
|
@ -203,16 +168,6 @@ public static class CharacterStatController
|
|||
/// </summary>
|
||||
public delegate void RaiseRequestHandler(RaiseRequest request, Action completed);
|
||||
|
||||
private sealed class TabVisual
|
||||
{
|
||||
public required CharacterStatTab Tab { get; init; }
|
||||
public required UiText Left { get; init; }
|
||||
public required UiText Center { get; init; }
|
||||
public required UiText Right { get; init; }
|
||||
public required UiText Label { get; init; }
|
||||
public required string Text { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SkillRowBinding(UiClickablePanel Panel, CharacterSkill Skill);
|
||||
|
||||
// ── Attribute row descriptors — retail display order per spec §1 ─────────
|
||||
|
|
@ -270,7 +225,9 @@ public static class CharacterStatController
|
|||
var activeListEntries = new List<UiElement>();
|
||||
var currentAttributeRows = new List<UiClickablePanel>();
|
||||
var currentSkillRows = new List<SkillRowBinding>();
|
||||
var tabVisuals = new List<TabVisual>();
|
||||
UiElement? attributesTab = layout.FindElement(TabAttribId);
|
||||
UiElement? skillsTab = layout.FindElement(TabSkillsId);
|
||||
UiElement? titlesTab = layout.FindElement(TabTitlesId);
|
||||
UiElement? contentPage = FindDirectChildById(layout.Root, AttributesPageId);
|
||||
|
||||
// Name (18px from dat FontDid), Heritage (14px), PkStatus (14px):
|
||||
|
|
@ -350,20 +307,8 @@ public static class CharacterStatController
|
|||
}
|
||||
}
|
||||
|
||||
// ── Tab bar sprite children (added to PANEL ROOT, not to the tab groups) ───
|
||||
// The tab groups (0x10000228/229/538) are UiText (Type 12) with
|
||||
// ConsumesDatChildren=true, so their 3 button children were consumed at import.
|
||||
// We inject the visual equivalent — 3 sprite UiTexts per tab — as CHILDREN OF
|
||||
// layout.Root at the tab's ABSOLUTE screen position, NOT as children of the tab
|
||||
// groups. This matters because the page-visibility pass (below) iterates
|
||||
// root.Children and hides any child that doesn't contain NameId; if we added
|
||||
// sprites to the tab groups, those groups would have non-zero Children.Count
|
||||
// and be eligible for the pass, then hidden (since NameId is in the content area).
|
||||
// By adding to root instead, the tab groups keep Children.Count==0 and are skipped
|
||||
// by "if (page.Children.Count == 0) continue" — they stay visible always.
|
||||
// Source: retail dump tab rects confirmed (2026-06-25):
|
||||
// Attributes = (0,0,92,25), Skills = (92,0,92,25), Titles = (184,0,92,25).
|
||||
// Interactive tab sprites are injected after the list/footer closures are wired.
|
||||
// The tab visuals are already retained in the imported LayoutDesc. Controllers
|
||||
// bind only click behavior and the active Open/Closed state below.
|
||||
|
||||
// ── Attribute list — 9 rows in list box 0x1000023D ────────────────────
|
||||
// Mutable selected-index box: -1 = nothing selected.
|
||||
|
|
@ -476,23 +421,12 @@ public static class CharacterStatController
|
|||
() => currentSkillRows, onRaiseRequest, RefreshAfterRaise);
|
||||
RebuildActiveList();
|
||||
|
||||
if (layout.Root is { } tabRoot2 && spriteResolve is not null)
|
||||
{
|
||||
if (layout.FindElement(TabAttribId) is { } origAttr) origAttr.Visible = false;
|
||||
if (layout.FindElement(TabSkillsId) is { } origSkills) origSkills.Visible = false;
|
||||
if (layout.FindElement(TabTitlesId) is { } origTitles) origTitles.Visible = false;
|
||||
|
||||
tabVisuals.Add(AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont,
|
||||
tabX: 0f, label: "Attributes", tab: CharacterStatTab.Attributes,
|
||||
onClick: () => SwitchTab(CharacterStatTab.Attributes)));
|
||||
tabVisuals.Add(AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont,
|
||||
tabX: 92f, label: "Skills", tab: CharacterStatTab.Skills,
|
||||
onClick: () => SwitchTab(CharacterStatTab.Skills)));
|
||||
AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont,
|
||||
tabX: 184f, label: "Titles", tab: CharacterStatTab.Attributes,
|
||||
onClick: static () => { });
|
||||
UpdateTabVisuals(tabVisuals, activeTab[0]);
|
||||
}
|
||||
RetailTabBinding.SetClick(attributesTab, () => SwitchTab(CharacterStatTab.Attributes));
|
||||
RetailTabBinding.SetClick(skillsTab, () => SwitchTab(CharacterStatTab.Skills));
|
||||
// Titles remain the known AP-109 gap. Keep its retail-authored closed visual,
|
||||
// but do not make an inert page look interactive until that controller lands.
|
||||
RetailTabBinding.SetClick(titlesTab, null);
|
||||
UpdateTabStates();
|
||||
|
||||
// ── Active-page selection (fixes the dark-overlay) ─────────────────────
|
||||
// WHY this cannot be done in the importer (dat state-model audit 2026-06-26):
|
||||
|
|
@ -520,10 +454,17 @@ public static class CharacterStatController
|
|||
SetFooterSelected(false);
|
||||
RebuildActiveList();
|
||||
RefreshActiveRaiseButtons();
|
||||
UpdateTabVisuals(tabVisuals, tab);
|
||||
UpdateTabStates();
|
||||
Console.WriteLine($"[CharacterStat] Tab click: {tab}");
|
||||
}
|
||||
|
||||
void UpdateTabStates()
|
||||
{
|
||||
RetailTabBinding.SetOpen(attributesTab, activeTab[0] == CharacterStatTab.Attributes);
|
||||
RetailTabBinding.SetOpen(skillsTab, activeTab[0] == CharacterStatTab.Skills);
|
||||
RetailTabBinding.SetOpen(titlesTab, false);
|
||||
}
|
||||
|
||||
void RebuildActiveList()
|
||||
{
|
||||
if (statList is null) return;
|
||||
|
|
@ -1849,124 +1790,6 @@ public static class CharacterStatController
|
|||
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add the three sprite pieces and the label for ONE tab to <paramref name="root"/>
|
||||
/// as direct root children at absolute positions. Sprites go to root (not to the
|
||||
/// tab group elements) so the tab groups keep Children.Count==0 and survive the
|
||||
/// page-visibility pass unchanged.
|
||||
///
|
||||
/// <para>Sprite ids from the retail UI layout dump (2026-06-25), nodes
|
||||
/// 0x10000439/0x100000E9/0x10000215 in layout 0x2100002E:
|
||||
/// Open (active): 0x06005D92/0x06005D94/0x06005D96;
|
||||
/// Closed (inactive): 0x06005D93/0x06005D95/0x06005D97.</para>
|
||||
///
|
||||
/// <para>Tab geometry confirmed from dump: each tab = 92px wide, 25px tall.
|
||||
/// Left-cap = 17px, center = 58px, right-cap = 17px (total 92px).</para>
|
||||
/// </summary>
|
||||
private static TabVisual AddTabSpritesToRoot(
|
||||
UiElement root,
|
||||
Func<uint, (uint handle, int w, int h)> spriteResolve,
|
||||
UiDatFont? datFont,
|
||||
float tabX,
|
||||
string label,
|
||||
CharacterStatTab tab,
|
||||
Action onClick)
|
||||
{
|
||||
uint leftId = TabClosedLeft;
|
||||
uint centerId = TabClosedCenter;
|
||||
uint rightId = TabClosedRight;
|
||||
|
||||
// Left cap: 17×25 at (tabX, 0)
|
||||
var left = MakeSpriteElement(spriteResolve, x: tabX, y: 0f, w: TabLeftCapW, h: TabH, spriteId: leftId);
|
||||
root.AddChild(left);
|
||||
// Center: 58×25 at (tabX+17, 0) — carries the label text on top
|
||||
var center = MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW, y: 0f, w: TabCenterW, h: TabH, spriteId: centerId);
|
||||
root.AddChild(center);
|
||||
// Right cap: 17×25 at (tabX+75, 0)
|
||||
var right = MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW + TabCenterW, y: 0f, w: TabRightCapW, h: TabH, spriteId: rightId);
|
||||
root.AddChild(right);
|
||||
|
||||
// Label text centered on the tab center piece.
|
||||
// Active (Open) tab: gold; inactive (Closed) tabs: parchment body color.
|
||||
// ZOrder=9 → draws on top of the sprite background (ZOrder=8) and original tab groups (ZOrder=1–3).
|
||||
Vector4 labelColor = Body;
|
||||
string capturedLabel = label;
|
||||
var labelEl = new UiText
|
||||
{
|
||||
Left = tabX + TabLeftCapW,
|
||||
Top = 0f,
|
||||
Width = TabCenterW,
|
||||
Height = TabH,
|
||||
ZOrder = 9,
|
||||
DatFont = datFont,
|
||||
ClickThrough = true,
|
||||
Centered = true,
|
||||
OneLine = true,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||||
};
|
||||
labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) };
|
||||
root.AddChild(labelEl);
|
||||
|
||||
var hit = new UiClickablePanel
|
||||
{
|
||||
Left = tabX,
|
||||
Top = 0f,
|
||||
Width = TabLeftCapW + TabCenterW + TabRightCapW,
|
||||
Height = TabH,
|
||||
ZOrder = 10,
|
||||
BackgroundColor = Vector4.Zero,
|
||||
BorderColor = Vector4.Zero,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||||
OnClick = onClick,
|
||||
};
|
||||
root.AddChild(hit);
|
||||
|
||||
return new TabVisual
|
||||
{
|
||||
Tab = tab,
|
||||
Left = left,
|
||||
Center = center,
|
||||
Right = right,
|
||||
Label = labelEl,
|
||||
Text = label,
|
||||
};
|
||||
}
|
||||
|
||||
private static void UpdateTabVisuals(IReadOnlyList<TabVisual> tabs, CharacterStatTab activeTab)
|
||||
{
|
||||
foreach (var tab in tabs)
|
||||
{
|
||||
bool isOpen = tab.Tab == activeTab;
|
||||
tab.Left.BackgroundSprite = isOpen ? TabOpenLeft : TabClosedLeft;
|
||||
tab.Center.BackgroundSprite = isOpen ? TabOpenCenter : TabClosedCenter;
|
||||
tab.Right.BackgroundSprite = isOpen ? TabOpenRight : TabClosedRight;
|
||||
Vector4 color = isOpen ? Gold : Body;
|
||||
string text = tab.Text;
|
||||
tab.Label.LinesProvider = () => new[] { new UiText.Line(text, color) };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Build a single sprite-drawing <see cref="UiText"/> leaf at the given position.
|
||||
/// ZOrder=8 places it above the original tab-group UiTexts (ZOrder=1–3) so it draws on top.</summary>
|
||||
private static UiText MakeSpriteElement(
|
||||
Func<uint, (uint handle, int w, int h)> spriteResolve,
|
||||
float x, float y, float w, float h, uint spriteId)
|
||||
{
|
||||
return new UiText
|
||||
{
|
||||
Left = x,
|
||||
Top = y,
|
||||
Width = w,
|
||||
Height = h,
|
||||
ZOrder = 8,
|
||||
BackgroundSprite = spriteId,
|
||||
SpriteResolve = id => { var (tex, tw, th) = spriteResolve(id); return (tex, tw, th); },
|
||||
LinesProvider = static () => System.Array.Empty<UiText.Line>(),
|
||||
ClickThrough = true,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Depth-first tree walk to collect every <see cref="UiButton"/> that was registered
|
||||
/// in <paramref name="layout"/> under the given dat element <paramref name="id"/>.
|
||||
|
|
|
|||
39
src/AcDream.App/UI/Layout/RetailTabBinding.cs
Normal file
39
src/AcDream.App/UI/Layout/RetailTabBinding.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Shared controller seam for retail DAT-authored tabs. The LayoutDesc owns the
|
||||
/// tab's font, colors, and child chrome; controllers only bind activation and
|
||||
/// select the retail <c>Open</c>/<c>Closed</c> state.
|
||||
/// </summary>
|
||||
internal static class RetailTabBinding
|
||||
{
|
||||
public static void SetClick(UiElement? element, Action? action)
|
||||
{
|
||||
if (element is null) return;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool SetOpen(UiElement? element, bool open)
|
||||
{
|
||||
if (element is IUiDatStateful stateful
|
||||
&& stateful.TrySetRetailState(open ? RetailUiStateIds.Open : RetailUiStateIds.Closed))
|
||||
return true;
|
||||
|
||||
if (element is UiButton button)
|
||||
{
|
||||
button.Selected = open;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -127,8 +127,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
_rowStyle = rowStyle;
|
||||
_rowFont = rowFont;
|
||||
|
||||
SetClick(spellTab, () => ShowPage(SpellbookWindowPage.Spells));
|
||||
SetClick(componentTab, () => ShowPage(SpellbookWindowPage.Components));
|
||||
RetailTabBinding.SetClick(spellTab, () => ShowPage(SpellbookWindowPage.Spells));
|
||||
RetailTabBinding.SetClick(componentTab, () => ShowPage(SpellbookWindowPage.Components));
|
||||
closeButton.OnClick = close;
|
||||
deleteButton.OnClick = RequestDeleteSelected;
|
||||
foreach ((uint id, uint mask) in FilterButtons)
|
||||
|
|
@ -213,8 +213,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
CurrentPage = page;
|
||||
_spellPage.Visible = page == SpellbookWindowPage.Spells;
|
||||
_componentPage.Visible = page == SpellbookWindowPage.Components;
|
||||
SetTabOpen(_spellTab, page == SpellbookWindowPage.Spells);
|
||||
SetTabOpen(_componentTab, page == SpellbookWindowPage.Components);
|
||||
RetailTabBinding.SetOpen(_spellTab, page == SpellbookWindowPage.Spells);
|
||||
RetailTabBinding.SetOpen(_componentTab, page == SpellbookWindowPage.Components);
|
||||
}
|
||||
|
||||
private void ConfigureSpellList(ImportedLayout layout)
|
||||
|
|
@ -520,30 +520,11 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
|
||||
SetClick(_spellTab, null);
|
||||
SetClick(_componentTab, null);
|
||||
RetailTabBinding.SetClick(_spellTab, null);
|
||||
RetailTabBinding.SetClick(_componentTab, null);
|
||||
_closeButton.OnClick = null;
|
||||
_deleteButton.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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue