diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs
index 4a5f319a..7fdb4c37 100644
--- a/src/AcDream.App/Studio/FixtureProvider.cs
+++ b/src/AcDream.App/Studio/FixtureProvider.cs
@@ -114,6 +114,13 @@ public static class FixtureProvider
break;
}
+ case 0x2100002Eu: // gmCharacterInfoUI — character report (LayoutDesc 0x2100002E)
+ CharacterController.Bind(
+ layout,
+ data: SampleData.SampleCharacter,
+ datFont: stack.VitalsDatFont);
+ break;
+
default:
// Unknown layout — no-op; the panel renders structurally.
break;
diff --git a/src/AcDream.App/Studio/SampleData.cs b/src/AcDream.App/Studio/SampleData.cs
index 2704c046..7678b603 100644
--- a/src/AcDream.App/Studio/SampleData.cs
+++ b/src/AcDream.App/Studio/SampleData.cs
@@ -1,3 +1,4 @@
+using AcDream.App.UI.Layout;
using AcDream.Core.Items;
namespace AcDream.App.Studio;
@@ -114,6 +115,44 @@ public static class SampleData
public const float StaminaPct = 0.6f;
public const float ManaPct = 0.9f;
+ // ── Sample character sheet (used by CharacterController in the Studio) ───
+
+ ///
+ /// Returns a representative for the studio's
+ /// synthetic character. Values are plausible retail-scale numbers so the
+ /// report renders with well-proportioned text in all sections.
+ ///
+ public static CharacterSheet SampleCharacter() => new()
+ {
+ Name = "Studio Player",
+ Level = 126,
+ Race = "Aluvian",
+ Heritage = "Aluvian Heritage",
+ Title = "the Adventurer",
+ BirthDate = "January 5, 2001",
+ PlayTime = "2 years, 114 days, 4 hours",
+ Deaths = 42,
+
+ HealthCurrent = 80, HealthMax = 100,
+ StaminaCurrent = 60, StaminaMax = 100,
+ ManaCurrent = 90, ManaMax = 100,
+
+ Strength = 100,
+ Endurance = 100,
+ Quickness = 100,
+ Coordination = 100,
+ Focus = 100,
+ Self = 100,
+
+ UnspentSkillCredits = 12,
+ SpecializedSkillCredits = 4,
+
+ AugmentationName = "Swords",
+
+ BurdenCurrent = 1200,
+ BurdenMax = 4500,
+ };
+
// ── Helpers ─────────────────────────────────────────────────────────────
private static void AddItem(
diff --git a/src/AcDream.App/Studio/StudioWindow.cs b/src/AcDream.App/Studio/StudioWindow.cs
index 2565fc13..28ac55ef 100644
--- a/src/AcDream.App/Studio/StudioWindow.cs
+++ b/src/AcDream.App/Studio/StudioWindow.cs
@@ -279,21 +279,43 @@ public sealed class StudioWindow : IDisposable
_imgui.BeginFrame((float)dt);
// ── Layout constants (fixed pane arrangement, FirstUseEver) ──────────────
- // Toolbar: full width, 40px tall at y=0.
- // Tree: 280px wide on the left, below toolbar.
+ // MenuBar: always-on-top main menu bar (~22px) — panel picker lives here so it
+ // is never covered by the floating panes (replaces the old 40px toolbar).
+ // Tree: 280px wide on the left, below menu bar.
// Canvas: centre strip between tree and properties.
- // Props: 340px wide on the right, below toolbar.
- const int kToolbarH = 40;
+ // Props: 340px wide on the right, below menu bar.
+ const int kMenuBarH = 22; // ImGui default main menu bar height
const int kTreeW = 280;
const int kPropsW = 340;
int canvasX = kTreeW;
int canvasW = Math.Max(1, iw - kTreeW - kPropsW);
int propsX = iw - kPropsW;
- int paneY = kToolbarH;
- int paneH = Math.Max(1, ih - kToolbarH);
+ int paneY = kMenuBarH;
+ int paneH = Math.Max(1, ih - kMenuBarH);
- // 5. Toolbar pane — slug picker.
- string? pickedSlug = _inspector.DrawToolbar(_dumpSlugs, _currentSlug, iw);
+ // 5. Main menu bar — panel picker combo pinned to the window top.
+ // BeginMainMenuBar returns true when the bar is visible (always is); the combo
+ // inside it is always-on-top and is never occluded by Tree/Canvas/Props panes.
+ string? pickedSlug = null;
+ if (ImGuiNET.ImGui.BeginMainMenuBar())
+ {
+ ImGuiNET.ImGui.SetNextItemWidth(300f);
+ string preview = _currentSlug ?? "(none)";
+ if (ImGuiNET.ImGui.BeginCombo("Panel", preview))
+ {
+ foreach (var slug in _dumpSlugs)
+ {
+ bool selected = string.Equals(slug, _currentSlug,
+ System.StringComparison.OrdinalIgnoreCase);
+ if (ImGuiNET.ImGui.Selectable(slug, selected) && !selected)
+ pickedSlug = slug;
+ if (selected)
+ ImGuiNET.ImGui.SetItemDefaultFocus();
+ }
+ ImGuiNET.ImGui.EndCombo();
+ }
+ ImGuiNET.ImGui.EndMainMenuBar();
+ }
if (pickedSlug is not null)
LoadDumpPanel(pickedSlug);
diff --git a/src/AcDream.App/UI/Layout/CharacterController.cs b/src/AcDream.App/UI/Layout/CharacterController.cs
new file mode 100644
index 00000000..6067525d
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/CharacterController.cs
@@ -0,0 +1,169 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.UI;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Per-window controller for the Character window (LayoutDesc 0x2100002E, gmCharacterInfoUI).
+///
+/// Retail fills a single scrollable UIElement_Text element (id 0x1000011d,
+/// m_pMainText) by calling a sequence of Update* methods that each
+/// AppendStringInfo into the same element. This controller replicates that report
+/// structure using acdream's .
+///
+/// Section order (ported from gmCharacterInfoUI::Update 0x004ba790):
+///
+/// - Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0)
+/// - Vitals / endurance (UpdateEnduranceInfo 0x004b8eb0)
+/// - Innate attributes (UpdateInnateAttributeInfo 0x004b87e0):
+/// InqAttribute 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self
+/// - Skills summary (UpdateFakeSkills 0x004b8930)
+/// - Augmentations (UpdateAugmentations 0x004b9000)
+/// - Burden / load (UpdateLoad 0x004b8a20)
+///
+/// Each section is separated by a blank line (retail: AppendStringInfo(m_pMainText, &var_120)
+/// with an empty StringInfo between sections).
+///
+/// StringInfo resolution is NOT ported — the full dat string-table lookup is a
+/// later refinement. For this pilot, labels are composed directly from the well-known
+/// AC attribute / stat names confirmed in the decomp string literals and data identifiers.
+///
+public static class CharacterController
+{
+ /// Dat element id for the main report text (m_pMainText). Confirmed in
+ /// gmCharacterInfoUI::PostInit 0x004b86f0: GetChildRecursive(this, 0x1000011d).
+ public const uint MainTextId = 0x1000011du;
+
+ /// White body text — matches retail's default UIElement_Text foreground.
+ private static readonly Vector4 BodyColor = new(1f, 1f, 1f, 1f);
+
+ /// Yellow section header — retail uses a different StringInfo color per section header.
+ private static readonly Vector4 HeaderColor = new(1f, 0.85f, 0.35f, 1f);
+
+ ///
+ /// Bind the character report text element found in to
+ /// . The report is regenerated each frame via
+ /// so the Studio or a live session can push a fresh
+ /// without re-binding.
+ ///
+ /// The element must resolve as a (Type 12 in the dat).
+ /// If 0x1000011d is absent from the layout the bind is a no-op — partial layouts
+ /// (e.g. test fakes) don't cause errors.
+ ///
+ /// Imported 0x2100002E layout tree.
+ /// Provider returning the current .
+ /// Retail dat font forwarded from the render stack. May be null
+ /// (falls back to the BitmapFont debug path).
+ public static void Bind(
+ ImportedLayout layout,
+ Func data,
+ UiDatFont? datFont = null)
+ {
+ if (layout.FindElement(MainTextId) is not UiText text)
+ return;
+
+ text.DatFont = datFont;
+ text.ClickThrough = false; // retail allows text selection in the report
+ text.AcceptsFocus = true;
+ text.IsEditControl = true;
+ text.CapturesPointerDrag = true;
+ text.LinesProvider = () => BuildReport(data());
+ }
+
+ // ── Report builder ────────────────────────────────────────────────────────
+
+ ///
+ /// Build the complete character report as a line list.
+ /// Order mirrors gmCharacterInfoUI::Update (0x004ba790).
+ ///
+ private static IReadOnlyList BuildReport(CharacterSheet s)
+ {
+ var lines = new List();
+
+ // ── Section 1: Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ──
+ // Retail InqInt(0x62) = DateOfBirth (unix timestamp); InqInt(0x7d) = TotalPlayTime;
+ // InqInt(0x2b) = NumDeaths.
+ AppendHeader(lines, s.Name);
+ Append(lines, $"Level {s.Level}");
+ if (s.Race is not null)
+ Append(lines, s.Race);
+ if (s.Heritage is not null)
+ Append(lines, s.Heritage);
+ if (s.Title is not null)
+ Append(lines, s.Title);
+ AppendBlank(lines);
+
+ if (s.BirthDate is not null)
+ Append(lines, $"Birth: {s.BirthDate}");
+ if (s.PlayTime is not null)
+ Append(lines, $"Age: {s.PlayTime}");
+ Append(lines, $"Deaths: {s.Deaths}");
+ AppendBlank(lines);
+
+ // ── Section 2: Vitals / endurance (UpdateEnduranceInfo 0x004b8eb0) ──
+ // Retail InqAttribute(1) = Strength base, InqAttribute(2) = Endurance base.
+ // Computes max-stamina tier and max-health tier from (Str+End) and (End+2*End) sums.
+ AppendHeader(lines, "Vitals");
+ Append(lines, $"Health: {s.HealthCurrent} / {s.HealthMax}");
+ Append(lines, $"Stamina: {s.StaminaCurrent} / {s.StaminaMax}");
+ Append(lines, $"Mana: {s.ManaCurrent} / {s.ManaMax}");
+ AppendBlank(lines);
+
+ // ── Section 3: Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ──
+ // InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination,
+ // Focus, Self (confirmed from the AddVariable_Int sequence in the decomp).
+ AppendHeader(lines, "Attributes");
+ Append(lines, $"Strength: {s.Strength}");
+ Append(lines, $"Endurance: {s.Endurance}");
+ Append(lines, $"Quickness: {s.Quickness}");
+ Append(lines, $"Coordination: {s.Coordination}");
+ Append(lines, $"Focus: {s.Focus}");
+ Append(lines, $"Self: {s.Self}");
+ AppendBlank(lines);
+
+ // ── Section 4: Skills summary (UpdateFakeSkills 0x004b8930) ──
+ // Retail InqInt(0xb5) = SkillCredits remaining; InqInt(0xc0) = AvailableSkillCredits.
+ AppendHeader(lines, "Skills");
+ Append(lines, $"Unspent skill credits: {s.UnspentSkillCredits}");
+ if (s.SpecializedSkillCredits > 0)
+ Append(lines, $"Specialized credits: {s.SpecializedSkillCredits}");
+ AppendBlank(lines);
+
+ // ── Section 5: Augmentations (UpdateAugmentations 0x004b9000) ──
+ // Retail InqInt(0x162) = AugmentationStat; string-switch on value 1..0xb + Unknown.
+ AppendHeader(lines, "Augmentations");
+ if (s.AugmentationName is not null)
+ Append(lines, s.AugmentationName);
+ else
+ Append(lines, "None");
+ AppendBlank(lines);
+
+ // ── Section 6: Burden / load (UpdateLoad 0x004b8a20) ──
+ // Retail InqLoad → EncumbranceCapacity(Strength, AugEncumbrance).
+ // When load >= 1.0 (overloaded): shows "X burden over capacity; Y% speed penalty".
+ // When aug > 0: shows "N augmentations (X.X% bonus)".
+ AppendHeader(lines, "Encumbrance");
+ Append(lines, $"Burden: {s.BurdenCurrent}");
+ Append(lines, $"Capacity: {s.BurdenMax}");
+ if (s.BurdenMax > 0)
+ {
+ int pct = (int)Math.Round(100.0 * s.BurdenCurrent / s.BurdenMax);
+ Append(lines, $"Load: {pct}%");
+ }
+
+ return lines;
+ }
+
+ // ── Line helpers ──────────────────────────────────────────────────────────
+
+ private static void AppendHeader(List lines, string text)
+ => lines.Add(new UiText.Line(text, HeaderColor));
+
+ private static void Append(List lines, string text)
+ => lines.Add(new UiText.Line(text, BodyColor));
+
+ private static void AppendBlank(List lines)
+ => lines.Add(new UiText.Line(string.Empty, BodyColor));
+}
diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs
new file mode 100644
index 00000000..f251f325
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs
@@ -0,0 +1,86 @@
+namespace AcDream.App.UI.Layout;
+
+///
+/// Snapshot of all character data the Character window report uses.
+/// Passed to as a provider delegate
+/// so the window can be rebound to live data in GameWindow or a static
+/// fixture in the UI Studio.
+///
+/// Field names and retail property ids confirmed from
+/// gmCharacterInfoUI::UpdatePlayerBirthAgeDeaths (0x004b8cb0),
+/// UpdateEnduranceInfo (0x004b8eb0),
+/// UpdateInnateAttributeInfo (0x004b87e0),
+/// UpdateFakeSkills (0x004b8930),
+/// UpdateAugmentations (0x004b9000), and
+/// UpdateLoad (0x004b8a20).
+///
+public sealed class CharacterSheet
+{
+ // ── Identity ──────────────────────────────────────────────────────────────
+
+ /// Character name (first line of the report).
+ public string Name { get; init; } = string.Empty;
+
+ /// Character level.
+ public int Level { get; init; }
+
+ /// Race string, e.g. "Aluvian". Null = omit.
+ public string? Race { get; init; }
+
+ /// Heritage string, e.g. "Aluvian Heritage". Null = omit.
+ public string? Heritage { get; init; }
+
+ /// Title string, e.g. "the Adventurer". Null = omit.
+ public string? Title { get; init; }
+
+ // ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ─────────
+
+ /// Formatted birth date string (retail InqInt(0x62) → strftime).
+ /// Null = omit the birth line.
+ public string? BirthDate { get; init; }
+
+ /// Formatted play-time duration (retail InqInt(0x7d) → QueryDuration).
+ /// Null = omit the age line.
+ public string? PlayTime { get; init; }
+
+ /// Total deaths (retail InqInt(0x2b) = NumDeaths).
+ public int Deaths { get; init; }
+
+ // ── Vitals (UpdateEnduranceInfo 0x004b8eb0) ─────────────────────────────
+
+ public int HealthCurrent { get; init; }
+ public int HealthMax { get; init; }
+ public int StaminaCurrent { get; init; }
+ public int StaminaMax { get; init; }
+ public int ManaCurrent { get; init; }
+ public int ManaMax { get; init; }
+
+ // ── Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ────────────
+ // InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self.
+
+ public int Strength { get; init; }
+ public int Endurance { get; init; }
+ public int Quickness { get; init; }
+ public int Coordination { get; init; }
+ public int Focus { get; init; }
+ public int Self { get; init; }
+
+ // ── Skills (UpdateFakeSkills 0x004b8930) ────────────────────────────────
+ // Retail InqInt(0xb5) = SkillCredits; InqInt(0xc0) = AvailableSkillCredits.
+
+ public int UnspentSkillCredits { get; init; }
+ public int SpecializedSkillCredits { get; init; }
+
+ // ── Augmentations (UpdateAugmentations 0x004b9000) ─────────────────────
+ // Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb.
+
+ /// Augmentation name from the switch in UpdateAugmentations (0x004b9000),
+ /// e.g. "Swords", "Two Handed Weapons". Null = "None".
+ public string? AugmentationName { get; init; }
+
+ // ── Burden / load (UpdateLoad 0x004b8a20) ───────────────────────────────
+ // Retail InqLoad + EncumbranceCapacity(Strength, AugEncumbrance).
+
+ public int BurdenCurrent { get; init; }
+ public int BurdenMax { get; init; }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs
new file mode 100644
index 00000000..ed61904a
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs
@@ -0,0 +1,184 @@
+using AcDream.App.Studio;
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// Unit tests for and .
+/// No dats, no GL — pure data-wiring + report-content tests.
+///
+public class CharacterControllerTests
+{
+ // ── Test 1: Bind finds the main text element and sets LinesProvider ───────
+
+ [Fact]
+ public void Bind_SetsLinesProviderOnMainText()
+ {
+ var text = new UiText();
+ var layout = FakeLayout((CharacterController.MainTextId, text));
+
+ CharacterController.Bind(layout, SampleData.SampleCharacter);
+
+ // LinesProvider must be replaced (default returns empty).
+ var lines = text.LinesProvider();
+ Assert.True(lines.Count > 0, "Expected non-empty report after Bind");
+ }
+
+ // ── Test 2: Report contains all six attribute names (decomp order 1,2,4,3,5,6) ───
+
+ [Fact]
+ public void Bind_ReportContainsAllSixAttributes()
+ {
+ var text = new UiText();
+ var layout = FakeLayout((CharacterController.MainTextId, text));
+
+ CharacterController.Bind(layout, SampleData.SampleCharacter);
+
+ var allText = string.Join("\n", text.LinesProvider().Select(l => l.Text));
+
+ Assert.Contains("Strength", allText);
+ Assert.Contains("Endurance", allText);
+ Assert.Contains("Quickness", allText);
+ Assert.Contains("Coordination", allText);
+ Assert.Contains("Focus", allText);
+ Assert.Contains("Self", allText);
+ }
+
+ // ── Test 3: Report contains vitals section ────────────────────────────────
+
+ [Fact]
+ public void Bind_ReportContainsVitals()
+ {
+ var text = new UiText();
+ var layout = FakeLayout((CharacterController.MainTextId, text));
+
+ CharacterController.Bind(layout, SampleData.SampleCharacter);
+
+ var allText = string.Join("\n", text.LinesProvider().Select(l => l.Text));
+
+ Assert.Contains("Health", allText);
+ Assert.Contains("Stamina", allText);
+ Assert.Contains("Mana", allText);
+ }
+
+ // ── Test 4: Report contains encumbrance / burden section ─────────────────
+
+ [Fact]
+ public void Bind_ReportContainsBurden()
+ {
+ var text = new UiText();
+ var layout = FakeLayout((CharacterController.MainTextId, text));
+
+ CharacterController.Bind(layout, SampleData.SampleCharacter);
+
+ var allText = string.Join("\n", text.LinesProvider().Select(l => l.Text));
+
+ Assert.Contains("Burden", allText);
+ Assert.Contains("Capacity", allText);
+ }
+
+ // ── Test 5: Report contains the sample character's name ──────────────────
+
+ [Fact]
+ public void Bind_ReportContainsSampleName()
+ {
+ var text = new UiText();
+ var layout = FakeLayout((CharacterController.MainTextId, text));
+
+ CharacterController.Bind(layout, SampleData.SampleCharacter);
+
+ var allText = string.Join("\n", text.LinesProvider().Select(l => l.Text));
+
+ // SampleData.SampleCharacter().Name == "Studio Player"
+ Assert.Contains("Studio Player", allText);
+ }
+
+ // ── Test 6: Missing element id is a no-op (no throw) ─────────────────────
+
+ [Fact]
+ public void Bind_MissingElement_DoesNotThrow()
+ {
+ // Layout does not contain MainTextId — Bind must silently skip.
+ var layout = FakeLayout(); // empty layout
+
+ // Must not throw.
+ CharacterController.Bind(layout, SampleData.SampleCharacter);
+ }
+
+ // ── Test 7: Element that is NOT a UiText is silently skipped ─────────────
+
+ [Fact]
+ public void Bind_WrongElementType_DoesNotThrow()
+ {
+ // Place a UiMeter at the MainTextId — Bind must skip (not crash on wrong type).
+ var meter = new UiMeter();
+ var layout = FakeLayout((CharacterController.MainTextId, meter));
+
+ CharacterController.Bind(layout, SampleData.SampleCharacter);
+ // No exception; meter is unchanged.
+ }
+
+ // ── Test 8: SampleCharacter fixture has non-zero attribute values ─────────
+
+ [Fact]
+ public void SampleCharacter_AttributesAreNonZero()
+ {
+ var s = SampleData.SampleCharacter();
+
+ Assert.True(s.Strength > 0, "Strength must be > 0");
+ Assert.True(s.Endurance > 0, "Endurance must be > 0");
+ Assert.True(s.Quickness > 0, "Quickness must be > 0");
+ Assert.True(s.Coordination > 0, "Coordination must be > 0");
+ Assert.True(s.Focus > 0, "Focus must be > 0");
+ Assert.True(s.Self > 0, "Self must be > 0");
+ }
+
+ // ── Test 9: SampleCharacter fixture has coherent vitals ──────────────────
+
+ [Fact]
+ public void SampleCharacter_VitalsAreCoherent()
+ {
+ var s = SampleData.SampleCharacter();
+
+ Assert.True(s.HealthCurrent > 0 && s.HealthCurrent <= s.HealthMax, "Health cur must be in [1, max]");
+ Assert.True(s.StaminaCurrent > 0 && s.StaminaCurrent <= s.StaminaMax, "Stamina cur must be in [1, max]");
+ Assert.True(s.ManaCurrent > 0 && s.ManaCurrent <= s.ManaMax, "Mana cur must be in [1, max]");
+ }
+
+ // ── Test 10: Report contains attribute VALUES from the fixture ────────────
+
+ [Fact]
+ public void Bind_ReportContainsSampleAttributeValues()
+ {
+ var text = new UiText();
+ var layout = FakeLayout((CharacterController.MainTextId, text));
+
+ var sheet = SampleData.SampleCharacter();
+ CharacterController.Bind(layout, () => sheet);
+
+ var allText = string.Join("\n", text.LinesProvider().Select(l => l.Text));
+
+ // All six attribute values should appear as strings in the report.
+ Assert.Contains(sheet.Strength.ToString(), allText);
+ Assert.Contains(sheet.Endurance.ToString(), allText);
+ Assert.Contains(sheet.Quickness.ToString(), allText);
+ Assert.Contains(sheet.Coordination.ToString(), allText);
+ Assert.Contains(sheet.Focus.ToString(), allText);
+ Assert.Contains(sheet.Self.ToString(), allText);
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private static ImportedLayout FakeLayout(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);
+ }
+}