fix(D.2b): Character window — tab bar sprites on root + footer State-A all 3 lines
BUG 1 (tab bar): Tab group elements (0x10000228/229/538) are UiText with
ConsumesDatChildren=true so their 3 button children are consumed at import.
Fix: inject 3 sprite UiTexts per tab as CHILDREN OF LAYOUT ROOT at absolute
tab rects, ZOrder=8/9 so they draw over dat-imported UiTexts (ZOrder=1-3).
Original tab groups hidden. Active tab (Attributes) gold; inactive parchment.
BUG 2 (footer): Three root causes, all fixed.
(a) _byId stores LAST registered copy per id: stateA (0x10000240) was
the Titles-page copy, hidden by the page-visibility pass. Fixed by
walking root.Children to find the Attributes page (contains NameId)
then FindInSubtree for stateA within that subtree.
(b) Attributes-page stateB/stateC siblings (stacked at y=545) were still
Visible=True, drawing over stateA line-1/line-2. Fixed with
HideAllById walking the Attributes page subtree for ids 241/247.
(c) Footer label elements (H=17-18px, Padding=4f) were routed through
UiTexts scroll path: bottom-pinned baseY ended above the top clip
boundary, silently blanking all text. Fixed: LabelProvider sets
Padding=0f for directly-bound footer single-line labels.
UiDatElement.ElementId exposes _info.Id for subtree id-based walks.
676 tests pass, vitals panel unaffected (regression screenshot clean).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
99291595bb
commit
5aa65dbd43
3 changed files with 236 additions and 111 deletions
|
|
@ -197,15 +197,36 @@ public static class CharacterStatController
|
|||
if (layout.FindElement(XpMeterId) is UiMeter meter)
|
||||
meter.Fill = () => data().XpFraction;
|
||||
|
||||
// ── 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);
|
||||
// ── 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).
|
||||
if (layout.Root is { } tabRoot && spriteResolve is not null)
|
||||
{
|
||||
// Hide the original tab group elements (0x10000228/229/538): they are UiText
|
||||
// nodes that render their own state sprite at ZOrder=1/2/3. Our replacement
|
||||
// sprites go to root at ZOrder=8 (above them), so the originals would paint
|
||||
// underneath. Hiding them avoids the double-draw and any opaque overlap.
|
||||
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;
|
||||
|
||||
AddTabSpritesToRoot(tabRoot, spriteResolve, datFont,
|
||||
tabX: 0f, label: "Attributes", isOpen: true);
|
||||
AddTabSpritesToRoot(tabRoot, spriteResolve, datFont,
|
||||
tabX: 92f, label: "Skills", isOpen: false);
|
||||
AddTabSpritesToRoot(tabRoot, spriteResolve, datFont,
|
||||
tabX: 184f, label: "Titles", isOpen: false);
|
||||
}
|
||||
|
||||
// ── Attribute list — 9 rows in list box 0x1000023D ────────────────────
|
||||
// Mutable selected-index box: -1 = nothing selected.
|
||||
|
|
@ -242,10 +263,28 @@ public static class CharacterStatController
|
|||
|
||||
// ── 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.
|
||||
// all stacked at the same position within the Attributes page. _byId stores only
|
||||
// the LAST copy of each id; the others live in the VISIBLE Attributes page and must
|
||||
// also be hidden.
|
||||
//
|
||||
// Strategy: walk the Attributes page (the root child that contains the NameId anchor)
|
||||
// and hide every UiDatElement whose ElementId matches B or C. The page-visibility
|
||||
// pass hides everything in the Skills/Titles pages, so we only need to act on the
|
||||
// Attributes page's copies.
|
||||
if (layout.FindElement(NameId) is { } bAnchor && layout.Root is { } fbRoot)
|
||||
{
|
||||
foreach (var fbPage in fbRoot.Children)
|
||||
{
|
||||
if (fbPage.Children.Count == 0) continue;
|
||||
if (!ContainsWidget(fbPage, bAnchor)) continue;
|
||||
// This is the Attributes page. Hide all stateB and stateC copies in it.
|
||||
HideAllById(fbPage, FooterStateBId);
|
||||
HideAllById(fbPage, FooterStateCId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Also hide the last-registered copies (in the _byId dict, which may be from a
|
||||
// different page). Belt-and-suspenders.
|
||||
if (layout.FindElement(FooterStateBId) is { } footerB) footerB.Visible = false;
|
||||
if (layout.FindElement(FooterStateCId) is { } footerC) footerC.Visible = false;
|
||||
|
||||
|
|
@ -562,27 +601,62 @@ public static class CharacterStatController
|
|||
int[] sel)
|
||||
{
|
||||
// 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);
|
||||
// footer child elements. The 5 children of 0x10000240 at their LOCAL coords:
|
||||
// title (0x1000024E): Left=0, Top=0, W=300, H=55
|
||||
// line-1 label (0x10000242): Left=5, Top=20, W=195 (State A wide) vs 145 (B/C narrow)
|
||||
// line-1 value (0x10000243): Left=200, Top=20, W=95
|
||||
// line-2 label (0x10000244): Left=5, Top=37, W=195 (State A wide) vs 145 (B/C narrow)
|
||||
// line-2 value (0x10000245): Left=200, Top=37, W=95
|
||||
// The State B/C copies use narrower labels (145px) to accommodate raise buttons.
|
||||
//
|
||||
// IMPORTANT: The footer state id (0x10000240) appears once per tab-page sub-layout
|
||||
// (Attributes / Skills / Titles). layout._byId stores only the LAST registered copy,
|
||||
// which ends up in the LAST-imported tab page (Titles). The page-visibility pass
|
||||
// hides the Titles page → the bound footer elements would be invisible.
|
||||
//
|
||||
// Fix: find stateA in the SAME subtree as the anchor element (NameId=0x10000231).
|
||||
// The anchor is in the Attributes content area (the page the pass keeps Visible=True).
|
||||
// We walk every root-level content area to find the one that contains the anchor,
|
||||
// then walk that subtree for the footer container.
|
||||
UiElement? stateA = null;
|
||||
if (layout.FindElement(NameId) is { } anchor && layout.Root is { } visRoot)
|
||||
{
|
||||
// Find the root-level content page that contains the anchor.
|
||||
foreach (var page in visRoot.Children)
|
||||
{
|
||||
if (page.Children.Count == 0) continue;
|
||||
if (!ContainsWidget(page, anchor)) continue;
|
||||
// This page will be Visible=True. Walk it for FooterStateAId.
|
||||
stateA = FindInSubtree(page, static el =>
|
||||
el is UiDatElement d && d.ElementId == FooterStateAId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fallback: layout._byId (test layouts with a single page).
|
||||
stateA ??= layout.FindElement(FooterStateAId);
|
||||
|
||||
UiText? T(int index, uint fallbackId)
|
||||
=> stateA is not null
|
||||
? GetTextChildAt(stateA, index)
|
||||
: layout.FindElement(fallbackId) as UiText;
|
||||
// Position-based lookup within stateA's children.
|
||||
// If stateA is null or the element isn't found, falls back to layout._byId.
|
||||
UiText? ByPos(float top, float left, uint fallbackId)
|
||||
{
|
||||
if (stateA is not null)
|
||||
{
|
||||
foreach (var c in stateA.Children)
|
||||
if (c is UiText t
|
||||
&& Math.Abs(c.Top - top) < 1f
|
||||
&& Math.Abs(c.Left - left) < 1f)
|
||||
return t;
|
||||
}
|
||||
return 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, () =>
|
||||
// Title (Top=0, Left=0): State A = "Select an Attribute to Improve"; State B = "{name}: {value}"
|
||||
// Clear BackgroundSprite on the title element: it is H=55 (full footer height) in the dat
|
||||
// and its background sprite would cover the line-1/line-2 elements at local y=20/37.
|
||||
// The controller owns the footer visual; the dat sprite is superfluous here.
|
||||
var titleEl = ByPos(0f, 0f, FooterTitleId);
|
||||
if (titleEl is not null) titleEl.BackgroundSprite = 0;
|
||||
LabelProvider(titleEl, datFont, Body, () =>
|
||||
{
|
||||
if (sel[0] < 0) return "Select an Attribute to Improve";
|
||||
var s = data();
|
||||
|
|
@ -591,29 +665,58 @@ public static class CharacterStatController
|
|||
return $"{name}: {value}";
|
||||
});
|
||||
|
||||
// Line-1 label (0x10000242 = child 1): State A = "Skill Credits Available:"; State B = "Experience To Raise:"
|
||||
LabelProvider(T(1, FooterLine1Label), datFont, Body, () =>
|
||||
// Line-1 label (Top=20, Left=5): State A = "Skill Credits Available:"; State B = "Experience To Raise:"
|
||||
var l1L = ByPos(20f, 5f, FooterLine1Label);
|
||||
LabelProvider(l1L, datFont, Body, () =>
|
||||
sel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:");
|
||||
|
||||
// Line-1 value (0x10000243 = child 2): State A = SkillCredits; State B = raise cost
|
||||
LabelProvider(T(2, FooterLine1Value), datFont, Body, () =>
|
||||
// Line-1 value (Top=20, Left=200): State A = SkillCredits; State B = raise cost
|
||||
var l1V = ByPos(20f, 200f, FooterLine1Value);
|
||||
LabelProvider(l1V, 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 (0x10000244 = child 3): "Unassigned Experience:" in both states
|
||||
LabelProvider(T(3, FooterLine2Label), datFont, Body,
|
||||
// Line-2 label (Top=37, Left=5): "Unassigned Experience:" in both states
|
||||
var l2L = ByPos(37f, 5f, FooterLine2Label);
|
||||
LabelProvider(l2L, datFont, Body,
|
||||
static () => "Unassigned Experience:");
|
||||
|
||||
// Line-2 value (0x10000245 = child 4): UnassignedXp in both states
|
||||
LabelProvider(T(4, FooterLine2Value), datFont, Body,
|
||||
// Line-2 value (Top=37, Left=200): UnassignedXp in both states
|
||||
var l2V = ByPos(37f, 200f, FooterLine2Value);
|
||||
LabelProvider(l2V, datFont, Body,
|
||||
() => data().UnassignedXp.ToString("N0"));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Depth-first walk of <paramref name="node"/> and its descendants.
|
||||
/// Sets <see cref="UiElement.Visible"/> = false on every <see cref="UiDatElement"/>
|
||||
/// whose <see cref="UiDatElement.ElementId"/> equals <paramref name="targetId"/>.</summary>
|
||||
private static void HideAllById(UiElement node, uint targetId)
|
||||
{
|
||||
if (node is UiDatElement d && d.ElementId == targetId)
|
||||
node.Visible = false;
|
||||
foreach (var child in node.Children)
|
||||
HideAllById(child, targetId);
|
||||
}
|
||||
|
||||
/// <summary>Depth-first search of <paramref name="node"/> and its descendants.
|
||||
/// Returns the first element for which <paramref name="predicate"/> returns true,
|
||||
/// or null if none found.</summary>
|
||||
private static UiElement? FindInSubtree(UiElement node, Func<UiElement, bool> predicate)
|
||||
{
|
||||
if (predicate(node)) return node;
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
var found = FindInSubtree(child, predicate);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool ContainsWidget(UiElement node, UiElement target)
|
||||
{
|
||||
if (ReferenceEquals(node, target)) return true;
|
||||
|
|
@ -647,7 +750,10 @@ public static class CharacterStatController
|
|||
}
|
||||
|
||||
/// <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>
|
||||
/// Used when the widget was found by subtree walk rather than <c>FindElement</c>.
|
||||
/// Sets <c>Padding = 0</c> to prevent the scroll-clip from hiding text in small
|
||||
/// (H≈17–18px) footer elements: with the default Padding=4 and a dat font line-height
|
||||
/// of ~12px the bottom-pinned baseY ends up above the top clip boundary → blank.</summary>
|
||||
private static void LabelProvider(UiText? t, UiDatFont? datFont, Vector4 color, Func<string> text)
|
||||
{
|
||||
if (t is null) return;
|
||||
|
|
@ -655,72 +761,84 @@ public static class CharacterStatController
|
|||
t.Centered = false;
|
||||
t.RightAligned = false;
|
||||
t.ClickThrough = true;
|
||||
t.Padding = 0f;
|
||||
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.
|
||||
/// 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 void AddTabSprites(
|
||||
ImportedLayout layout,
|
||||
uint groupId,
|
||||
Func<uint, (uint handle, int w, int h)>? spriteResolve,
|
||||
private static void AddTabSpritesToRoot(
|
||||
UiElement root,
|
||||
Func<uint, (uint handle, int w, int h)> spriteResolve,
|
||||
UiDatFont? datFont,
|
||||
float tabX,
|
||||
string label,
|
||||
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);
|
||||
// Left cap: 17×25 at (tabX, 0)
|
||||
root.AddChild(MakeSpriteElement(spriteResolve, x: tabX, y: 0f, w: TabLeftCapW, h: TabH, spriteId: leftId));
|
||||
// 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)
|
||||
root.AddChild(MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW + TabCenterW, y: 0f, w: TabRightCapW, h: TabH, spriteId: rightId));
|
||||
|
||||
// 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 = isOpen ? Gold : 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,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||||
};
|
||||
labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) };
|
||||
root.AddChild(labelEl);
|
||||
}
|
||||
|
||||
/// <summary>Add a single sprite-drawing <see cref="UiText"/> child to <paramref name="parent"/>.</summary>
|
||||
private static void AddSpriteChild(
|
||||
UiElement parent,
|
||||
/// <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)
|
||||
{
|
||||
parent.AddChild(new UiText
|
||||
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>
|
||||
|
|
@ -786,4 +904,5 @@ public static class CharacterStatController
|
|||
foreach (var child in node.Children)
|
||||
CollectMatchingButtons(child, canonical, seen, result);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue