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