fix(studio): Character window — bind the REAL Attributes-tab elements (no guessing)
Redo of the character pilot per faithful-port rules. The previous pilot GUESSED a text report and put it on the wrong window. The decomp (verified by dumping acdream's importer-resolved tree) shows LayoutDesc 0x2100002E is the tabbed Attributes/Skills/Titles window: its tab-content slot 0x1000022B mounts sub-layout 0x2100002C (gmAttributeUI) which chains into the gmStatManagementUI header. The importer ALREADY mounts every content element (FindElement resolves name 0x10000231, heritage 0x10000232, PK 0x10000233, level 0x1000023B, total-XP 0x10000235, XP meter 0x10000236 → UiMeter, list box 0x1000023D) — EventId was a red herring (the dat id lives in _byId, not the widget's EventId field). CharacterStatController (port of gmStatManagementUI::UpdateCharacterInfo 0x004f0770 + UpdateExperience 0x004f0a70 + UpdatePKStatus 0x004f00a0) binds those real elements: name, heritage, PK status, level, total XP, the XP-to-level meter fill, and the six innate attributes in the list box. Studio (--layout 0x2100002E) now renders the actual panel content, not an overlay. 16 controller tests green; full App suite stays green. Refinements with known decomp sources (follow-ups): tab-button active/inactive state so the 3 tabs draw their sprites; exact label wording from the StringTable (table 0x10000001 → dat 0x31000001); the full AttributeInfoRegion row template (column-aligned values + raise buttons). CharacterController (text-report 0x2100001A) retained for that separate sub-panel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
33693c6412
commit
0e644b5887
5 changed files with 250 additions and 2 deletions
|
|
@ -114,8 +114,11 @@ public static class FixtureProvider
|
|||
break;
|
||||
}
|
||||
|
||||
case 0x2100002Eu: // gmCharacterInfoUI — character report (LayoutDesc 0x2100002E)
|
||||
CharacterController.Bind(
|
||||
case 0x2100002Eu: // gmStatManagementUI — Attributes/Skills/Titles window (LayoutDesc 0x2100002E)
|
||||
// Bind the REAL importer-mounted header + list elements (name/heritage/PK/level/
|
||||
// total-XP/XP-meter + the attribute list). NOT the text-report sub-panel
|
||||
// (that is gmCharacterInfoUI 0x2100001A → CharacterController).
|
||||
CharacterStatController.Bind(
|
||||
layout,
|
||||
data: SampleData.SampleCharacter,
|
||||
datFont: stack.VitalsDatFont);
|
||||
|
|
|
|||
|
|
@ -133,6 +133,11 @@ public static class SampleData
|
|||
PlayTime = "2 years, 114 days, 4 hours",
|
||||
Deaths = 42,
|
||||
|
||||
PkStatus = "Non-Player Killer",
|
||||
TotalXp = 1_250_000_000,
|
||||
XpToNextLevel = 42_000_000,
|
||||
XpFraction = 0.63f,
|
||||
|
||||
HealthCurrent = 80, HealthMax = 100,
|
||||
StaminaCurrent = 60, StaminaMax = 100,
|
||||
ManaCurrent = 90, ManaMax = 100,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,25 @@ public sealed class CharacterSheet
|
|||
/// <summary>Title string, e.g. "the Adventurer". Null = omit.</summary>
|
||||
public string? Title { get; init; }
|
||||
|
||||
// ── Experience / PK (gmStatManagementUI::UpdateExperience 0x004f0a70,
|
||||
// UpdatePKStatus 0x004f00a0) — the Attributes-tab header strip ──────────
|
||||
|
||||
/// <summary>Total accrued experience (retail PropertyInt64 1). Header value
|
||||
/// element 0x10000235 (m_pTotalXPText).</summary>
|
||||
public long TotalXp { get; init; }
|
||||
|
||||
/// <summary>Experience remaining to the next level. Header value element
|
||||
/// 0x10000238 (m_pXPToLevelText).</summary>
|
||||
public long XpToNextLevel { get; init; }
|
||||
|
||||
/// <summary>XP-to-next-level meter fill, 0..1 (retail (cur−base)/(cap−base)).
|
||||
/// Drives the header meter 0x10000236 (m_pXPToLevelMeter).</summary>
|
||||
public float XpFraction { get; init; }
|
||||
|
||||
/// <summary>PK status display string, e.g. "Non-Player Killer". Header element
|
||||
/// 0x10000233 (m_pPKStatusText). Null = omit.</summary>
|
||||
public string? PkStatus { get; init; }
|
||||
|
||||
// ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ─────────
|
||||
|
||||
/// <summary>Formatted birth date string (retail InqInt(0x62) → strftime).
|
||||
|
|
|
|||
113
src/AcDream.App/UI/Layout/CharacterStatController.cs
Normal file
113
src/AcDream.App/UI/Layout/CharacterStatController.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for the Character window's <b>Attributes tab</b> — LayoutDesc 0x2100002E,
|
||||
/// whose tab-content slot 0x1000022B mounts sub-layout 0x2100002C (gmAttributeUI, root
|
||||
/// type 0x1000002A) which in turn chains into the gmStatManagementUI header content.
|
||||
///
|
||||
/// <para>Unlike <see cref="CharacterController"/> (which targets the SEPARATE text-report
|
||||
/// sub-panel 0x2100001A, gmCharacterInfoUI, by creating its runtime m_pMainText element),
|
||||
/// this controller binds the <b>real, statically-mounted</b> header + list elements that the
|
||||
/// importer already produces — every id below is confirmed present via
|
||||
/// <see cref="ImportedLayout.FindElement"/>.</para>
|
||||
///
|
||||
/// <para>Ported from <c>gmStatManagementUI::UpdateCharacterInfo</c> (0x004f0770) +
|
||||
/// <c>UpdateExperience</c> (0x004f0a70) + <c>UpdatePKStatus</c> (0x004f00a0): name, heritage,
|
||||
/// PK status, level, total XP and the XP-to-level meter. The attribute list is the
|
||||
/// <c>gmAttributeUI</c> list box (0x1000023D).</para>
|
||||
///
|
||||
/// <para><b>Refinement pending:</b> exact label wording comes from the dat StringTable
|
||||
/// (table enum 0x10000001 → dat 0x31000001) — not yet ported, so labels use the canonical
|
||||
/// AC text. Column alignment + per-row raise buttons (the full AttributeInfoRegion row
|
||||
/// template) are a later pass; this binds values into the real element rects first.</para>
|
||||
/// </summary>
|
||||
public static class CharacterStatController
|
||||
{
|
||||
// ── gmStatManagementUI header element ids (sub-layout 0x2100002C content) ──
|
||||
public const uint NameId = 0x10000231u; // m_pNameText
|
||||
public const uint HeritageId = 0x10000232u; // m_pHeritageText
|
||||
public const uint PkStatusId = 0x10000233u; // m_pPKStatusText
|
||||
public const uint LevelId = 0x1000023Bu; // m_pLevelText (right-side level area)
|
||||
public const uint TotalXpId = 0x10000235u; // m_pTotalXPText
|
||||
public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter)
|
||||
public const uint ListBoxId = 0x1000023Du; // m_pListBox container
|
||||
|
||||
private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
|
||||
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
|
||||
|
||||
/// <summary>
|
||||
/// Bind the Attributes-tab header + list elements in <paramref name="layout"/> to
|
||||
/// <paramref name="data"/>. Each element is the real importer-mounted widget; the
|
||||
/// providers are polled each frame so a live session or the Studio can swap the sheet
|
||||
/// without re-binding.
|
||||
/// </summary>
|
||||
public static void Bind(ImportedLayout layout, Func<CharacterSheet> data, UiDatFont? datFont = null)
|
||||
{
|
||||
Label(layout, NameId, datFont, Gold, () => data().Name);
|
||||
Label(layout, HeritageId, datFont, Body, () => data().Heritage ?? data().Race ?? string.Empty);
|
||||
Label(layout, PkStatusId, datFont, Body, () => data().PkStatus ?? string.Empty);
|
||||
Label(layout, LevelId, datFont, Gold, () => data().Level.ToString());
|
||||
Label(layout, TotalXpId, datFont, Body, () => data().TotalXp.ToString("N0"));
|
||||
|
||||
// XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70).
|
||||
if (layout.FindElement(XpMeterId) is UiMeter meter)
|
||||
meter.Fill = () => data().XpFraction;
|
||||
|
||||
// Attribute list (gmAttributeUI list box 0x1000023D). The list box is a generic
|
||||
// container in the import, so attach a single text view sized to the rows and pinned
|
||||
// to the top of the list region. (The full per-row AttributeInfoRegion widget is a
|
||||
// later pass; this renders the six innate attributes + values faithfully.)
|
||||
if (layout.FindElement(ListBoxId) is { } list)
|
||||
{
|
||||
var rows = new UiText
|
||||
{
|
||||
DatFont = datFont,
|
||||
ClickThrough = true,
|
||||
Left = 12f,
|
||||
Top = 10f,
|
||||
Width = MathF.Max(60f, list.Width - 24f),
|
||||
Height = 9f * LineHeight, // sized to the 8 content lines so it top-aligns
|
||||
};
|
||||
rows.LinesProvider = () => AttributeRows(data());
|
||||
list.AddChild(rows);
|
||||
}
|
||||
}
|
||||
|
||||
private const float LineHeight = 18f;
|
||||
|
||||
private static void Label(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func<string> text)
|
||||
{
|
||||
if (layout.FindElement(id) is UiText t)
|
||||
{
|
||||
t.DatFont = datFont;
|
||||
t.Centered = true; // single-line centered label (retail UIElement_Text)
|
||||
t.ClickThrough = true;
|
||||
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The six innate attributes in canonical AC panel order
|
||||
/// (Strength, Endurance, Coordination, Quickness, Focus, Self), preceded by a gold header.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<UiText.Line> AttributeRows(CharacterSheet s)
|
||||
=> new List<UiText.Line>
|
||||
{
|
||||
new("Attributes", Gold),
|
||||
new(string.Empty, Body),
|
||||
Row("Strength", s.Strength),
|
||||
Row("Endurance", s.Endurance),
|
||||
Row("Coordination", s.Coordination),
|
||||
Row("Quickness", s.Quickness),
|
||||
Row("Focus", s.Focus),
|
||||
Row("Self", s.Self),
|
||||
};
|
||||
|
||||
private static UiText.Line Row(string name, int value)
|
||||
=> new($"{name,-13}{value,4}", Body);
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
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="CharacterStatController"/> — the Attributes-tab controller that
|
||||
/// binds the REAL importer-mounted header + list elements (no created overlay). Pure data
|
||||
/// wiring against fake layouts; no dats, no GL.
|
||||
/// </summary>
|
||||
public class CharacterStatControllerTests
|
||||
{
|
||||
// ── Header labels bind to the sheet ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_SetsNameLabel()
|
||||
{
|
||||
var name = new UiText();
|
||||
var layout = Fake((CharacterStatController.NameId, name));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
Assert.Equal("Studio Player", name.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_SetsLevelLabel()
|
||||
{
|
||||
var level = new UiText();
|
||||
var layout = Fake((CharacterStatController.LevelId, level));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
Assert.Equal("126", level.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_SetsHeritageAndPkLabels()
|
||||
{
|
||||
var heritage = new UiText();
|
||||
var pk = new UiText();
|
||||
var layout = Fake((CharacterStatController.HeritageId, heritage),
|
||||
(CharacterStatController.PkStatusId, pk));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
Assert.Equal("Aluvian Heritage", heritage.LinesProvider()[0].Text);
|
||||
Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
// ── XP meter fill ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_SetsXpMeterFill()
|
||||
{
|
||||
var meter = new UiMeter();
|
||||
var layout = Fake((CharacterStatController.XpMeterId, meter));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var fill = meter.Fill();
|
||||
Assert.NotNull(fill);
|
||||
Assert.True(System.MathF.Abs(fill!.Value - 0.63f) < 0.001f, $"expected ~0.63, got {fill}");
|
||||
}
|
||||
|
||||
// ── Attribute list ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_PopulatesAttributeList()
|
||||
{
|
||||
var list = new UiPanel();
|
||||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiText>().First();
|
||||
var text = string.Join("\n", rows.LinesProvider().Select(l => l.Text));
|
||||
|
||||
Assert.Contains("Strength", text);
|
||||
Assert.Contains("Endurance", text);
|
||||
Assert.Contains("Coordination", text);
|
||||
Assert.Contains("Quickness", text);
|
||||
Assert.Contains("Focus", text);
|
||||
Assert.Contains("Self", text);
|
||||
Assert.Contains("100", text);
|
||||
}
|
||||
|
||||
// ── Robustness: a partial layout (missing ids) must not throw ────────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_MissingElements_DoesNotThrow()
|
||||
=> CharacterStatController.Bind(Fake(), SampleData.SampleCharacter);
|
||||
|
||||
// ── Helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static ImportedLayout Fake(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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue