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

@ -114,8 +114,11 @@ public static class FixtureProvider
break; break;
} }
case 0x2100002Eu: // gmCharacterInfoUI — character report (LayoutDesc 0x2100002E) case 0x2100002Eu: // gmStatManagementUI — Attributes/Skills/Titles window (LayoutDesc 0x2100002E)
CharacterController.Bind( // Bind the REAL importer-mounted header + list elements (name/heritage/PK/level/
// total-XP/XP-meter + the attribute list). NOT the text-report sub-panel
// (that is gmCharacterInfoUI 0x2100001A → CharacterController).
CharacterStatController.Bind(
layout, layout,
data: SampleData.SampleCharacter, data: SampleData.SampleCharacter,
datFont: stack.VitalsDatFont); datFont: stack.VitalsDatFont);

View file

@ -133,6 +133,11 @@ public static class SampleData
PlayTime = "2 years, 114 days, 4 hours", PlayTime = "2 years, 114 days, 4 hours",
Deaths = 42, Deaths = 42,
PkStatus = "Non-Player Killer",
TotalXp = 1_250_000_000,
XpToNextLevel = 42_000_000,
XpFraction = 0.63f,
HealthCurrent = 80, HealthMax = 100, HealthCurrent = 80, HealthMax = 100,
StaminaCurrent = 60, StaminaMax = 100, StaminaCurrent = 60, StaminaMax = 100,
ManaCurrent = 90, ManaMax = 100, ManaCurrent = 90, ManaMax = 100,

View file

@ -33,6 +33,25 @@ public sealed class CharacterSheet
/// <summary>Title string, e.g. "the Adventurer". Null = omit.</summary> /// <summary>Title string, e.g. "the Adventurer". Null = omit.</summary>
public string? Title { get; init; } public string? Title { get; init; }
// ── Experience / PK (gmStatManagementUI::UpdateExperience 0x004f0a70,
// UpdatePKStatus 0x004f00a0) — the Attributes-tab header strip ──────────
/// <summary>Total accrued experience (retail PropertyInt64 1). Header value
/// element 0x10000235 (m_pTotalXPText).</summary>
public long TotalXp { get; init; }
/// <summary>Experience remaining to the next level. Header value element
/// 0x10000238 (m_pXPToLevelText).</summary>
public long XpToNextLevel { get; init; }
/// <summary>XP-to-next-level meter fill, 0..1 (retail (curbase)/(capbase)).
/// Drives the header meter 0x10000236 (m_pXPToLevelMeter).</summary>
public float XpFraction { get; init; }
/// <summary>PK status display string, e.g. "Non-Player Killer". Header element
/// 0x10000233 (m_pPKStatusText). Null = omit.</summary>
public string? PkStatus { get; init; }
// ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ───────── // ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ─────────
/// <summary>Formatted birth date string (retail InqInt(0x62) → strftime). /// <summary>Formatted birth date string (retail InqInt(0x62) → strftime).

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);
}

View file

@ -0,0 +1,108 @@
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit tests for <see cref="CharacterStatController"/> — the Attributes-tab controller that
/// binds the REAL importer-mounted header + list elements (no created overlay). Pure data
/// wiring against fake layouts; no dats, no GL.
/// </summary>
public class CharacterStatControllerTests
{
// ── Header labels bind to the sheet ──────────────────────────────────────
[Fact]
public void Bind_SetsNameLabel()
{
var name = new UiText();
var layout = Fake((CharacterStatController.NameId, name));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Studio Player", name.LinesProvider()[0].Text);
}
[Fact]
public void Bind_SetsLevelLabel()
{
var level = new UiText();
var layout = Fake((CharacterStatController.LevelId, level));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("126", level.LinesProvider()[0].Text);
}
[Fact]
public void Bind_SetsHeritageAndPkLabels()
{
var heritage = new UiText();
var pk = new UiText();
var layout = Fake((CharacterStatController.HeritageId, heritage),
(CharacterStatController.PkStatusId, pk));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Aluvian Heritage", heritage.LinesProvider()[0].Text);
Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text);
}
// ── XP meter fill ────────────────────────────────────────────────────────
[Fact]
public void Bind_SetsXpMeterFill()
{
var meter = new UiMeter();
var layout = Fake((CharacterStatController.XpMeterId, meter));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var fill = meter.Fill();
Assert.NotNull(fill);
Assert.True(System.MathF.Abs(fill!.Value - 0.63f) < 0.001f, $"expected ~0.63, got {fill}");
}
// ── Attribute list ───────────────────────────────────────────────────────
[Fact]
public void Bind_PopulatesAttributeList()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiText>().First();
var text = string.Join("\n", rows.LinesProvider().Select(l => l.Text));
Assert.Contains("Strength", text);
Assert.Contains("Endurance", text);
Assert.Contains("Coordination", text);
Assert.Contains("Quickness", text);
Assert.Contains("Focus", text);
Assert.Contains("Self", text);
Assert.Contains("100", text);
}
// ── Robustness: a partial layout (missing ids) must not throw ────────────
[Fact]
public void Bind_MissingElements_DoesNotThrow()
=> CharacterStatController.Bind(Fake(), SampleData.SampleCharacter);
// ── Helper ─────────────────────────────────────────────────────────────
private static ImportedLayout Fake(params (uint id, UiElement e)[] items)
{
var dict = new Dictionary<uint, UiElement>();
var root = new UiPanel();
foreach (var (id, e) in items)
{
root.AddChild(e);
dict[id] = e;
}
return new ImportedLayout(root, dict);
}
}