fix(studio): Character window — bind the REAL Attributes-tab elements (no guessing)

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>
This commit is contained in:
Erik 2026-06-25 19:47:33 +02:00
parent 33693c6412
commit 0e644b5887
5 changed files with 250 additions and 2 deletions

View file

@ -0,0 +1,113 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Controller for the Character window's <b>Attributes tab</b> — LayoutDesc 0x2100002E,
/// whose tab-content slot 0x1000022B mounts sub-layout 0x2100002C (gmAttributeUI, root
/// type 0x1000002A) which in turn chains into the gmStatManagementUI header content.
///
/// <para>Unlike <see cref="CharacterController"/> (which targets the SEPARATE text-report
/// sub-panel 0x2100001A, gmCharacterInfoUI, by creating its runtime m_pMainText element),
/// this controller binds the <b>real, statically-mounted</b> header + list elements that the
/// importer already produces — every id below is confirmed present via
/// <see cref="ImportedLayout.FindElement"/>.</para>
///
/// <para>Ported from <c>gmStatManagementUI::UpdateCharacterInfo</c> (0x004f0770) +
/// <c>UpdateExperience</c> (0x004f0a70) + <c>UpdatePKStatus</c> (0x004f00a0): name, heritage,
/// PK status, level, total XP and the XP-to-level meter. The attribute list is the
/// <c>gmAttributeUI</c> list box (0x1000023D).</para>
///
/// <para><b>Refinement pending:</b> exact label wording comes from the dat StringTable
/// (table enum 0x10000001 → dat 0x31000001) — not yet ported, so labels use the canonical
/// AC text. Column alignment + per-row raise buttons (the full AttributeInfoRegion row
/// template) are a later pass; this binds values into the real element rects first.</para>
/// </summary>
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 ListBoxId = 0x1000023Du; // m_pListBox container
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
/// <summary>
/// Bind the Attributes-tab header + list elements in <paramref name="layout"/> to
/// <paramref name="data"/>. Each element is the real importer-mounted widget; the
/// providers are polled each frame so a live session or the Studio can swap the sheet
/// without re-binding.
/// </summary>
public static void Bind(ImportedLayout layout, Func<CharacterSheet> data, UiDatFont? datFont = null)
{
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"));
// XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70).
if (layout.FindElement(XpMeterId) is UiMeter meter)
meter.Fill = () => data().XpFraction;
// Attribute list (gmAttributeUI list box 0x1000023D). The list box is a generic
// container in the import, so attach a single text view sized to the rows and pinned
// to the top of the list region. (The full per-row AttributeInfoRegion widget is a
// later pass; this renders the six innate attributes + values faithfully.)
if (layout.FindElement(ListBoxId) is { } list)
{
var rows = new UiText
{
DatFont = datFont,
ClickThrough = true,
Left = 12f,
Top = 10f,
Width = MathF.Max(60f, list.Width - 24f),
Height = 9f * LineHeight, // sized to the 8 content lines so it top-aligns
};
rows.LinesProvider = () => AttributeRows(data());
list.AddChild(rows);
}
}
private const float LineHeight = 18f;
private static void Label(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func<string> text)
{
if (layout.FindElement(id) is UiText t)
{
t.DatFont = datFont;
t.Centered = true; // single-line centered label (retail UIElement_Text)
t.ClickThrough = true;
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
}
}
/// <summary>
/// The six innate attributes in canonical AC panel order
/// (Strength, Endurance, Coordination, Quickness, Focus, Self), preceded by a gold header.
/// </summary>
private static IReadOnlyList<UiText.Line> AttributeRows(CharacterSheet s)
=> new List<UiText.Line>
{
new("Attributes", Gold),
new(string.Empty, Body),
Row("Strength", s.Strength),
Row("Endurance", s.Endurance),
Row("Coordination", s.Coordination),
Row("Quickness", s.Quickness),
Row("Focus", s.Focus),
Row("Self", s.Self),
};
private static UiText.Line Row(string name, int value)
=> new($"{name,-13}{value,4}", Body);
}