fix(studio): Character pilot — CREATE m_pMainText (it's a runtime element, not static)

The character report element m_pMainText (0x1000011d) is created at RUNTIME by
gmCharacterInfoUI — it is NOT in any static LayoutDesc (confirmed: acdream's
dat-import of both 0x2100002E and 0x2100006E lacks it; only a runtime UI-tree
capture has it). So CharacterController.Bind now CREATES the UiText report
element + attaches it to the panel body (Left 12 / Top 44 / fills, ZOrder above
chrome) and fills it via LinesProvider — replicating what the retail gm*UI does.
Verified: the studio (--layout 0x2100002E) renders the full report — identity,
birth/age/deaths, vitals, the 6 attributes, skills, augmentations, encumbrance.
Tests rewritten to assert the CREATED element (10 pass); full App suite 619 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-25 19:01:09 +02:00
parent 4988dd4dfe
commit 33693c6412
2 changed files with 51 additions and 75 deletions

View file

@ -61,15 +61,28 @@ public static class CharacterController
Func<CharacterSheet> data,
UiDatFont? datFont = null)
{
if (layout.FindElement(MainTextId) is not UiText text)
return;
// Retail's gmCharacterInfoUI CREATES m_pMainText (0x1000011d) at RUNTIME — it is NOT a static
// element in any LayoutDesc (confirmed: acdream's dat-import of 0x2100002E AND 0x2100006E both
// lack it; only a runtime UI-tree capture has it). So replicate that: build the report text
// element ourselves and place it in the panel body, exactly as the gm*UI does at runtime.
var root = layout.Root;
if (root is null) return;
text.DatFont = datFont;
text.ClickThrough = false; // retail allows text selection in the report
text.AcceptsFocus = true;
text.IsEditControl = true;
text.CapturesPointerDrag = true;
var text = new UiText
{
EventId = MainTextId,
Name = "m_pMainText",
Left = 12f,
Top = 44f, // below the window header row
Width = System.Math.Max(40f, root.Width - 24f),
Height = System.Math.Max(40f, root.Height - 56f),
Anchors = AnchorEdges.None,
ZOrder = 1_000_000, // draw above the static chrome
DatFont = datFont,
ClickThrough = false,
};
text.LinesProvider = () => BuildReport(data());
root.AddChild(text);
}
// ── Report builder ────────────────────────────────────────────────────────

View file

@ -6,23 +6,31 @@ namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit tests for <see cref="CharacterController"/> and <see cref="SampleData.SampleCharacter"/>.
/// The controller CREATES the m_pMainText report element (0x1000011d) at bind time — retail's
/// gm*UI builds it at runtime; it is not a static dat element — and attaches it to the layout root.
/// 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 ───────
/// <summary>Bind on an empty layout, then return the created m_pMainText element.</summary>
private static UiText BindAndGetText(System.Func<CharacterSheet> data)
{
var layout = FakeLayout();
CharacterController.Bind(layout, data);
return layout.Root.Children.OfType<UiText>()
.First(t => t.EventId == CharacterController.MainTextId);
}
private static string Report(System.Func<CharacterSheet> data)
=> string.Join("\n", BindAndGetText(data).LinesProvider().Select(l => l.Text));
// ── Test 1: Bind creates m_pMainText with a non-empty report ──────────────
[Fact]
public void Bind_SetsLinesProviderOnMainText()
public void Bind_CreatesMainTextWithReport()
{
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");
var text = BindAndGetText(SampleData.SampleCharacter);
Assert.True(text.LinesProvider().Count > 0, "Expected non-empty report after Bind");
}
// ── Test 2: Report contains all six attribute names (decomp order 1,2,4,3,5,6) ───
@ -30,13 +38,7 @@ public class CharacterControllerTests
[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));
var allText = Report(SampleData.SampleCharacter);
Assert.Contains("Strength", allText);
Assert.Contains("Endurance", allText);
Assert.Contains("Quickness", allText);
@ -50,13 +52,7 @@ public class CharacterControllerTests
[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));
var allText = Report(SampleData.SampleCharacter);
Assert.Contains("Health", allText);
Assert.Contains("Stamina", allText);
Assert.Contains("Mana", allText);
@ -67,13 +63,7 @@ public class CharacterControllerTests
[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));
var allText = Report(SampleData.SampleCharacter);
Assert.Contains("Burden", allText);
Assert.Contains("Capacity", allText);
}
@ -82,42 +72,24 @@ public class CharacterControllerTests
[Fact]
public void Bind_ReportContainsSampleName()
{
var text = new UiText();
var layout = FakeLayout((CharacterController.MainTextId, text));
=> Assert.Contains("Studio Player", Report(SampleData.SampleCharacter));
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) ─────────────────────
// ── Test 6: Bind on a layout WITHOUT the element creates it (no throw) ────
[Fact]
public void Bind_MissingElement_DoesNotThrow()
public void Bind_OnEmptyLayout_CreatesElement()
{
// Layout does not contain MainTextId — Bind must silently skip.
var layout = FakeLayout(); // empty layout
// Must not throw.
var layout = FakeLayout(); // no MainTextId present
CharacterController.Bind(layout, SampleData.SampleCharacter);
Assert.Contains(layout.Root.Children.OfType<UiText>(),
t => t.EventId == CharacterController.MainTextId);
}
// ── Test 7: Element that is NOT a UiText is silently skipped ─────────────
// ── Test 7: The created element carries the m_pMainText id ───────────────
[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.
}
public void Bind_CreatedElementHasMainTextId()
=> Assert.Equal(CharacterController.MainTextId, BindAndGetText(SampleData.SampleCharacter).EventId);
// ── Test 8: SampleCharacter fixture has non-zero attribute values ─────────
@ -125,7 +97,6 @@ public class CharacterControllerTests
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");
@ -140,7 +111,6 @@ public class CharacterControllerTests
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]");
@ -151,15 +121,8 @@ public class CharacterControllerTests
[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.
var allText = Report(() => sheet);
Assert.Contains(sheet.Strength.ToString(), allText);
Assert.Contains(sheet.Endurance.ToString(), allText);
Assert.Contains(sheet.Quickness.ToString(), allText);