feat(studio): Character panel pilot — gmCharacterInfoUI report text + menu-bar picker

Task A — CharacterController (LayoutDesc 0x2100002E)
- CharacterSheet.cs: data record for all fields used by the report sections,
  with retail property-id citations per field (DateOfBirth 0x62, TotalPlayTime
  0x7d, NumDeaths 0x2b, SkillCredits 0xb5/0xc0, AugmentationStat 0x162,
  EncumbranceVal 0x5/0xe6).
- CharacterController.cs: static Bind(layout, data, datFont) wires element
  0x1000011d (m_pMainText, confirmed gmCharacterInfoUI::PostInit 0x004b86f0) as
  a UiText; LinesProvider builds the report. Report section order mirrors
  gmCharacterInfoUI::Update (0x004ba790):
  1. UpdatePlayerBirthAgeDeaths 0x004b8cb0 — birth/age/deaths
  2. UpdateEnduranceInfo 0x004b8eb0 — vitals (H/S/M cur/max)
  3. UpdateInnateAttributeInfo 0x004b87e0 — 6 attrs InqAttribute 1,2,4,3,5,6
     = Strength, Endurance, Quickness, Coordination, Focus, Self
  4. UpdateFakeSkills 0x004b8930 — skill credits (InqInt 0xb5 / 0xc0)
  5. UpdateAugmentations 0x004b9000 — aug name (InqInt 0x162 switch 1..0xb)
  6. UpdateLoad 0x004b8a20 — burden cur/max/pct
  Element 0x1000011d imports as UiText (Type 12); multi-line text set via
  LinesProvider — same pattern as ChatWindowController transcript.
- SampleData.SampleCharacter(): plausible retail-scale CharacterSheet fixture.
- FixtureProvider.Populate case 0x2100002Eu: CharacterController.Bind with
  SampleData.SampleCharacter + VitalsDatFont.
- CharacterControllerTests.cs: 10 pure data-wiring + content tests (no GL/dats).

Task B — Studio panel picker → main menu bar
- StudioWindow.OnRender: replaced the floating "Studio" toolbar window (kToolbarH
  40px) with ImGui.BeginMainMenuBar / EndMainMenuBar (kMenuBarH 22px). Panel
  picker combo now lives in the always-on-top menu bar and cannot be occluded by
  Tree/Canvas/Props panes. paneY reduced from 40→22, freeing ~18px for the canvas.

Build: dotnet build green (0 CS errors; DLL-lock warnings are AcDream.App being
live — not compile errors). Full suite: 619 passed, 2 skipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-25 18:37:30 +02:00
parent c03a241884
commit 4988dd4dfe
6 changed files with 515 additions and 8 deletions

View file

@ -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;

View file

@ -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) ───
/// <summary>
/// Returns a representative <see cref="CharacterSheet"/> for the studio's
/// synthetic character. Values are plausible retail-scale numbers so the
/// report renders with well-proportioned text in all sections.
/// </summary>
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(

View file

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

View file

@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Per-window controller for the Character window (LayoutDesc 0x2100002E, gmCharacterInfoUI).
///
/// <para>Retail fills a single scrollable <c>UIElement_Text</c> element (id 0x1000011d,
/// <c>m_pMainText</c>) by calling a sequence of Update* methods that each
/// <c>AppendStringInfo</c> into the same element. This controller replicates that report
/// structure using acdream's <see cref="UiText.LinesProvider"/>.</para>
///
/// <para>Section order (ported from <c>gmCharacterInfoUI::Update</c> 0x004ba790):
/// <list type="number">
/// <item>Birth / age / deaths (<c>UpdatePlayerBirthAgeDeaths</c> 0x004b8cb0)</item>
/// <item>Vitals / endurance (<c>UpdateEnduranceInfo</c> 0x004b8eb0)</item>
/// <item>Innate attributes (<c>UpdateInnateAttributeInfo</c> 0x004b87e0):
/// InqAttribute 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self</item>
/// <item>Skills summary (<c>UpdateFakeSkills</c> 0x004b8930)</item>
/// <item>Augmentations (<c>UpdateAugmentations</c> 0x004b9000)</item>
/// <item>Burden / load (<c>UpdateLoad</c> 0x004b8a20)</item>
/// </list>
/// Each section is separated by a blank line (retail: <c>AppendStringInfo(m_pMainText, &amp;var_120)</c>
/// with an empty StringInfo between sections).</para>
///
/// <para>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.</para>
/// </summary>
public static class CharacterController
{
/// <summary>Dat element id for the main report text (m_pMainText). Confirmed in
/// <c>gmCharacterInfoUI::PostInit</c> 0x004b86f0: <c>GetChildRecursive(this, 0x1000011d)</c>.</summary>
public const uint MainTextId = 0x1000011du;
/// <summary>White body text — matches retail's default UIElement_Text foreground.</summary>
private static readonly Vector4 BodyColor = new(1f, 1f, 1f, 1f);
/// <summary>Yellow section header — retail uses a different StringInfo color per section header.</summary>
private static readonly Vector4 HeaderColor = new(1f, 0.85f, 0.35f, 1f);
/// <summary>
/// Bind the character report text element found in <paramref name="layout"/> to
/// <paramref name="data"/>. The report is regenerated each frame via
/// <see cref="UiText.LinesProvider"/> so the Studio or a live session can push a fresh
/// <see cref="CharacterSheet"/> without re-binding.
///
/// <para>The element must resolve as a <see cref="UiText"/> (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.</para>
/// </summary>
/// <param name="layout">Imported 0x2100002E layout tree.</param>
/// <param name="data">Provider returning the current <see cref="CharacterSheet"/>.</param>
/// <param name="datFont">Retail dat font forwarded from the render stack. May be null
/// (falls back to the BitmapFont debug path).</param>
public static void Bind(
ImportedLayout layout,
Func<CharacterSheet> 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 ────────────────────────────────────────────────────────
/// <summary>
/// Build the complete character report as a line list.
/// Order mirrors <c>gmCharacterInfoUI::Update</c> (0x004ba790).
/// </summary>
private static IReadOnlyList<UiText.Line> BuildReport(CharacterSheet s)
{
var lines = new List<UiText.Line>();
// ── 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<UiText.Line> lines, string text)
=> lines.Add(new UiText.Line(text, HeaderColor));
private static void Append(List<UiText.Line> lines, string text)
=> lines.Add(new UiText.Line(text, BodyColor));
private static void AppendBlank(List<UiText.Line> lines)
=> lines.Add(new UiText.Line(string.Empty, BodyColor));
}

View file

@ -0,0 +1,86 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Snapshot of all character data the Character window report uses.
/// Passed to <see cref="CharacterController.Bind"/> as a provider delegate
/// so the window can be rebound to live data in GameWindow or a static
/// fixture in the UI Studio.
///
/// <para>Field names and retail property ids confirmed from
/// <c>gmCharacterInfoUI::UpdatePlayerBirthAgeDeaths</c> (0x004b8cb0),
/// <c>UpdateEnduranceInfo</c> (0x004b8eb0),
/// <c>UpdateInnateAttributeInfo</c> (0x004b87e0),
/// <c>UpdateFakeSkills</c> (0x004b8930),
/// <c>UpdateAugmentations</c> (0x004b9000), and
/// <c>UpdateLoad</c> (0x004b8a20).</para>
/// </summary>
public sealed class CharacterSheet
{
// ── Identity ──────────────────────────────────────────────────────────────
/// <summary>Character name (first line of the report).</summary>
public string Name { get; init; } = string.Empty;
/// <summary>Character level.</summary>
public int Level { get; init; }
/// <summary>Race string, e.g. "Aluvian". Null = omit.</summary>
public string? Race { get; init; }
/// <summary>Heritage string, e.g. "Aluvian Heritage". Null = omit.</summary>
public string? Heritage { get; init; }
/// <summary>Title string, e.g. "the Adventurer". Null = omit.</summary>
public string? Title { get; init; }
// ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ─────────
/// <summary>Formatted birth date string (retail InqInt(0x62) → strftime).
/// Null = omit the birth line.</summary>
public string? BirthDate { get; init; }
/// <summary>Formatted play-time duration (retail InqInt(0x7d) → QueryDuration).
/// Null = omit the age line.</summary>
public string? PlayTime { get; init; }
/// <summary>Total deaths (retail InqInt(0x2b) = NumDeaths).</summary>
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.
/// <summary>Augmentation name from the switch in UpdateAugmentations (0x004b9000),
/// e.g. "Swords", "Two Handed Weapons". Null = "None".</summary>
public string? AugmentationName { get; init; }
// ── Burden / load (UpdateLoad 0x004b8a20) ───────────────────────────────
// Retail InqLoad + EncumbranceCapacity(Strength, AugEncumbrance).
public int BurdenCurrent { get; init; }
public int BurdenMax { get; init; }
}

View file

@ -0,0 +1,184 @@
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="CharacterController"/> and <see cref="SampleData.SampleCharacter"/>.
/// No dats, no GL — pure data-wiring + report-content tests.
/// </summary>
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<uint, UiElement>();
var root = new UiPanel();
foreach (var (id, e) in items)
{
root.AddChild(e);
dict[id] = e;
}
return new ImportedLayout(root, dict);
}
}