feat(D.2b): Character window — value captions + tab bar sprites + footer legibility
Gap 1 — Header captions:
- 0x1000023A ("Character Level") above the level value: bound via LabelLeft().
- 0x10000234 ("Total Experience (XP):") left of total XP: bound via LabelLeft().
- 0x10000237/0x10000238 (XP-to-level label + value) are children of the UiMeter
(0x10000236, ConsumesDatChildren=true) and cannot be bound — documented in
code comments; XP meter fill still bound via Fill=XpFraction.
Gap 2 — Tab bar sprites:
- Tab group elements 0x10000228/229/538 are Type-12 UIElement_Text in the dat
(ConsumesDatChildren=true), so the three button children (left-cap, center,
right-cap) are consumed at import and absent from the widget tree. Old
SetTabState/SetButtonStateRecursive found no UiButton children to set.
- Fix: AddTabSprites() injects three UiText sprite-children per group using
the known RenderSurface ids confirmed from the retail UI layout dump:
Open (active) 0x06005D92/0x06005D94/0x06005D96
Closed (inactive) 0x06005D93/0x06005D95/0x06005D97
Source: dump nodes 0x10000439/0x100000E9/0x10000215 in layout 0x2100002E,
state_id 11=Closed, 12=Open per gmTabUI::SetActive(bool).
Gap 3 — Footer legibility:
- The shared footer child ids (0x1000024E etc.) appear in THREE footer-state
groups (A/B/C). ImportedLayout._byId stores the LAST duplicate = narrower
State B/C copies (145px labels). Fix: hide State B/C groups (footerB/footerC
Visible=false), walk State A container (0x10000240) positionally to bind the
wider State A labels (195px). FooterLine1Label now reads "Skill Credits
Available:" and FooterLine2Label reads "Unassigned Experience:" at full width.
Tests: 3 old tab-state tests (SetButtonStateRecursive expectation) replaced by
4 new sprite-injection tests + 2 caption-binding tests. Full suite: 676 pass,
0 fail (was 673 pass after 3 failures).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
defbde1f86
commit
99291595bb
3 changed files with 297 additions and 85 deletions
|
|
@ -45,23 +45,59 @@ namespace AcDream.App.UI.Layout;
|
|||
public static class CharacterStatController
|
||||
{
|
||||
// ── gmStatManagementUI header element ids (sub-layout 0x2100002C content) ──
|
||||
public const uint NameId = 0x10000231u; // m_pNameText
|
||||
public const uint HeritageId = 0x10000232u; // m_pHeritageText
|
||||
public const uint PkStatusId = 0x10000233u; // m_pPKStatusText
|
||||
public const uint LevelId = 0x1000023Bu; // m_pLevelText (right-side level area)
|
||||
public const uint TotalXpId = 0x10000235u; // m_pTotalXPText
|
||||
public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter)
|
||||
public const uint NameId = 0x10000231u; // m_pNameText
|
||||
public const uint HeritageId = 0x10000232u; // m_pHeritageText
|
||||
public const uint PkStatusId = 0x10000233u; // m_pPKStatusText
|
||||
public const uint LevelCaptionId = 0x1000023Au; // "Character Level" caption ABOVE level value
|
||||
public const uint LevelId = 0x1000023Bu; // m_pLevelText (right-side level area)
|
||||
public const uint TotalXpLabelId = 0x10000234u; // "Total Experience (XP):" caption left of value
|
||||
public const uint TotalXpId = 0x10000235u; // m_pTotalXPText
|
||||
public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter)
|
||||
// NOTE: 0x10000237 (XP-to-level label) and 0x10000238 (XP-to-level value) are children of
|
||||
// the UiMeter element 0x10000236 and are consumed by it (UiMeter.ConsumesDatChildren=true).
|
||||
// FindElement returns NULL for both. They cannot be bound via the element id.
|
||||
// The XP meter itself renders the track sprite; the controller binds Fill=XpFraction.
|
||||
public const uint ListBoxId = 0x1000023Du; // m_pListBox container
|
||||
|
||||
// ── Footer STATE-A container id ──────────────────────────────────────────
|
||||
// 0x10000240 is the "nothing selected" footer group. Its children (0x1000024E label row,
|
||||
// 0x10000242–0x10000245 labels+values) are the correct State-A versions with wider
|
||||
// label widths (195px vs 145px in State B). _byId stores the LAST duplicate, which
|
||||
// is the narrower State-B/C copy — so we walk the tree to 0x10000240 and bind from there.
|
||||
public const uint FooterStateAId = 0x10000240u; // State-A footer container (nothing selected)
|
||||
public const uint FooterStateBId = 0x10000241u; // State-B footer container (row selected)
|
||||
public const uint FooterStateCId = 0x10000247u; // State-C footer container (hide inactive)
|
||||
|
||||
// ── Tab bar element ids (LayoutDesc 0x2100002E root) ────────────────────
|
||||
// Three group containers, each holding three UiButton children forming the 3-piece
|
||||
// tab visual. State "Open" (UIStateId 0x0C) = active; "Closed" (0x0B) = inactive.
|
||||
// Source: gmTabUI::SetActive(bool) — the tab-bar controller calls SetState(Open/Closed)
|
||||
// on the three child buttons of the active vs inactive tab slots.
|
||||
// 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.
|
||||
//
|
||||
// Fix: the controller adds three sprite-drawing children to each UiText manually, 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.
|
||||
public const uint TabAttribId = 0x10000228u; // Attributes tab group
|
||||
public const uint TabSkillsId = 0x10000229u; // Skills tab group
|
||||
public const uint TabTitlesId = 0x10000538u; // Titles tab group
|
||||
|
||||
// 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
|
||||
|
|
@ -141,22 +177,35 @@ public static class CharacterStatController
|
|||
Label(layout, NameId, datFont, Gold, () => data().Name);
|
||||
Label(layout, HeritageId, datFont, Body, () => data().Heritage ?? data().Race ?? string.Empty);
|
||||
Label(layout, PkStatusId, datFont, Body, () => data().PkStatus ?? string.Empty);
|
||||
Label(layout, LevelId, datFont, Gold, () => data().Level.ToString());
|
||||
Label(layout, TotalXpId, datFont, Body, () => data().TotalXp.ToString("N0"));
|
||||
|
||||
// ── Header captions (new — retail labels above/left of each number) ──────
|
||||
// 0x1000023A = "Character Level" caption area above the level value (235,0,65×35).
|
||||
// Source: dump idx=29/30; spec §Header element map.
|
||||
LabelLeft(layout, LevelCaptionId, datFont, Body, static () => "Character Level");
|
||||
|
||||
Label(layout, LevelId, datFont, Gold, () => data().Level.ToString());
|
||||
|
||||
// 0x10000234 = "Total Experience (XP):" caption, left of the XP value.
|
||||
// Source: dump idx=22; spec §Header element map.
|
||||
LabelLeft(layout, TotalXpLabelId, datFont, Body, static () => "Total Experience (XP):");
|
||||
|
||||
Label(layout, TotalXpId, datFont, Body, () => data().TotalXp.ToString("N0"));
|
||||
|
||||
// XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70).
|
||||
// NOTE: child elements 0x10000237 (label) and 0x10000238 (value) are consumed by
|
||||
// the UiMeter and cannot be bound — FindElement returns NULL for both.
|
||||
if (layout.FindElement(XpMeterId) is UiMeter meter)
|
||||
meter.Fill = () => data().XpFraction;
|
||||
|
||||
// ── Tab bar button states ─────────────────────────────────────────────
|
||||
// Each tab group (0x10000228/229/538) is a UiDatElement container whose children
|
||||
// are UiButton widgets that form the 3-piece tab visual. Setting ActiveState on
|
||||
// all children to "Open" or "Closed" switches the visible sprite set.
|
||||
// Source: UIStateId.Open (0x0C) / UIStateId.Closed (0x0B).
|
||||
// Only Attributes is active; Skills + Titles controllers don't exist yet.
|
||||
SetTabState(layout, TabAttribId, StateOpen);
|
||||
SetTabState(layout, TabSkillsId, StateClosed);
|
||||
SetTabState(layout, TabTitlesId, StateClosed);
|
||||
// ── Tab bar sprite children ──────────────────────────────────────────
|
||||
// The tab groups are UiText (Type 12) with ConsumesDatChildren=true, so the
|
||||
// three button children (left-cap/label/right-cap) were consumed at import and
|
||||
// are absent from the widget tree. Add sprite-drawing children manually here
|
||||
// using the known RenderSurface ids from the dump.
|
||||
// Attributes tab = Open (active); Skills + Titles = Closed (inactive).
|
||||
AddTabSprites(layout, TabAttribId, spriteResolve, isOpen: true);
|
||||
AddTabSprites(layout, TabSkillsId, spriteResolve, isOpen: false);
|
||||
AddTabSprites(layout, TabTitlesId, spriteResolve, isOpen: false);
|
||||
|
||||
// ── Attribute list — 9 rows in list box 0x1000023D ────────────────────
|
||||
// Mutable selected-index box: -1 = nothing selected.
|
||||
|
|
@ -191,8 +240,18 @@ public static class CharacterStatController
|
|||
foreach (var b in allRaise1) b.Visible = false;
|
||||
foreach (var b in allRaise10) b.Visible = false;
|
||||
|
||||
// ── Footer State B/C visibility ───────────────────────────────────────
|
||||
// There are THREE footer state groups (A=0x10000240, B=0x10000241, C=0x10000247)
|
||||
// all stacked at the same position. The shared child ids (0x1000024E, 0x10000242,
|
||||
// etc.) appear once in each; _byId stores only the LAST duplicate (State C or B copy)
|
||||
// which uses narrower label widths (145px vs 195px in State A) designed to fit the
|
||||
// raise buttons. Hide B and C initially so only State A is visible.
|
||||
if (layout.FindElement(FooterStateBId) is { } footerB) footerB.Visible = false;
|
||||
if (layout.FindElement(FooterStateCId) is { } footerC) footerC.Visible = false;
|
||||
|
||||
// ── Footer State A initial binding ────────────────────────────────────
|
||||
// All footer providers close over sel[] so they can reflect State A vs B per frame.
|
||||
// Walk to the State-A container directly (rather than _byId which returns the
|
||||
// last duplicate) so we get the wider-label copies (195px) for the unselected state.
|
||||
BindFooterDynamic(layout, datFont, data, sel);
|
||||
|
||||
List<UiClickablePanel>? rowPanels = null;
|
||||
|
|
@ -214,32 +273,6 @@ public static class CharacterStatController
|
|||
}
|
||||
}
|
||||
|
||||
// ── Tab bar state ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Set the <see cref="UiButton.ActiveState"/> on ALL <see cref="UiButton"/> descendants
|
||||
/// of the tab group container identified by <paramref name="groupId"/> to
|
||||
/// <paramref name="state"/>. The three-piece tab visual is composed of three UiButton
|
||||
/// children (left-cap, label/center, right-cap) that must all share the same state.
|
||||
///
|
||||
/// <para>Source: retail tab-button assembly with child buttons having UIStateId states
|
||||
/// "Closed" (0x0B) and "Open" (0x0C) in the dump — confirmed from
|
||||
/// <c>docs/research/2026-06-25-retail-ui-layout-dump.json</c>.</para>
|
||||
/// </summary>
|
||||
private static void SetTabState(ImportedLayout layout, uint groupId, string state)
|
||||
{
|
||||
if (layout.FindElement(groupId) is not { } group) return;
|
||||
SetButtonStateRecursive(group, state);
|
||||
}
|
||||
|
||||
private static void SetButtonStateRecursive(UiElement node, string state)
|
||||
{
|
||||
if (node is UiButton btn)
|
||||
btn.ActiveState = state;
|
||||
foreach (var child in node.Children)
|
||||
SetButtonStateRecursive(child, state);
|
||||
}
|
||||
|
||||
// ── 9-row attribute list ─────────────────────────────────────────────────
|
||||
|
||||
private static List<UiClickablePanel> BuildAttributeRows(
|
||||
|
|
@ -515,6 +548,12 @@ public static class CharacterStatController
|
|||
/// title = "{AttrName}: {value}" (e.g. "Focus: 10"); line-1 label = "Experience To Raise:";
|
||||
/// line-1 value = raise cost; line-2 label = "Unassigned Experience:";
|
||||
/// line-2 value = UnassignedXp.</para>
|
||||
///
|
||||
/// <para>Footer element ids appear in THREE footer-state groups (0x10000240 State A,
|
||||
/// 0x10000241 State B, 0x10000247 State C). <c>_byId</c> stores the LAST duplicate,
|
||||
/// which is the State B/C copy with narrower labels (145px vs 195px in State A). To get
|
||||
/// the correct wide-label State A elements, we find the State A container directly and
|
||||
/// walk to the child elements by id within that subtree.</para>
|
||||
/// </summary>
|
||||
private static void BindFooterDynamic(
|
||||
ImportedLayout layout,
|
||||
|
|
@ -522,8 +561,28 @@ public static class CharacterStatController
|
|||
Func<CharacterSheet> data,
|
||||
int[] sel)
|
||||
{
|
||||
// Title: State A = "Select an Attribute to Improve"; State B = "{name}: {value}"
|
||||
Label(layout, FooterTitleId, datFont, Body, () =>
|
||||
// Walk the State A container (0x10000240) to find the wide-label copies of the
|
||||
// footer child elements. The children of 0x10000240 in positional order are:
|
||||
// [0] title (0x1000024E) at (0,0,300,20)
|
||||
// [1] line-1 label (0x10000242) at (5,20,195,17) ← wide: 195px (State A)
|
||||
// [2] line-1 value (0x10000243) at (200,20,95,17)
|
||||
// [3] line-2 label (0x10000244) at (5,37,195,18)
|
||||
// [4] line-2 value (0x10000245) at (200,37,95,18)
|
||||
// The State B/C copies use narrower labels (145px) to accommodate raise buttons at x=260.
|
||||
// We use positional indexing rather than EventId (which is auto-assigned by UiRoot, not
|
||||
// set from the dat element id, so EventId does NOT equal the dat handle).
|
||||
// If the State A container exists in the tree (production layout), bind from its
|
||||
// positional children so we get the 195px-wide labels rather than the narrower
|
||||
// State B/C copies that _byId would return. Fall back to _byId for fake/test layouts.
|
||||
var stateA = layout.FindElement(FooterStateAId);
|
||||
|
||||
UiText? T(int index, uint fallbackId)
|
||||
=> stateA is not null
|
||||
? GetTextChildAt(stateA, index)
|
||||
: layout.FindElement(fallbackId) as UiText;
|
||||
|
||||
// Title (0x1000024E = child 0): State A = "Select an Attribute to Improve"; State B = "{name}: {value}"
|
||||
LabelProvider(T(0, FooterTitleId), datFont, Body, () =>
|
||||
{
|
||||
if (sel[0] < 0) return "Select an Attribute to Improve";
|
||||
var s = data();
|
||||
|
|
@ -532,24 +591,24 @@ public static class CharacterStatController
|
|||
return $"{name}: {value}";
|
||||
});
|
||||
|
||||
// Line-1 label: State A = "Skill Credits Available:"; State B = "Experience To Raise:"
|
||||
Label(layout, FooterLine1Label, datFont, Body, () =>
|
||||
// Line-1 label (0x10000242 = child 1): State A = "Skill Credits Available:"; State B = "Experience To Raise:"
|
||||
LabelProvider(T(1, FooterLine1Label), datFont, Body, () =>
|
||||
sel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:");
|
||||
|
||||
// Line-1 value: State A = SkillCredits; State B = raise cost
|
||||
Label(layout, FooterLine1Value, datFont, Body, () =>
|
||||
// Line-1 value (0x10000243 = child 2): State A = SkillCredits; State B = raise cost
|
||||
LabelProvider(T(2, FooterLine1Value), datFont, Body, () =>
|
||||
{
|
||||
if (sel[0] < 0) return data().SkillCredits.ToString();
|
||||
long cost = GetRaiseCost(data(), sel[0]);
|
||||
return cost > 0 ? cost.ToString("N0") : "—";
|
||||
});
|
||||
|
||||
// Line-2 label: "Unassigned Experience:" in both states
|
||||
Label(layout, FooterLine2Label, datFont, Body,
|
||||
// Line-2 label (0x10000244 = child 3): "Unassigned Experience:" in both states
|
||||
LabelProvider(T(3, FooterLine2Label), datFont, Body,
|
||||
static () => "Unassigned Experience:");
|
||||
|
||||
// Line-2 value: UnassignedXp in both states
|
||||
Label(layout, FooterLine2Value, datFont, Body,
|
||||
// Line-2 value (0x10000245 = child 4): UnassignedXp in both states
|
||||
LabelProvider(T(4, FooterLine2Value), datFont, Body,
|
||||
() => data().UnassignedXp.ToString("N0"));
|
||||
}
|
||||
|
||||
|
|
@ -574,6 +633,96 @@ public static class CharacterStatController
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Left-justified label (for captions that should be left-aligned, not centered).</summary>
|
||||
private static void LabelLeft(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func<string> text)
|
||||
{
|
||||
if (layout.FindElement(id) is UiText t)
|
||||
{
|
||||
t.DatFont = datFont;
|
||||
t.Centered = false;
|
||||
t.RightAligned = false;
|
||||
t.ClickThrough = true;
|
||||
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bind a directly-located <see cref="UiText"/> widget with a provider.
|
||||
/// Used when the widget was found by subtree walk rather than <c>FindElement</c>.</summary>
|
||||
private static void LabelProvider(UiText? t, UiDatFont? datFont, Vector4 color, Func<string> text)
|
||||
{
|
||||
if (t is null) return;
|
||||
t.DatFont = datFont;
|
||||
t.Centered = false;
|
||||
t.RightAligned = false;
|
||||
t.ClickThrough = true;
|
||||
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the <see cref="UiText"/> child of <paramref name="parent"/> at positional
|
||||
/// index <paramref name="index"/> (zero-based). Used to locate footer children within the
|
||||
/// State-A container by position rather than by dat id (which is not stored in EventId
|
||||
/// by the production importer — EventId is auto-assigned by UiRoot.AddPanel).
|
||||
/// Returns null if the index is out of range or the child is not UiText.
|
||||
/// </summary>
|
||||
private static UiText? GetTextChildAt(UiElement? parent, int index)
|
||||
{
|
||||
if (parent is null || index < 0 || index >= parent.Children.Count) return null;
|
||||
return parent.Children[index] as UiText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add the three sprite-drawing children to a tab group <see cref="UiText"/> element
|
||||
/// to render the 3-piece tab button (left-cap / center / right-cap). The tab group
|
||||
/// elements are type-12 UIElement_Text in the dat, so their button children were consumed
|
||||
/// at import (ConsumesDatChildren=true). This method injects the visual equivalent.
|
||||
///
|
||||
/// <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>
|
||||
/// </summary>
|
||||
private static void AddTabSprites(
|
||||
ImportedLayout layout,
|
||||
uint groupId,
|
||||
Func<uint, (uint handle, int w, int h)>? spriteResolve,
|
||||
bool isOpen)
|
||||
{
|
||||
if (layout.FindElement(groupId) is not { } group) return;
|
||||
if (spriteResolve is null) return;
|
||||
|
||||
uint leftId = isOpen ? TabOpenLeft : TabClosedLeft;
|
||||
uint centerId = isOpen ? TabOpenCenter : TabClosedCenter;
|
||||
uint rightId = isOpen ? TabOpenRight : TabClosedRight;
|
||||
|
||||
// Left cap: 17×25 at x=0
|
||||
AddSpriteChild(group, spriteResolve, x: 0f, y: 0f, w: TabLeftCapW, h: TabH, spriteId: leftId);
|
||||
// Center: 58×25 at x=17
|
||||
AddSpriteChild(group, spriteResolve, x: TabLeftCapW, y: 0f, w: TabCenterW, h: TabH, spriteId: centerId);
|
||||
// Right cap: 17×25 at x=75
|
||||
AddSpriteChild(group, spriteResolve, x: TabLeftCapW + TabCenterW, y: 0f, w: TabRightCapW, h: TabH, spriteId: rightId);
|
||||
}
|
||||
|
||||
/// <summary>Add a single sprite-drawing <see cref="UiText"/> child to <paramref name="parent"/>.</summary>
|
||||
private static void AddSpriteChild(
|
||||
UiElement parent,
|
||||
Func<uint, (uint handle, int w, int h)> spriteResolve,
|
||||
float x, float y, float w, float h, uint spriteId)
|
||||
{
|
||||
parent.AddChild(new UiText
|
||||
{
|
||||
Left = x,
|
||||
Top = y,
|
||||
Width = w,
|
||||
Height = h,
|
||||
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"/>.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue