diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs
index 7fdb4c37..e35be76c 100644
--- a/src/AcDream.App/Studio/FixtureProvider.cs
+++ b/src/AcDream.App/Studio/FixtureProvider.cs
@@ -114,8 +114,11 @@ public static class FixtureProvider
break;
}
- case 0x2100002Eu: // gmCharacterInfoUI — character report (LayoutDesc 0x2100002E)
- CharacterController.Bind(
+ case 0x2100002Eu: // gmStatManagementUI — Attributes/Skills/Titles window (LayoutDesc 0x2100002E)
+ // 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,
data: SampleData.SampleCharacter,
datFont: stack.VitalsDatFont);
diff --git a/src/AcDream.App/Studio/SampleData.cs b/src/AcDream.App/Studio/SampleData.cs
index 7678b603..969af65c 100644
--- a/src/AcDream.App/Studio/SampleData.cs
+++ b/src/AcDream.App/Studio/SampleData.cs
@@ -133,6 +133,11 @@ public static class SampleData
PlayTime = "2 years, 114 days, 4 hours",
Deaths = 42,
+ PkStatus = "Non-Player Killer",
+ TotalXp = 1_250_000_000,
+ XpToNextLevel = 42_000_000,
+ XpFraction = 0.63f,
+
HealthCurrent = 80, HealthMax = 100,
StaminaCurrent = 60, StaminaMax = 100,
ManaCurrent = 90, ManaMax = 100,
diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs
index f251f325..aeeec885 100644
--- a/src/AcDream.App/UI/Layout/CharacterSheet.cs
+++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs
@@ -33,6 +33,25 @@ public sealed class CharacterSheet
/// Title string, e.g. "the Adventurer". Null = omit.
public string? Title { get; init; }
+ // ── Experience / PK (gmStatManagementUI::UpdateExperience 0x004f0a70,
+ // UpdatePKStatus 0x004f00a0) — the Attributes-tab header strip ──────────
+
+ /// Total accrued experience (retail PropertyInt64 1). Header value
+ /// element 0x10000235 (m_pTotalXPText).
+ public long TotalXp { get; init; }
+
+ /// Experience remaining to the next level. Header value element
+ /// 0x10000238 (m_pXPToLevelText).
+ public long XpToNextLevel { get; init; }
+
+ /// XP-to-next-level meter fill, 0..1 (retail (cur−base)/(cap−base)).
+ /// Drives the header meter 0x10000236 (m_pXPToLevelMeter).
+ public float XpFraction { get; init; }
+
+ /// PK status display string, e.g. "Non-Player Killer". Header element
+ /// 0x10000233 (m_pPKStatusText). Null = omit.
+ public string? PkStatus { get; init; }
+
// ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ─────────
/// Formatted birth date string (retail InqInt(0x62) → strftime).
diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs
new file mode 100644
index 00000000..fa79cd85
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.UI;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Controller for the Character window's Attributes tab — LayoutDesc 0x2100002E,
+/// whose tab-content slot 0x1000022B mounts sub-layout 0x2100002C (gmAttributeUI, root
+/// type 0x1000002A) which in turn chains into the gmStatManagementUI header content.
+///
+/// Unlike (which targets the SEPARATE text-report
+/// sub-panel 0x2100001A, gmCharacterInfoUI, by creating its runtime m_pMainText element),
+/// this controller binds the real, statically-mounted header + list elements that the
+/// importer already produces — every id below is confirmed present via
+/// .
+///
+/// Ported from gmStatManagementUI::UpdateCharacterInfo (0x004f0770) +
+/// UpdateExperience (0x004f0a70) + UpdatePKStatus (0x004f00a0): name, heritage,
+/// PK status, level, total XP and the XP-to-level meter. The attribute list is the
+/// gmAttributeUI list box (0x1000023D).
+///
+/// Refinement pending: 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.
+///
+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
+
+ ///
+ /// Bind the Attributes-tab header + list elements in to
+ /// . 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.
+ ///
+ public static void Bind(ImportedLayout layout, Func 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 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) };
+ }
+ }
+
+ ///
+ /// The six innate attributes in canonical AC panel order
+ /// (Strength, Endurance, Coordination, Quickness, Focus, Self), preceded by a gold header.
+ ///
+ private static IReadOnlyList AttributeRows(CharacterSheet s)
+ => new List
+ {
+ 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);
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
new file mode 100644
index 00000000..70faab1a
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
@@ -0,0 +1,108 @@
+using AcDream.App.Studio;
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// Unit tests for — 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.
+///
+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().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();
+ var root = new UiPanel();
+ foreach (var (id, e) in items)
+ {
+ root.AddChild(e);
+ dict[id] = e;
+ }
+ return new ImportedLayout(root, dict);
+ }
+}