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>
182 lines
9 KiB
C#
182 lines
9 KiB
C#
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, &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)
|
|
{
|
|
// 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;
|
|
|
|
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 ────────────────────────────────────────────────────────
|
|
|
|
/// <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));
|
|
}
|