Retail CACQualities::EnchantAttribute (0x00594570), EnchantAttribute2nd
(0x00594670, already ported for #6), and EnchantSkill (0x005947b0) are the
three enchantment-composition functions the Character window's Attributes
and Skills tabs depend on. Primary attributes never reference the vitae
singleton in retail (only Attribute2nd/Skill do) — confirmed directly from
the decompiled function bodies, not assumed.
EnchantmentMath.GetMod gains requiredType/includeVitae parameters (default
to the prior behavior) so a numeric StatMod key collision across domains
(e.g. key=1 is both Strength and MaxHealth) can't leak a buff into the
wrong computation. Spellbook.GetAttributeMod/GetSkillMod and
LocalPlayerState.GetEffectiveAttribute/GetEffectiveSkill/
GetSkillVitaeModifier wire the retail chain through to the panel.
CharacterSheetProvider now reports the effective value as the main number
and CharacterSkill.CurrentLevel is no longer an alias of BaseLevel (this
also activates the previously-dead SkillValueColor buffed/debuffed row
coloring). CharacterStatController's footer-title parenthetical is cited
from gmAttributeUI::DisplaySelectionFooter_Attribute (0x0049d280) and
gmSkillUI::DisplaySelectionFooter_Trained (0x0049b860) +
SkillInfoRegion::GetVitaeModifier (0x004f0fa0): skills show up to two
segments (vitae's own contribution, then the buff-only residual), while
vitae-immune attributes show at most one; no parenthetical when the delta
is zero. The panel now refreshes on Spellbook.EnchantmentsChanged, not only
raw property/attribute updates.
Core goldens cover the user-reported 33% vitae example (303->203, "(-100)"
exactly), buff+vitae composition, and the attribute vitae-immunity finding.
Provider/controller tests cover the full row-click -> footer-title path and
live refresh. Full solution suite passes with zero failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Parse retail PrivateUpdatePropertyInt64 and route authoritative Total/Available Experience through both local-player projections so Attributes, the level meter, and Skills refresh together. Preserve the existing retail XP curve and right-align the Total XP value.
Close the user-confirmed item-give gate for #216 and record the named-retail/ACE/holtburger conformance evidence.
Co-Authored-By: Codex <noreply@openai.com>
Route untrained-skill promotion through DialogFactory with retail's exact prompt and stored skill/credit properties. Defer the character-sheet refresh until accept or reject completes while leaving trained-skill XP raises immediate.
Co-Authored-By: Codex <codex@openai.com>
Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02
UI architecture review mandated before commit:
- ItemInteractionController: single owner of double-click use/equip/
container-open, targeted-use mode (health kits), drag-out drop;
toolbar shortcut drags don't drop the real item. ItemEquipRules for
multi-slot (coat) coverage via equip masks.
- Cursor phase: CursorFeedbackController (semantic priority chain:
drag > resize > window-move > target-mode > text) + RetailCursorCatalog
(enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState
0x00564630) resolved through the portal EnumIDMap chain by
RetailCursorResolver; RetailCursorManager applies dat cursor art to the
OS cursor. Register row AP-72 covers the OS standard-cursor fallback.
- Character window goes live: CharacterSheetProvider owns sheet assembly,
XP-curve/raise-cost math and the raise flow — extracted out of
GameWindow per Code Structure Rule 1 instead of committing the ~430-line
feature body there. Optimistic XP/credit debits go through eventful
store APIs (new ClientObjectTable.UpdateInt64Property +
LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw
property-dictionary writes; register row AP-73 covers the still-missing
raise ledger (#163).
- RetailWindowFrame: the shared nine-slice window mount recipe; the
character window uses it, remaining windows migrate via #164.
- Status-bar buttons toggle inventory/character windows; retail row-major
backpack ordering; WorldSession.SendUseWithTarget + raise/train sends.
GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the
sheet/raise logic is unit-tested in CharacterSheetProviderTests instead
of trapped in the god object. Build green; full suite 3,286 tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Item 1 — XP-next label left-alignment: the "XP for next level:" label (child of the
XP meter) was not left-aligned with the "Total Experience (XP):" caption above it.
Fixed by computing the meter's x-offset at bind time and setting xpLabel.Left =
TotalXpLabel.Left - meter.Left, plus Centered=false/RightAligned=false.
Item 2 — icon size / row height: attribute-row icons reduced from 24px → 16px,
row height from 30px → 22px. The 9 rows are now compact and tightly packed matching
the retail reference (2026-06-26). Row font (18px dat) still fits the 22px row.
Item 3 — selection bar: UiClickablePanel gains UseSelectionBars (default false) and
SelectionBarHeight (default 3px). When UseSelectionBars=true and BackgroundSprite is
set, OnDraw draws the sprite as a thin horizontal bar at the TOP edge (y=0) and BOTTOM
edge (y=H-barH) of the row — full panel width, no left/right end-caps, UV-tiled
horizontally (u1=Width/nativeW). Falls back to the base UiPanel fill (BackgroundColor
or full-stretch sprite) when UseSelectionBars=false. AddRow sets UseSelectionBars=true
on all attribute/vital rows so the selection highlight shows as retail-style bars.
Sprite 0x06001397 is 300×32 px; at 3px bar height the UV crop shows the sprite's
top 3px (top bar) and bottom 3px (bottom bar). Temp pre-select for screenshot
verification was added then removed before this commit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
LayoutImporter.BuildWidget gains a UiMeter-specific branch after the
ConsumesDatChildren gate: Type-3 slice containers (already consumed by
DatWidgetFactory.BuildMeter to extract sprite ids) are skipped, but all
other children (Type-12 UIElement_Text overlays) are built normally,
registered in byId, and attached as UiElement children of the meter.
This fixes the XP meter (0x10000236): its two Type-12 children
0x10000237 "XP for next level:" label
0x10000238 XP-to-next-level value
are now real UiText widgets in the tree, findable via FindElement.
CharacterStatController.Bind now uses FindElement + LinesProvider binding
instead of injecting runtime AddChild nodes. The two previously-injected
UiText overlays are removed; the controller binds Padding=0, ClickThrough,
RightAligned on the dat-origin widgets instead.
New constants XpNextLabelId + XpNextValueId replace the old comment block.
TAB GROUPS (SKIPPED — documented): UiText.ConsumesDatChildren flipping is
NOT safe globally (risks chat/vitals) and the page-visibility pass in
CharacterStatController depends on tab group Children.Count==0. Tab sprite
injection onto layout.Root stays and is explicitly commented as deliberate.
VITALS SAFE: health/stamina/mana meters have only Type-3 children → new
loop finds nothing → zero UiElement children added → byte-identical render.
VitalsController.BindMeter AddChild(number) pattern unchanged.
Tests: 3 new LayoutImporterTests (slice-only, Fix-5 text children,
vitals regression guard) + 3 CharacterStatControllerTests (label bound,
value bound, missing children no-throw). 715 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Investigation (2026-06-26): the character footer three sibling groups (0x10000240/241/247)
all carry DefaultState=0x10000011 (StatManagement_Footer_Default) — the dat does NOT
differentiate them by visibility. Parent 0x1000022F has States {Default, Text, Meter}
with PassToChildren=true, but each child group registers all three states with
IncorporationFlags=None and Media=0, so state-propagation produces no visual change
on any group. Retail gmStatManagementUI uses hardcoded element-id dispatch for
0x10000240/241/247 — the groups are never hidden/shown via the dat state mechanism
(decomp gmStatManagementUI::GetFooterTitleLabel @0x004f0170).
Tab-page containers (0x1000022B/22C/10000539) have DefaultState=Undef — no visibility
encoding. Tab visibility is managed purely at runtime by gmTabUI.
IncorporationFlags (DatReaderWriter): {None, PassToChildren, X, Y, Width, Height,
ZLevel} — no Visible flag. The importer cannot set initial Group visibility from
the dat; the controller is the correct and only place. CharacterStatController
HideAllById footer pass and ContainsWidget page-visibility walk are retail-faithful
and must be kept.
No logic changes — comment-only documentation. Build green, 710 App tests green.
Studio screenshots: character footer shows State-A by default with no overlap;
vitals and toolbar unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DatWidgetFactory.Create now accepts an optional fontResolve: Func<uint,UiDatFont?>
parameter. When supplied and the element has a non-zero FontDid, the element
receives its own dat font instead of the shared global datFont fallback.
Null = original single-font behavior (the live GameWindow path passes null —
provably unchanged). LayoutImporter.Build/BuildFromInfos/Import all thread
the optional resolver down to the factory.
RenderStack gains a lazy font cache (ConcurrentDictionary, pre-seeded with
VitalsDatFont + LargeDatFont) and a ResolveDatFont(uint) method. StudioWindow
wires stack.ResolveDatFont into LayoutSource so every studio import gets
per-element fonts. GameWindow import calls left passing null (follow-up todo).
CharacterStatController font-hack cleanup (diagnosed via one-shot console dump
then removed):
- Name (0x10000231): dat FontDid = 18px font — remove datFont override (null)
- Heritage/PkStatus: dat FontDid = 14px fonts — remove override
- LevelCaption: dat FontDid = 16px — remove override (same font, no visual change)
- Level (0x1000023B): dat FontDid = 36px (the big retail gold font) — was forced
to rowDatFont/LargeDatFont (18px); now drops to null so the dat 36px font drives
- TotalXpLabel/TotalXp: dat FontDid = 16px — remove override
- FooterTitle (0x1000024E): dat FontDid = 20px — remove datFont override
- KEEP: synthesized elements (XP meter overlays, 9 attribute rows, tab sprites)
still use datFont directly since they have no dat origin
All Label/LabelTwoLine/LabelLeft/LabelProvider helpers updated: null = keep
build-time dat font; non-null = controller explicit override (backward-compat).
8 new tests in DatWidgetFactoryFontResolveTests:
- null resolver → DatFont == global datFont
- FontDid=0 → resolver not called
- resolver returns null → fallback to global datFont
- resolver called with element's FontDid
- controller DatFont override wins after build
- LayoutImporter.Build threads fontResolve to factory
- meter element fires resolver for non-zero FontDid
- BuildFromInfos without fontResolve param = original behavior
Build + all 710 App tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LayoutImporter.ReadState now reads Properties 0x1B (ColorBaseProperty, ARGB bytes) and stores the normalized Vector4 in ElementInfo.FontColor (nullable). ElementReader.Merge propagates it with the same non-null-derived-wins rule as FontDid and HJustify. DatWidgetFactory.BuildText seeds UiText.DefaultColor from FontColor when present.
Diagnosis for LayoutDesc 0x2100002E: ALL 12 header and footer text elements carry NO dat color. Every color is runtime set by CharacterStatController. Comments added at each callsite. No hardcoded colors deleted.
Tests added: 3 ElementReader FontColor Merge + 3 DatWidgetFactory DefaultColor. 702 passed, 0 failed. Screenshots: character window, vitals, toolbar all unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
LayoutImporter.ReadState now reads Properties[0x14] (HorizontalJustification,
EnumBaseProperty: 0=Left, 1=Center, 3/5=Right) and Properties[0x15]
(VerticalJustification: 2=Top, 4=Bottom; else Center) into two new ElementInfo
fields HJustify/VJustify. Merge propagates them with the same non-default-wins
rule used for FontDid.
DatWidgetFactory.BuildText applies the resolved justify at build time:
HJustify=Center sets Centered=true, HJustify=Right sets RightAligned=true,
VJustify=Top/Bottom sets VerticalJustify. Controllers that FindElement and set
those properties afterward continue to override - backward-compat preserved.
UiText gains VerticalJustify (Top/Center/Bottom, default Center). The Centered
and RightAligned single-line paths call UiText.VOffset() for the Y coordinate,
so VJustify.Top renders text at y=Padding rather than the fixed (H-lh)/2 center.
CharacterStatController: footer title (0x1000024E, H=55 dat box) previously
used Height=18 + Anchors=None to prevent center-vertical overlap with line-1/2.
Diagnostic confirmed dat says HJustify=Center, VJustify=Center. The hack is
replaced with one minimal explicit override: VerticalJustify=Top on the
already-Centered element. Text now renders at top of the 55px box natively.
Centered=false and RightAligned=false hand-sets removed where dat supplies them.
Dat justify values (studio diagnostic, 2026-06-26):
0x1000024E footer title: HJustify=Center, VJustify=Center
0x10000235/0x10000243/0x10000245 value fields: HJustify=Right
header name/heritage/pk/level: HJustify=Center
+13 tests: ElementReader Merge propagation; DatWidgetFactory BuildText
justify application + controller-override backward-compat; UiText VOffset.
696 passed, 0 failed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Retail's m_pLevelText (0x1000023B) element is a plain UIElement_Text, not a
sprite-digit widget — the "large gold" appearance comes from the dat font
size on that specific element. Since 0x40000001 (18px) is the largest font
confirmed in client_portal.dat and there is no per-element font override in
our LayoutImporter, use rowDatFont (18px) for the level value so it fills
more of the 65×50 element and approaches the retail appearance.
Investigation: namedretail grep gmStatManagementUI + UpdateCharacterInfo
(0x004f0770) confirmed Type-12 UIElement_Text + SetText(L"%d"). No NumberSprite
or DigitSprite widget type exists in retail — the diff is purely font size.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the translucent-gold BackgroundColor tint with sprite 0x06001397
(Button state 6 — the retail dark horizontal bars) for the selected-row
background. When spriteResolve is provided to Bind(), clicking a row now
applies BackgroundSprite=0x06001397 + SpriteResolve on that row and clears
it on all others. Falls back to HighlightBg tint when no resolver is passed
(tests, or contexts without GL).
UiPanel.BackgroundSprite / SpriteResolve added so any panel can host a
sprite background in place of the solid BackgroundColor rect. HandleRowClick
updated to accept spriteResolve and apply the sprite or tint branch
depending on availability. Two new tests verify the sprite / deselect paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- RowHeight reduced 44→30px to pack the 9 rows tighter, matching retail's denser list
- Attribute row name/value text now uses Font 0x40000001 (MaxCharHeight=18px) instead of
the default 0x40000000 (16px); both fonts are in client_portal.dat (confirmed 2026-06-26)
- RenderStack gains LargeDatFont field; RenderBootstrap.Create loads both fonts
- FixtureProvider passes LargeDatFont as rowDatFont to CharacterStatController.Bind
- CharacterStatController.Bind gains rowDatFont? parameter (falls back to datFont);
passed to BuildAttributeRows so row UiText elements use the larger font
- Full solution tests green (681/683 App + 1579/1581 Core + 343 Net + 425 UI)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Name label color changed from gold to white (retail "Horan" is white; 2026-06-26 ref)
- Level caption now provides 2 lines ("Character" / "Level") so neither truncates in the 65px element
- "Total Experience (XP):" caption (TotalXpLabelId 0x10000234) now visible; fixed Padding=0 on
LabelLeft so the dat-font line is not clipped by the bottom-pin scroll math in small-height elements
- "XP for next level:" caption + value injected as UiText children ON the UiMeter (0x10000236);
the meter's ConsumesDatChildren=true only gates the importer — AddChild at runtime works fine;
framework draws children after OnDraw so the text overlays the red bar faithfully
- Selected footer title (State B) renders WHITE per retail spec; State A title stays body/parchment
- Maxed attribute (raise cost = 0) shows "Infinity!" in Experience To Raise line per retail spec
- 5 new tests; 1 existing LevelCaption test updated to expect 2 lines; 681/683 pass (full suite green)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A position dump (the apparatus, after 5 speculative passes) showed the footer title element is H=55
(the whole footer box) in the dat; centering "Select an Attribute to Improve" in it landed the text
dead-center (~y572), painting over "Skill Credits Available:" (y565) and "Unassigned Experience:"
(y582). Constrain the title to a thin line (H=18) at the footer top + drop its anchors so the
per-frame stretch doesn't re-grow it. All three State-A lines now read cleanly. Diagnosed from data,
not guessing — exactly the apparatus-over-cdb call.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
- UiClickablePanel: new UiPanel subclass with OnClick action + HandlesClick=true
so row clicks survive whole-window-Draggable ancestor frames.
- CharacterStatController overhaul (Pass 2):
- Tab bar button states: SetButtonStateRecursive walks each tab group container
(0x10000228/229/538) setting UiButton.ActiveState="Open" (Attributes) or
"Closed" (Skills/Titles) via UIStateId enum string names from DatReaderWriter.
- Row click: 9 rows become UiClickablePanel; sel[] mutable box drives footer/highlight.
- Toggle: clicking the same row deselects (→ footer State A); click a new row
updates title="Attrib: value", line-1 label="Experience To Raise:", line-1
value=cost, line-2="Unassigned Experience:" in both states.
- Row highlight: BackgroundColor=HighlightBg (semi-translucent gold) on selected row.
- Raise buttons (0x10000246 ×1 + 0x100005EB ×10): hidden initially; shown on
selection with ActiveState="Normal" (affordable) or "Ghosted" (cost=0 or unaffordable).
CollectButtonsById tree-walk finds ALL copies of the button across tab-page mounts
(not just the last-registered _byId copy) so all instances are controlled.
- CharacterSheet: AttributeRaiseCosts long[] (Strength…Mana raise costs in retail
display order; cost=0 → max/disabled row demos the Ghosted button state).
- SampleData.SampleCharacter: fills AttributeRaiseCosts[9] — Strength/Quickness=0
(maxed), Focus@10→110 matching the retail screenshot (spec §4).
- 35 new tests (total 673 pass) covering: row click→footer B title/line1/line2,
toggle deselect→footer A, switch row, highlight set/clear, raise button
hidden/Normal/Ghosted/deselect, tab Open/Closed states, GetRaiseCost helper,
GetRowName helper, SampleData fixture sanity.
Console.WriteLine("[CharacterStat] Row click: index=N → selected=N (Name)") fires
on every click for the user's live verification in the studio.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: the sub-layout 0x2100002C (design H=337px) mounted into tab slot
0x1000022B (H=575px) via ShouldMountBaseChildren. ElementReader.Merge takes the
derived (sub-layout) H=337 as canonical, so the background element's first
ApplyAnchor call captured _amB=238 and the list box captured _amB=65 — both
stayed at their 337px-parent sizes even though the slot is 575px.
Fix: CascadeHeight in LayoutImporter.Resolve mirrors retail's
UIElement::UpdateForParentSizeChange. When ShouldMountBaseChildren fires and the
slot is taller than the base design, every full-stretch background child has its
height cascaded: Top+Bottom anchors → stretch, Bottom-only → pin-to-bottom,
Top-only/None → unchanged. This propagates the correct bottom margins through the
entire subtree before the first render frame.
Layout result (confirmed via headless screenshot):
- List box grows from 160px → 398px (9 rows × 44px ≈ 396px)
- Footer elements move from abs Y≈307 → abs Y≈545 (matching retail dump)
- Separator moves to abs Y≈535
Row constants updated: RowHeight 44px, IconSize 24px, RowPadX 4px, IconGap 6px.
Footer State-A text corrected per spec: title="Select an Attribute to Improve",
line-1 label="Skill Credits Available:", line-1 value=SkillCredits (96),
line-2 label="Unassigned Experience:", line-2 value=UnassignedXp (formatted N0).
CharacterSheet.UnassignedXp (long, retail InqInt64(2)) added.
SampleData.SampleCharacter().UnassignedXp = 87_757_321_741L.
5 footer tests renamed + assertions updated; SampleCharacter_UnassignedXp_IsSet added.
639 tests green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
LayoutDesc 0x2100002E is a tabbed window; all three pages (Attributes/Skills/Titles) mount
their content as overlapping root children. acdream drew all three, so the inactive pages'
backdrops painted over the active content — the dim "almost opaque" cover the user reported,
and it displaced the XP meter. The shared gmStatManagement ids are duplicated across pages and
_byId keeps the LAST-mounted copy, so every bound widget lives in exactly one page; the
controller now keeps that page visible (ContainsWidget reference-walk from the bound name
element) and hides the rest. Header/attrs/XP-bar now render clean + correctly placed.
Tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Redo of the character pilot per faithful-port rules. The previous pilot GUESSED a text
report and put it on the wrong window. The decomp (verified by dumping acdream's
importer-resolved tree) shows LayoutDesc 0x2100002E is the tabbed Attributes/Skills/Titles
window: its tab-content slot 0x1000022B mounts sub-layout 0x2100002C (gmAttributeUI) which
chains into the gmStatManagementUI header. The importer ALREADY mounts every content element
(FindElement resolves name 0x10000231, heritage 0x10000232, PK 0x10000233, level 0x1000023B,
total-XP 0x10000235, XP meter 0x10000236 → UiMeter, list box 0x1000023D) — EventId was a red
herring (the dat id lives in _byId, not the widget's EventId field).
CharacterStatController (port of gmStatManagementUI::UpdateCharacterInfo 0x004f0770 +
UpdateExperience 0x004f0a70 + UpdatePKStatus 0x004f00a0) binds those real elements: name,
heritage, PK status, level, total XP, the XP-to-level meter fill, and the six innate
attributes in the list box. Studio (--layout 0x2100002E) now renders the actual panel
content, not an overlay. 16 controller tests green; full App suite stays green.
Refinements with known decomp sources (follow-ups): tab-button active/inactive state so the
3 tabs draw their sprites; exact label wording from the StringTable (table 0x10000001 →
dat 0x31000001); the full AttributeInfoRegion row template (column-aligned values + raise
buttons). CharacterController (text-report 0x2100001A) retained for that separate sub-panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>