fix(ui): match retail vitae and character info

Restore Vitae's omitted penalty paragraph, replace the invented character summary with gmCharacterInfoUI's ordered report and property meanings, preserve authored translucent body surfaces, and initialize the end-session button in its visible Normal DAT state.

Release build and all 5,830 tests pass with five intentional skips. Connected visual gate pending.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 12:06:14 +02:00
parent d1d603105f
commit 16c21e299c
21 changed files with 771 additions and 303 deletions

View file

@ -72,12 +72,21 @@ Link ping RTT and Vitae
recovery XP are live; transport packet-loss averaging and Mini Game gameplay
remain the explicitly narrowed AP-110 residual.
The 2026-07-17 retail-conformance follow-up completes the visible detail data:
Vitae now includes all three localized paragraphs; Character Information now
emits retail's birth/playtime/deaths, resistance grades, innates,
chess/fishing, earned mastery/augmentation, and burden report instead of an
invented character-sheet summary. Character/Vitae authored center surfaces
are no longer composited twice, and the end-session button enters its Normal
DAT state before hover.
**Files:** `src/AcDream.App/UI/Layout/IndicatorBarController.cs`;
`src/AcDream.App/UI/Layout/DatWidgetFactory.cs`;
`src/AcDream.Core.Net/WorldSession.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`.
**Research:**
`docs/research/2026-07-17-retail-indicator-bar-pseudocode.md`.
`docs/research/2026-07-17-retail-vitae-character-info-pseudocode.md`.
**Acceptance:** All seven authored controls render their retail sprites. Link
quality follows the 5/20/40-second thresholds and bad-link flash cadence;

View file

@ -722,6 +722,10 @@ Helpful/Harmful instantiate retail's DAT icon/name/time row and selected-spell
information presentation. The burden icon sends panel 3 (Character Information),
not panel 11 (Attributes/Skills). Link uses retail's payload-free ping/RTT
cadence; Vitae uses PropertyInt 129/139 and the retail XP-pool threshold formula.
The detail conformance pass also restores Vitae's omitted penalty explanation,
ports the exact Character Information report/property meanings, prevents
double-compositing authored translucent panel centers, and initializes the
end-session control in its visible Normal DAT state before hover.
The first connected gate exposed and corrected two retained-import defects:
spellbook tabs are authored stateful text controls (not buttons), and retail
text starts with zero margins. Real-DAT fixtures now pin spellbook binding,

View file

@ -23,10 +23,13 @@ Both Attributes + Skills tabs share a header strip filled by the base class
`UpdatePKStatus` 0x004f00a0). The tab sub-layout `0x2100002C` nests `0x10000226` which
chains again into `0x21000045` — the real header/list content is **two inheritance levels deep**.
The **text report** ("birth/age/deaths, innate attributes, augmentations, load") the earlier
pilot guessed is a SEPARATE sub-panel: LayoutDesc `0x2100001A`, `gmCharacterInfoUI`
(register 0x004b8c90), whose `m_pMainText` (`0x1000011d`) is created at RUNTIME. That window
is handled by `CharacterController` (create-the-element path) — different layout, not 0x2100002E.
The **text report** ("birth/playtime/deaths, resist grades, innate attributes,
chess/fishing, earned augmentations, load") is a SEPARATE child panel:
`gmCharacterInfoUI` (register `0x004b8c90`), root `0x10000183` under the
production shared-panel LayoutDesc `0x2100006E`. Its authored
`m_pMainText` (`0x1000011d`) and scrollbar are imported from that child tree;
the controller only supplies the report. This is different from `0x2100002E`.
See `2026-07-17-retail-vitae-character-info-pseudocode.md` for the exact report.
## Importer reality (dumped live, 2026-06-25)

View file

@ -0,0 +1,128 @@
# Retail Vitae and Character Information pseudocode
Sources: Sept. 2013 named retail client, installed `local_English.dat`, and the
matching v11.4186 executable. String keys are resolved from StringTable
`0x23000001`; duration formatting is StringTable `0x23000006`.
## Vitae
`gmVitaeUI::Update @ 0x004A7400`
```text
vitae = current Vitae enchantment modifier, or 1.0
lost = 100 - trunc(vitae * 100)
if lost <= 0:
mainText = ID_Vitae_Text_Full
else:
mainText = ID_Vitae_Text_Vitae(lost)
mainText += ID_Vitae_Text_Skills(lost)
remaining = VitaeCPPoolThreshold(vitae, deathLevel) - currentPool
mainText += ID_Vitae_Text_Experience(remaining)
```
The skills paragraph is not optional. It explains that health, stamina, mana,
and skills are reduced by the same percentage and warns about penalties above
15 percent.
## Character Information
`gmCharacterInfoUI::Update @ 0x004BA790` clears `m_pMainText`, appends six
sections, and inserts one empty `StringInfo` between each section:
```text
UpdatePlayerBirthAgeDeaths
blank
UpdateEnduranceInfo
blank
UpdateInnateAttributeInfo
blank
UpdateFakeSkills
blank
UpdateAugmentations
blank
UpdateLoad
```
It does not prepend character name, level, race, title, current vitals, or
invented section headings.
### Birth, play time, and deaths
`gmCharacterInfoUI::UpdatePlayerBirthAgeDeaths @ 0x004B8CB0`
```text
if PropertyInt 0x62 exists:
append ID_CharacterInfo_Birth(strftime("%c", localtime(value)))
if PropertyInt 0x7D exists:
append ID_CharacterInfo_Played(TimeUtils::QueryDuration(value))
deaths = PropertyInt 0x2B, default 0
append the None / One / Two string for 0 / 1 / 2
otherwise append ID_CharacterInfo_Deaths_Many(deaths)
```
`TimeUtils::QueryDuration @ 0x00684170` divides seconds into 365-day years,
30-day months, seven-day weeks, days, hours, minutes, and seconds. Zero units
are omitted by `ID_DurationFormat`.
### Resistance grades
`gmCharacterInfoUI::UpdateEnduranceInfo @ 0x004B8EB0`
```text
strength = current Strength
endurance = current Endurance
resist = grade(strength + endurance,
[200, 260, 320, 380, 440])
regen = grade(strength + 2 * endurance,
[200, 346, 470, 580, 690])
grade values in ascending order:
None, Poor, Mediocre, Hardy, Resilient, Indomitable
append ID_CharacterInfo_Resists(RESIST=resist, REGEN=regen)
```
The localized template uses `RESIST` for both the Natural and Drain lines and
`REGEN` for the Regeneration Bonus line.
The literal grades and thresholds were verified from the matching executable
at `0x00794028` and `0x007B1EB8..0x007B1EE8`.
### Innates and fake skills
`UpdateInnateAttributeInfo @ 0x004B87E0` appends the six innate values in the
order Strength, Endurance, Coordination, Quickness, Focus, Self.
`UpdateFakeSkills @ 0x004B8930` appends PropertyInt `0xB5` as Chess Rank and
PropertyInt `0xC0` as Fishing Skill. These are not skill-credit counters.
### Augmentations and load
`UpdateAugmentations @ 0x004B9000` emits only earned mastery, luminance, and
augmentation lines. It emits no `Augmentations` heading and no `None` line.
The first three mastery properties are `0x162`, `0x163`, and `0x16A`; ordinary
augmentation counts are read from their individual PropertyInt qualities.
`UpdateLoad @ 0x004B8A20` computes retail encumbrance capacity from Strength
and PropertyInt `0xE6`. Below 100 percent load it appends the not-overburdened
sentence. At or above 100 percent it appends excess burden and the Run/Jump/
defense reduction. When `0xE6` is positive it also appends the Seventh Mule
augmentation sentence.
## Panel surface and quit button
The Character Information and Vitae child layouts author a full-height
`0x06004CC2` center surface beneath their text. The Positive Effects child does
not; it relies on its enclosing shared panel surface. When mounting those child
layouts in acdream's standalone shared window, the outer chrome must not paint
a second center surface over an already-authored one. Two translucent center
layers compound into the opaque-looking result reported in-client.
The quit control is the ordinary button `0x100000FA` in LayoutDesc
`0x21000071`. Its DAT default is state 1 (`Normal`) with RenderSurface
`0x06007BB1`; state 3 is the pressed surface. `gmIndicatorsUI` does not own a
hover-only visibility rule, so the retained button must enter Normal during
controller initialization, before pointer input.

View file

@ -160,6 +160,8 @@ public static class SampleData
UnspentSkillCredits = 12,
SpecializedSkillCredits = 4,
ChessRank = 12,
FishingSkill = 4,
// Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A.
SkillCredits = 96,
@ -196,7 +198,10 @@ public static class SampleData
new(37, "Fletching", 0x06001A55u, CharacterSkillAdvancementClass.Untrained, 10, 10, false, 4, 8, 0L),
},
AugmentationName = "Swords",
CharacterInfoProperties = new Dictionary<uint, int>
{
[0x162u] = 2, // Swords melee mastery
},
BurdenCurrent = 1200,
BurdenMax = 4500,

View file

@ -1,197 +1,410 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI;
using System.Globalization;
using System.Text;
using AcDream.Core.Items;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Per-window controller for the Character Information child in the production
/// gmPanelUI layout (LayoutDesc 0x2100006E, root 0x10000183).
///
/// <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>
/// Retained port of retail <c>gmCharacterInfoUI</c>. Retail writes one
/// localized report into <c>m_pMainText</c>; it is not a second character
/// sheet and therefore does not repeat name, level, title, or current vitals.
/// </summary>
public static class CharacterController
{
public const uint LayoutId = 0x2100006Eu;
public const uint RootId = 0x10000183u;
/// <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;
public const uint MainTextId = 0x1000011Du;
public const uint ScrollbarId = 0x1000011Eu;
public const uint CloseId = 0x100000FCu;
/// <summary>White body text — matches retail's default UIElement_Text foreground.</summary>
private static readonly Vector4 BodyColor = new(1f, 1f, 1f, 1f);
private static readonly AugmentationDescriptor[] Augmentations =
[
new(0xDAu, "ID_CharacterInfo_Augmentation_Attribute_Strength", true),
new(0xDBu, "ID_CharacterInfo_Augmentation_Attribute_Endurance", true),
new(0xDCu, "ID_CharacterInfo_Augmentation_Attribute_Coordination", true),
new(0xDDu, "ID_CharacterInfo_Augmentation_Attribute_Quickness", true),
new(0xDEu, "ID_CharacterInfo_Augmentation_Attribute_Focus", true),
new(0xDFu, "ID_CharacterInfo_Augmentation_Attribute_Self", true),
new(0xF0u, "ID_CharacterInfo_Augmentation_Resist_Slash", true),
new(0xF1u, "ID_CharacterInfo_Augmentation_Resist_Pierce", true),
new(0xF2u, "ID_CharacterInfo_Augmentation_Resist_Blunt", true),
new(0xF3u, "ID_CharacterInfo_Augmentation_Resist_Acid", true),
new(0x147u, "ID_CharacterInfo_Augmentation_Resist_Nether", true),
new(0xF4u, "ID_CharacterInfo_Augmentation_Resist_Fire", true),
new(0xF5u, "ID_CharacterInfo_Augmentation_Resist_Frost", true),
new(0xF6u, "ID_CharacterInfo_Augmentation_Resist_Lightning", true),
new(0xE0u, "ID_CharacterInfo_Augmentation_Spec_Gearcraft", false),
new(0xE1u, "ID_CharacterInfo_Augmentation_Spec_WeaponTinkering", false),
new(0xE2u, "ID_CharacterInfo_Augmentation_Spec_MagicItemTinkering", false),
new(0xE3u, "ID_CharacterInfo_Augmentation_Spec_ArmorTinkering", false),
new(0xE4u, "ID_CharacterInfo_Augmentation_Spec_ItemTinkering", false),
new(0x125u, "ID_CharacterInfo_Augmentation_Spec_Salvaging", false),
new(0xE5u, "ID_CharacterInfo_Augmentation_ExtraPackSlot", false),
new(0xE6u, "ID_CharacterInfo_Augmentation_IncreasedCarryingCapacity", true),
new(0xE7u, "ID_CharacterInfo_Augmentation_LessDeathItemLoss", true),
new(0xE8u, "ID_CharacterInfo_Augmentation_SpellsRemainPastDeath", false),
new(0xE9u, "ID_CharacterInfo_Augmentation_CriticalDefense", false),
new(0xEAu, "ID_CharacterInfo_Augmentation_BonusXP", false),
new(0xEBu, "ID_CharacterInfo_Augmentation_BonusSalvage", true),
new(0xECu, "ID_CharacterInfo_Augmentation_BonusImbueChance", false),
new(0xEDu, "ID_CharacterInfo_Augmentation_FasterRegen", true),
new(0xEEu, "ID_CharacterInfo_Augmentation_IncreasedSpellDuration", true),
new(0x126u, "ID_CharacterInfo_Augmentation_Infused_CreatureMagic", true),
new(0x127u, "ID_CharacterInfo_Augmentation_Infused_ItemMagic", true),
new(0x128u, "ID_CharacterInfo_Augmentation_Infused_LifeMagic", true),
new(0x129u, "ID_CharacterInfo_Augmentation_Infused_WarMagic", true),
new(0x148u, "ID_CharacterInfo_Augmentation_Infused_VoidMagic", true),
new(0x12Cu, "ID_CharacterInfo_Augmentation_SkilledMelee", true),
new(0x12Du, "ID_CharacterInfo_Augmentation_SkilledMissile", true),
new(0x12Eu, "ID_CharacterInfo_Augmentation_SkilledMagic", true),
new(0x135u, "ID_CharacterInfo_Augmentation_DamageBonus", true),
new(0x136u, "ID_CharacterInfo_Augmentation_DamageResist", true),
new(0x12Au, "ID_CharacterInfo_Augmentation_CriticalExpertise", false),
new(0x12Bu, "ID_CharacterInfo_Augmentation_CriticalPower", false),
new(0x146u, "ID_CharacterInfo_Augmentation_JackOfAllTrades", false),
];
/// <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);
internal static IReadOnlyList<string> AugmentationStringKeys
=> Augmentations.Select(descriptor => descriptor.StringKey).ToArray();
/// <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>Production data resolves the element as a <see cref="UiText"/>
/// (Type 12). A synthetic fallback is created only for deliberately reduced
/// test layouts.</para>
/// </summary>
/// <param name="layout">Imported 0x2100006E / 0x10000183 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,
Action? close = null)
Action? close = null,
CharacterInfoStrings? strings = null)
{
// End-of-retail data authors m_pMainText under the Character Information
// child. The fallback exists only so focused controller tests can bind a
// deliberately reduced tree without fabricating production structure.
var root = layout.Root;
if (root is null) return;
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(data);
UiElement root = layout.Root;
UiText text = layout.FindElement(MainTextId) as UiText ?? 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,
EventId = MainTextId,
Name = "m_pMainText",
Left = 12f,
Top = 44f,
Width = Math.Max(40f, root.Width - 24f),
Height = Math.Max(40f, root.Height - 56f),
ZOrder = 1_000_000,
DatFont = datFont,
ClickThrough = false,
};
if (text.Parent is null)
root.AddChild(text);
else if (text.DatFont is null && datFont is not null)
text.DatFont = datFont;
CharacterInfoStrings copy = strings ?? CharacterInfoStrings.English;
text.PreserveEndOnLayout = false;
text.LinesProvider = () => IndicatorDetailText.Shape(text, BuildReport(data(), copy));
if (layout.FindElement(ScrollbarId) is UiScrollbar scrollbar)
scrollbar.Model = text.Scroll;
text.LinesProvider = () => BuildReport(data());
if (layout.FindElement(CloseId) is UiButton closeButton)
closeButton.OnClick = close;
}
// ── 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)
internal static string BuildReport(CharacterSheet sheet, CharacterInfoStrings strings)
{
var lines = new List<UiText.Line>();
var body = new StringBuilder();
// ── 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);
// gmCharacterInfoUI::UpdatePlayerBirthAgeDeaths @ 0x004B8CB0.
string? birth = sheet.BirthTimestamp is int timestamp
? FormatRetailDate(timestamp)
: sheet.BirthDate;
if (!string.IsNullOrEmpty(birth))
AppendTokens(body, strings.Birth, birth);
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);
string? played = sheet.TotalPlayTimeSeconds is int seconds
? FormatRetailDuration(seconds)
: sheet.PlayTime;
if (!string.IsNullOrEmpty(played))
AppendTokens(body, strings.Played, played);
// ── 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)
switch (sheet.Deaths)
{
int pct = (int)Math.Round(100.0 * s.BurdenCurrent / s.BurdenMax);
Append(lines, $"Load: {pct}%");
case 0: body.Append(strings.DeathsNone); break;
case 1: body.Append(strings.DeathsOne); break;
case 2: body.Append(strings.DeathsTwo); break;
default: AppendTokens(body, strings.DeathsMany, sheet.Deaths); break;
}
AppendSectionBreak(body);
return lines;
// gmCharacterInfoUI::UpdateEnduranceInfo @ 0x004B8EB0.
string resist = ResistanceGrade(sheet.Strength + sheet.Endurance,
200, 260, 320, 380, 440);
string regeneration = ResistanceGrade(
sheet.Strength + (2 * sheet.Endurance),
200, 346, 470, 580, 690);
// ID_CharacterInfo_Resists references the named RESIST variable twice
// (Natural and Drain) and REGEN once.
body.Append(strings.Resists[0]).Append(resist)
.Append(strings.Resists[1]).Append(resist)
.Append(strings.Resists[2]).Append(regeneration)
.Append(strings.Resists[3]);
AppendSectionBreak(body);
// gmCharacterInfoUI::UpdateInnateAttributeInfo @ 0x004B87E0.
AppendTokens(body, strings.Innates,
sheet.Strength, sheet.Endurance, sheet.Coordination,
sheet.Quickness, sheet.Focus, sheet.Self);
AppendSectionBreak(body);
// gmCharacterInfoUI::UpdateFakeSkills @ 0x004B8930.
AppendTokens(body, strings.Chess, sheet.ChessRank);
AppendTokens(body, strings.Fishing, sheet.FishingSkill);
AppendSectionBreak(body);
// gmCharacterInfoUI::UpdateAugmentations @ 0x004B9000: no heading
// and no synthetic "None" line; only qualities greater than zero.
AppendMastery(body, strings.MeleeMastery,
Get(sheet, 0x162u), MeleeMasteryName);
AppendMastery(body, strings.RangedMastery,
Get(sheet, 0x163u), RangedMasteryName);
AppendMastery(body, strings.SummoningMastery,
Get(sheet, 0x16Au), SummoningMasteryName);
foreach (AugmentationDescriptor descriptor in Augmentations)
{
int value = Get(sheet, descriptor.PropertyId);
if (value <= 0
|| !strings.AugmentationText.TryGetValue(
descriptor.StringKey, out string[]? tokens))
continue;
if (descriptor.IncludeValue)
AppendTokens(body, tokens, value);
else
body.Append(ResolvePlural(tokens[0], value));
}
AppendSectionBreak(body);
// gmCharacterInfoUI::UpdateLoad @ 0x004B8A20.
int capacity = BurdenMath.EncumbranceCapacity(
sheet.Strength, sheet.EncumbranceAugmentations);
float load = BurdenMath.LoadRatio(capacity, sheet.BurdenCurrent);
if (load < 1f)
body.Append(strings.LoadNone);
else
AppendTokens(body, strings.LoadBurdened,
sheet.BurdenCurrent - capacity,
BurdenMath.LoadPenaltyPercent(load));
if (sheet.EncumbranceAugmentations > 0)
AppendTokens(body, strings.LoadAugmentations,
sheet.EncumbranceAugmentations,
sheet.EncumbranceAugmentations * 20f);
return body.ToString().TrimEnd('\r', '\n');
}
// ── Line helpers ──────────────────────────────────────────────────────────
private static int Get(CharacterSheet sheet, uint id)
=> sheet.CharacterInfoProperties.TryGetValue(id, out int value) ? value : 0;
private static void AppendHeader(List<UiText.Line> lines, string text)
=> lines.Add(new UiText.Line(text, HeaderColor));
private static void AppendMastery(
StringBuilder body,
string[] tokens,
int value,
Func<int, string> name)
{
if (value > 0)
AppendTokens(body, tokens, name(value));
}
private static void Append(List<UiText.Line> lines, string text)
=> lines.Add(new UiText.Line(text, BodyColor));
private static string MeleeMasteryName(int value) => value switch
{
1 => "Unarmed Weapons",
2 => "Swords",
3 => "Axes",
4 => "Maces",
5 => "Spears",
6 => "Daggers",
7 => "Staves",
11 => "Two Handed Weapons",
_ => "Unknown",
};
private static void AppendBlank(List<UiText.Line> lines)
=> lines.Add(new UiText.Line(string.Empty, BodyColor));
private static string RangedMasteryName(int value) => value switch
{
8 => "Bows",
9 => "Crossbows",
10 => "Thrown Weapons",
12 => "Magical Spells",
_ => "Unknown",
};
private static string SummoningMasteryName(int value) => value switch
{
1 => "Primalist",
2 => "Necromancer",
3 => "Naturalist",
_ => "Unknown",
};
internal static string ResistanceGrade(
int value,
int none,
int poor,
int mediocre,
int hardy,
int resilient)
=> value <= none ? "None"
: value <= poor ? "Poor"
: value <= mediocre ? "Mediocre"
: value <= hardy ? "Hardy"
: value <= resilient ? "Resilient"
: "Indomitable";
internal static string FormatRetailDuration(int totalSeconds)
{
long remaining = Math.Max(0, totalSeconds);
(long seconds, string singular, string plural)[] units =
[
(31_536_000, "year", "years"),
(2_592_000, "month", "months"),
(604_800, "week", "weeks"),
(86_400, "day", "days"),
(3_600, "hour", "hours"),
(60, "minute", "minutes"),
(1, "second", "seconds"),
];
var parts = new List<string>();
foreach (var unit in units)
{
long count = remaining / unit.seconds;
remaining %= unit.seconds;
if (count != 0)
parts.Add($"{count} {(count == 1 ? unit.singular : unit.plural)}");
}
return string.Join(' ', parts);
}
private static string FormatRetailDate(int unixSeconds)
{
try
{
return DateTimeOffset.FromUnixTimeSeconds(unixSeconds)
.LocalDateTime.ToString("G", CultureInfo.CurrentCulture);
}
catch (ArgumentOutOfRangeException)
{
return unixSeconds.ToString(CultureInfo.CurrentCulture);
}
}
private static void AppendTokens(
StringBuilder body,
IReadOnlyList<string> tokens,
params object[] values)
{
for (int i = 0; i < tokens.Count; i++)
{
int pluralValue = values.Length == 0 ? 0
: NumericValue(values[Math.Min(i, values.Length - 1)]);
body.Append(ResolvePlural(tokens[i], pluralValue));
if (i < values.Length)
body.Append(Convert.ToString(values[i], CultureInfo.CurrentCulture));
}
}
private static string ResolvePlural(string text, int value)
=> text.Replace("{time[1]|times}", value == 1 ? "time" : "times",
StringComparison.Ordinal);
private static int NumericValue(object value) => value switch
{
byte v => v,
short v => v,
int v => v,
long v => checked((int)v),
float v => (int)v,
double v => (int)v,
_ => 0,
};
private static void AppendSectionBreak(StringBuilder body)
{
if (body.Length != 0 && body[^1] != '\n') body.Append('\n');
body.Append('\n');
}
private readonly record struct AugmentationDescriptor(
uint PropertyId,
string StringKey,
bool IncludeValue);
}
public sealed record CharacterInfoStrings(
string[] Birth,
string[] Played,
string DeathsNone,
string DeathsOne,
string DeathsTwo,
string[] DeathsMany,
string[] Resists,
string[] Innates,
string[] Chess,
string[] Fishing,
string LoadNone,
string[] LoadBurdened,
string[] LoadAugmentations,
string[] MeleeMastery,
string[] RangedMastery,
string[] SummoningMastery,
IReadOnlyDictionary<string, string[]> AugmentationText)
{
public static CharacterInfoStrings FromDat(
Func<string, string[]?> resolve)
{
ArgumentNullException.ThrowIfNull(resolve);
CharacterInfoStrings fallback = English;
string[] Get(string key, string[] defaultValue)
=> resolve(key) is { Length: > 0 } value ? value : defaultValue;
string GetOne(string key, string defaultValue)
=> Get(key, [defaultValue])[0];
var augmentations = new Dictionary<string, string[]>();
foreach (string key in CharacterController.AugmentationStringKeys)
if (resolve(key) is { Length: > 0 } value)
augmentations[key] = value;
return new CharacterInfoStrings(
Get("ID_CharacterInfo_Birth", fallback.Birth),
Get("ID_CharacterInfo_Played", fallback.Played),
GetOne("ID_CharacterInfo_Deaths_None", fallback.DeathsNone),
GetOne("ID_CharacterInfo_Deaths_One", fallback.DeathsOne),
GetOne("ID_CharacterInfo_Deaths_Two", fallback.DeathsTwo),
Get("ID_CharacterInfo_Deaths_Many", fallback.DeathsMany),
Get("ID_CharacterInfo_Resists", fallback.Resists),
Get("ID_CharacterInfo_Innates", fallback.Innates),
Get("ID_CharacterInfo_Chess", fallback.Chess),
Get("ID_CharacterInfo_Fishing", fallback.Fishing),
GetOne("ID_CharacterInfo_Load_None", fallback.LoadNone),
Get("ID_CharacterInfo_Load_Burdened", fallback.LoadBurdened),
Get("ID_CharacterInfo_Load_Augmentations", fallback.LoadAugmentations),
Get("ID_CharacterInfo_Mastery_Melee", fallback.MeleeMastery),
Get("ID_CharacterInfo_Mastery_Ranged", fallback.RangedMastery),
Get("ID_CharacterInfo_Mastery_Summoning", fallback.SummoningMastery),
augmentations);
}
public static CharacterInfoStrings English { get; } = new(
["You were born on ", ".\n"],
["You have played for ", ".\n"],
"You have never died!\n",
"You have died only once!\n",
"You have died twice.\n",
["You have died ", " times.\n"],
["Natural Resistances: ", "\nDrain Resistances: ", "\nRegeneration Bonus: ", "\n"],
["Innate Strength: ", "\nInnate Endurance: ", "\nInnate Coordination: ",
"\nInnate Quickness: ", "\nInnate Focus: ", "\nInnate Self: ", "\n"],
["Chess Rank: ", "\n"],
["Fishing Skill: ", "\n"],
"You are not overburdened at this time.\n",
["You are currently overburdened by ",
" burden units. This is reducing your Run, Jump, Melee Defense and Missile Defense skills by ",
"%.\n"],
["You have been augmented ",
" times with the Might of the Seventh Mule. This has increased your carrying capacity by ",
"%.\n"],
["Your melee mastery is ", ".\n"],
["Your ranged mastery is ", ".\n\n"],
["Your summoning mastery is ", ".\n\n"],
new Dictionary<string, string[]>());
}

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
namespace AcDream.App.UI.Layout;
@ -70,6 +71,12 @@ public sealed class CharacterSheet
/// <summary>Total deaths (retail InqInt(0x2b) = NumDeaths).</summary>
public int Deaths { get; init; }
/// <summary>Raw retail PropertyInt 0x62. Null means the quality was absent.</summary>
public int? BirthTimestamp { get; init; }
/// <summary>Raw retail PropertyInt 0x7D seconds. Null means the quality was absent.</summary>
public int? TotalPlayTimeSeconds { get; init; }
// ── Vitals (UpdateEnduranceInfo 0x004b8eb0) ─────────────────────────────
public int HealthCurrent { get; init; }
@ -90,12 +97,18 @@ public sealed class CharacterSheet
public int Self { get; init; }
// ── Skills (UpdateFakeSkills 0x004b8930) ────────────────────────────────
// Retail InqInt(0xb5) = SkillCredits; InqInt(0xc0) = AvailableSkillCredits.
// Character Information uses 0xB5/0xC0 for Chess/Fishing; skill credits use 0x18.
// InqInt(0x18) = available skill credits — footer 0x10000245 in the Attributes tab.
public int UnspentSkillCredits { get; init; }
public int SpecializedSkillCredits { get; init; }
/// <summary>Retail Character Information PropertyInt 0xB5.</summary>
public int ChessRank { get; init; }
/// <summary>Retail Character Information PropertyInt 0xC0.</summary>
public int FishingSkill { get; init; }
/// <summary>
/// Available (unspent) skill credits shown in the Attributes tab footer State-A.
/// Retail InqInt(0x18) — gmStatManagementUI::DisplayDefaultFooter (0x0049cde0).
@ -144,11 +157,20 @@ public sealed class CharacterSheet
/// e.g. "Swords", "Two Handed Weapons". Null = "None".</summary>
public string? AugmentationName { get; init; }
/// <summary>
/// Raw PropertyInt snapshot used by retail's data-driven mastery,
/// luminance, and augmentation report. The controller reads only the
/// quality ids cited by <c>gmCharacterInfoUI::UpdateAugmentations</c>.
/// </summary>
public IReadOnlyDictionary<uint, int> CharacterInfoProperties { get; init; }
= new Dictionary<uint, int>();
// ── Burden / load (UpdateLoad 0x004b8a20) ───────────────────────────────
// Retail InqLoad + EncumbranceCapacity(Strength, AugEncumbrance).
public int BurdenCurrent { get; init; }
public int BurdenMax { get; init; }
public int EncumbranceAugmentations { get; init; }
}
public enum CharacterSkillAdvancementClass

View file

@ -31,12 +31,10 @@ public sealed class CharacterSheetProvider
private const uint UnassignedXpPropertyId = 2u;
/// <summary>
/// Skill-credit debit fallback chain, most-specific first. Retail reads
/// InqInt(0x18) for the Attributes-tab footer, InqInt(0xc0) for available
/// and InqInt(0xb5) for total skill credits (see CharacterSheet docs);
/// the live server population varies, so debit whichever is present.
/// Retail PropertyInt 0x18 = available skill credits. Properties 0xB5 and
/// 0xC0 are Chess Rank and Fishing Skill and must never be debited.
/// </summary>
private static readonly uint[] SkillCreditPropertyIds = { 0x18u, 0xC0u, 0xB5u };
private static readonly uint[] SkillCreditPropertyIds = { 0x18u };
private readonly ClientObjectTable _objects;
private readonly LocalPlayerState _localPlayer;
@ -109,7 +107,7 @@ public sealed class CharacterSheetProvider
long unassignedXp = props.GetInt64(UnassignedXpPropertyId);
var xp = ComputeLevelXp(level, totalXp);
int skillCredits = props.GetInt(0x18u, props.GetInt(0xC0u, props.GetInt(0xB5u)));
int skillCredits = props.GetInt(0x18u);
return new CharacterSheet
{
@ -138,8 +136,17 @@ public sealed class CharacterSheetProvider
Focus = AttrCurrent(LocalPlayerState.AttributeKind.Focus),
Self = AttrCurrent(LocalPlayerState.AttributeKind.Self),
UnspentSkillCredits = props.GetInt(0xB5u, skillCredits),
SpecializedSkillCredits = props.GetInt(0xC0u),
UnspentSkillCredits = skillCredits,
SpecializedSkillCredits = 0,
ChessRank = props.GetInt(0xB5u),
FishingSkill = props.GetInt(0xC0u),
BirthTimestamp = props.Ints.TryGetValue(0x62u, out int born)
? born
: null,
TotalPlayTimeSeconds = props.Ints.TryGetValue(0x7Du, out int played)
? played
: null,
Deaths = props.GetInt(0x2Bu),
SkillCredits = skillCredits,
UnassignedXp = unassignedXp,
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
@ -147,6 +154,8 @@ public sealed class CharacterSheetProvider
Skills = BuildLiveCharacterSkills(),
BurdenCurrent = props.GetInt(5u),
BurdenMax = props.GetInt(96u),
EncumbranceAugmentations = props.GetInt(0xE6u),
CharacterInfoProperties = new Dictionary<uint, int>(props.Ints),
};
}

View file

@ -43,6 +43,23 @@ public sealed class DatStringResolver
return entry.Strings[index].Value;
}
/// <summary>Returns every literal token for one retail StringInfo entry.</summary>
public string[]? ResolveAll(uint tableId, uint stringId)
{
if (tableId == 0u || stringId == 0u)
return null;
if (!_tables.TryGetValue(tableId, out StringTable? table))
{
table = _dats.Get<StringTable>(tableId);
_tables[tableId] = table;
}
return table is not null
&& table.Strings.TryGetValue(stringId, out var entry)
&& entry.Strings.Count != 0
? entry.Strings.Select(value => value.Value).ToArray()
: null;
}
/// <summary>
/// Exact retail ELF-style string hash used for StringInfo keys.
/// Ported line-for-line from <c>compute_str_hash @ 0x00413110</c>.

View file

@ -106,6 +106,9 @@ public sealed class IndicatorBarController : IRetainedPanelController
_lastLinkFlash = now;
_link.TrySetRetailState(ConnectionGoodState);
_miniGame.TrySetRetailState(UiButtonStateMachine.Ghosted);
// Ordinary button 0x100000FA has no hover state: retail begins in its
// authored Normal state (0x06007BB1) before any pointer notice.
_endCharacterSession.TrySetRetailState(UiButtonStateMachine.Normal);
UpdateEnchantments();
UpdateBurden();
}

View file

@ -61,6 +61,7 @@ public static class RetailWindowFrame
public bool ConstrainResizeToParent { get; init; }
public float Opacity { get; init; } = 1f;
public bool DrawChromeCenter { get; init; } = true;
public bool Visible { get; init; } = true;
public AnchorEdges ContentAnchors { get; init; } =
@ -99,6 +100,9 @@ public static class RetailWindowFrame
_ => throw new ArgumentOutOfRangeException(nameof(options.Chrome)),
};
if (outerFrame is UiNineSlicePanel nineSlice)
nineSlice.DrawCenterFill = options.DrawChromeCenter;
const int border = RetailChromeSprites.Border;
content.Left = border;
content.Top = border;

View file

@ -105,6 +105,9 @@ public sealed class VitaeUiController : IRetainedPanelController
body = _strings.LostPrefix
+ lostPercent
+ _strings.LostSuffix
+ _strings.SkillsPrefix
+ lostPercent
+ _strings.SkillsSuffix
+ _strings.RecoveryPrefix
+ remaining
+ _strings.RecoverySuffix;
@ -146,13 +149,17 @@ public sealed record VitaeStrings(
string FullStrength,
string LostPrefix,
string LostSuffix,
string SkillsPrefix,
string SkillsSuffix,
string RecoveryPrefix,
string RecoverySuffix)
{
public static VitaeStrings English { get; } = new(
"Your Vitae, or life force, is at full strength.",
"Due to your recent death, you have temporarily lost ",
" % of your Vitae, or life force.",
"% of your Vitae, or life force.",
"\n\nThis means that your health, stamina, mana, and skills are temporarily reduced by ",
"%. A reduction of less than 15% will not hinder you much, but beware losing much more than that.",
"\n\nYou will regain 1% of your Vitae once you earn ",
" more experience.");
}

View file

@ -919,6 +919,7 @@ public sealed class RetailUiRuntime : IDisposable
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
ContentClickThrough = false,
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
Controller = controller,
});
_panelUi.RegisterMainPanel(
@ -943,12 +944,14 @@ public sealed class RetailUiRuntime : IDisposable
{
ElementInfo? rootInfo;
ImportedLayout? layout;
CharacterInfoStrings strings;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
CharacterController.LayoutId,
CharacterController.RootId);
var resolver = new DatStringResolver(_bindings.Assets.Dats);
layout = rootInfo is null
? null
: LayoutImporter.Build(
@ -956,7 +959,9 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
new DatStringResolver(_bindings.Assets.Dats).Resolve);
resolver.Resolve);
strings = CharacterInfoStrings.FromDat(key => resolver.ResolveAll(
0x23000001u, DatStringResolver.ComputeHash(key)));
}
if (rootInfo is null || layout is null) return;
@ -964,7 +969,8 @@ public sealed class RetailUiRuntime : IDisposable
layout,
_bindings.Character.Provider.BuildSheet,
_bindings.Assets.DefaultFont,
() => CloseWindow(WindowNames.CharacterInformation));
() => CloseWindow(WindowNames.CharacterInformation),
strings);
RegisterIndicatorDetailPanel(
RetailPanelCatalog.CharacterInformation,
WindowNames.CharacterInformation,
@ -1042,11 +1048,19 @@ public sealed class RetailUiRuntime : IDisposable
resolver.Resolve);
VitaeStrings fallback = VitaeStrings.English;
strings = new VitaeStrings(
resolver.Resolve(0x23000001u, 0x0142DDECu) ?? fallback.FullStrength,
resolver.Resolve(0x23000001u, 0x045D7665u, 0) ?? fallback.LostPrefix,
resolver.Resolve(0x23000001u, 0x045D7665u, 1) ?? fallback.LostSuffix,
resolver.Resolve(0x23000001u, 0x0DCD8C35u, 0) ?? fallback.RecoveryPrefix,
resolver.Resolve(0x23000001u, 0x0DCD8C35u, 1) ?? fallback.RecoverySuffix);
Resolve("ID_Vitae_Text_Full", 0, fallback.FullStrength),
Resolve("ID_Vitae_Text_Vitae", 0, fallback.LostPrefix),
Resolve("ID_Vitae_Text_Vitae", 1, fallback.LostSuffix),
Resolve("ID_Vitae_Text_Skills", 0, fallback.SkillsPrefix),
Resolve("ID_Vitae_Text_Skills", 1, fallback.SkillsSuffix),
Resolve("ID_Vitae_Text_Experience", 0, fallback.RecoveryPrefix),
Resolve("ID_Vitae_Text_Experience", 1, fallback.RecoverySuffix));
string Resolve(string key, int token, string fallbackValue)
=> resolver.Resolve(
0x23000001u,
DatStringResolver.ComputeHash(key),
token) ?? fallbackValue;
}
if (rootInfo is null || layout is null) return;
@ -1127,6 +1141,7 @@ public sealed class RetailUiRuntime : IDisposable
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
ContentClickThrough = false,
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
Controller = controller,
});
_panelUi.RegisterMainPanel(
@ -1139,6 +1154,28 @@ public sealed class RetailUiRuntime : IDisposable
&& restorePrevious);
}
/// <summary>
/// Retail's Vitae and Character Information children carry their own
/// 0x06004CC2 body surface below the 25px title row. The standalone shared
/// window contributes the border only in that case; painting the same
/// translucent center twice makes the panel look opaque.
/// </summary>
internal static bool AuthorsFullPanelCenter(ElementInfo rootInfo)
{
const float RetailTitleHeight = 25f;
return Visit(rootInfo);
bool Visit(ElementInfo info)
{
bool coversBody = info.Width >= rootInfo.Width
&& info.Height >= rootInfo.Height - RetailTitleHeight;
if (coversBody && info.StateMedia.Values.Any(
media => media.File == RetailChromeSprites.CenterFill))
return true;
return info.Children.Any(Visit);
}
}
private void MountIndicators()
{
ImportedLayout? layout = Import(IndicatorBarController.LayoutId);

View file

@ -22,6 +22,13 @@ public class UiNineSlicePanel : UiPanel
private readonly System.Func<uint, (uint tex, int w, int h)> _resolve;
/// <summary>
/// Whether this wrapper paints the shared center surface. Some retail child
/// panels already author that same full-height surface; their standalone
/// wrapper supplies only the border so translucency is not compounded.
/// </summary>
public bool DrawCenterFill { get; set; } = true;
public UiNineSlicePanel(System.Func<uint, (uint, int, int)> resolve)
{
_resolve = resolve;
@ -66,7 +73,8 @@ public class UiNineSlicePanel : UiPanel
// and draws in OnDrawAfterChildren (over the content's edges) so content can
// never poke through the frame (e.g. the toolbar's 2px bottom-right cap overhang).
var r = ComputeFrameRects(Width, Height, RetailChromeSprites.Border);
DrawTiled(ctx, RetailChromeSprites.CenterFill, r.Center);
if (DrawCenterFill)
DrawTiled(ctx, RetailChromeSprites.CenterFill, r.Center);
}
protected override void OnDrawAfterChildren(UiRenderContext ctx)

View file

@ -389,6 +389,22 @@ public static class BurdenMath
public static float LoadRatio(int capacity, int burden)
=> capacity <= 0 ? 0f : (float)burden / capacity;
/// <summary>
/// Retail <c>EncumbranceSystem::LoadMod @ 0x004FCC70</c>: full
/// effectiveness through 100% load, a linear falloff to zero at 200%,
/// then zero above it.
/// </summary>
public static float LoadModifier(float load)
=> load <= 1f ? 1f : load < 2f ? 2f - load : 0f;
/// <summary>
/// Percentage printed by <c>gmCharacterInfoUI::UpdateLoad @ 0x004B8A20</c>.
/// Retail truncates <c>LoadMod * 10</c> before converting the ten steps to
/// a percentage.
/// </summary>
public static int LoadPenaltyPercent(float load)
=> (10 - (int)(LoadModifier(load) * 10f)) * 10;
/// <summary>
/// Retail <c>gmBackpackUI::SetLoadLevel</c> bar fill (decomp 176542,
/// <c>0x004a6ea6</c>): <c>clamp(load * 0.3333…, 0, 1)</c> — the bar is 1/3 full at

View file

@ -4,16 +4,10 @@ using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit tests for <see cref="CharacterController"/> and <see cref="SampleData.SampleCharacter"/>.
/// Production Character Information data authors m_pMainText (0x1000011d);
/// reduced unit-test layouts exercise the controller's fallback element.
/// No GL — pure data-wiring + report-content tests.
/// </summary>
public class CharacterControllerTests
public sealed class CharacterControllerTests
{
[Fact]
public void AuthoredCharacterInformationPanel_BindsTextScrollbarAndClose()
public void AuthoredPanel_BindsTextScrollbarAndClose()
{
ImportedLayout layout = FixtureLoader.LoadCharacterInformation();
int closes = 0;
@ -21,15 +15,13 @@ public class CharacterControllerTests
CharacterController.Bind(
layout, SampleData.SampleCharacter, close: () => closes++);
Assert.Equal(CharacterController.RootId, layout.Root.DatElementId);
Assert.Equal((300f, 362f), (layout.Root.Width, layout.Root.Height));
UiText text = Assert.IsType<UiText>(
layout.FindElement(CharacterController.MainTextId));
UiScrollbar scrollbar = Assert.IsType<UiScrollbar>(
layout.FindElement(CharacterController.ScrollbarId));
Assert.Same(text.Scroll, scrollbar.Model);
Assert.False(text.PreserveEndOnLayout);
Assert.Contains("Studio Player", text.LinesProvider().Select(line => line.Text));
Assert.Contains("You were born on", VisibleText(text));
UiButton close = Assert.IsType<UiButton>(
layout.FindElement(CharacterController.CloseId));
@ -37,136 +29,76 @@ public class CharacterControllerTests
Assert.Equal(1, closes);
}
/// <summary>Bind on an empty layout, then return the created m_pMainText element.</summary>
private static UiText BindAndGetText(System.Func<CharacterSheet> data)
[Fact]
public void Report_UsesRetailContentInsteadOfInventedCharacterSheetSummary()
{
var layout = FakeLayout();
CharacterController.Bind(layout, data);
return layout.Root.Children.OfType<UiText>()
.First(t => t.EventId == CharacterController.MainTextId);
string report = Report(SampleData.SampleCharacter());
Assert.DoesNotContain("Studio Player", report);
Assert.DoesNotContain("Level 126", report);
Assert.DoesNotContain("Health:", report);
Assert.DoesNotContain("Augmentations", report);
Assert.DoesNotContain("Encumbrance", report);
Assert.Contains("You have died 42 times.", report);
Assert.Contains("Natural Resistances:", report);
Assert.Contains("Drain Resistances:", report);
Assert.Contains("Regeneration Bonus:", report);
Assert.Contains("Innate Strength: 200", report);
Assert.Contains("Innate Coordination: 10", report);
Assert.Contains("Chess Rank: 12", report);
Assert.Contains("Fishing Skill: 4", report);
Assert.Contains("Your melee mastery is Swords.", report);
Assert.Contains("You are not overburdened at this time.", report);
}
private static string Report(System.Func<CharacterSheet> data)
=> string.Join("\n", BindAndGetText(data).LinesProvider().Select(l => l.Text));
[Theory]
[InlineData(200, "None")]
[InlineData(201, "Poor")]
[InlineData(260, "Poor")]
[InlineData(261, "Mediocre")]
[InlineData(321, "Hardy")]
[InlineData(381, "Resilient")]
[InlineData(441, "Indomitable")]
public void ResistanceGrade_UsesRetailInclusiveBoundaries(
int value, string expected)
=> Assert.Equal(expected, CharacterController.ResistanceGrade(
value, 200, 260, 320, 380, 440));
// ── Test 1: Bind creates m_pMainText with a non-empty report ──────────────
[Theory]
[InlineData(0, "")]
[InlineData(1, "1 second")]
[InlineData(61, "1 minute 1 second")]
[InlineData(3_196_800, "1 month 1 week")]
[InlineData(31_536_000, "1 year")]
public void Duration_UsesRetailUnitDecomposition(int seconds, string expected)
=> Assert.Equal(expected, CharacterController.FormatRetailDuration(seconds));
[Fact]
public void Bind_CreatesMainTextWithReport()
public void DeathCount_UsesRetailSpecialCases()
{
var text = BindAndGetText(SampleData.SampleCharacter);
Assert.True(text.LinesProvider().Count > 0, "Expected non-empty report after Bind");
Assert.Contains("never died", Report(new CharacterSheet { Deaths = 0 }));
Assert.Contains("only once", Report(new CharacterSheet { Deaths = 1 }));
Assert.Contains("died twice", Report(new CharacterSheet { Deaths = 2 }));
Assert.Contains("died 3 times", Report(new CharacterSheet { Deaths = 3 }));
}
// ── Test 2: Report contains all six attribute names (decomp order 1,2,4,3,5,6) ───
[Fact]
public void Bind_ReportContainsAllSixAttributes()
public void EmptyLayout_GetsFallbackMainText()
{
var allText = Report(SampleData.SampleCharacter);
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 allText = Report(SampleData.SampleCharacter);
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 allText = Report(SampleData.SampleCharacter);
Assert.Contains("Burden", allText);
Assert.Contains("Capacity", allText);
}
// ── Test 5: Report contains the sample character's name ──────────────────
[Fact]
public void Bind_ReportContainsSampleName()
=> Assert.Contains("Studio Player", Report(SampleData.SampleCharacter));
// ── Test 6: Bind on a layout WITHOUT the element creates it (no throw) ────
[Fact]
public void Bind_OnEmptyLayout_CreatesElement()
{
var layout = FakeLayout(); // no MainTextId present
ImportedLayout layout = FakeLayout();
CharacterController.Bind(layout, SampleData.SampleCharacter);
Assert.Contains(layout.Root.Children.OfType<UiText>(),
t => t.EventId == CharacterController.MainTextId);
text => text.EventId == CharacterController.MainTextId);
}
// ── Test 7: The created element carries the m_pMainText id ───────────────
private static string Report(CharacterSheet sheet)
=> CharacterController.BuildReport(sheet, CharacterInfoStrings.English);
[Fact]
public void Bind_CreatedElementHasMainTextId()
=> Assert.Equal(CharacterController.MainTextId, BindAndGetText(SampleData.SampleCharacter).EventId);
private static string VisibleText(UiText text)
=> string.Join('\n', text.LinesProvider().Select(line => line.Text));
// ── 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 sheet = SampleData.SampleCharacter();
var allText = Report(() => sheet);
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);
}
private static ImportedLayout FakeLayout()
=> new(new UiPanel { Width = 300f, Height = 362f },
new Dictionary<uint, UiElement>());
}

View file

@ -61,6 +61,7 @@ public sealed class CharacterSheetProviderTests
player.Properties.Ints[0x19u] = 1; // level
player.Properties.Int64s[1u] = 150L; // total XP — mid 100..250 band
player.Properties.Int64s[2u] = unassignedXp;
player.Properties.Ints[0x18u] = 4; // available skill credits
Table.AddOrUpdate(player);
return player;
}
@ -94,6 +95,7 @@ public sealed class CharacterSheetProviderTests
Assert.Equal(100L, sheet.XpToNextLevel);
Assert.Equal(1f / 3f, sheet.XpFraction, precision: 4);
Assert.Equal(11, sheet.Strength); // ranks + start
Assert.Equal(4, sheet.SkillCredits);
// Raise x1: Attributes[2] xpSpent = 30 10; x10 clamps at curve end: 100 10.
Assert.Equal(20L, sheet.AttributeRaiseCosts[0]);
Assert.Equal(90L, sheet.AttributeRaise10Costs[0]);
@ -173,11 +175,11 @@ public sealed class CharacterSheetProviderTests
}
[Fact]
public void HandleRaiseRequest_TrainSkill_DebitsFirstPresentCreditProperty()
public void HandleRaiseRequest_TrainSkill_DebitsRetailSkillCreditProperty()
{
var h = new Harness();
var player = h.AddPlayerObject();
player.Properties.Ints[0xC0u] = 4; // only the second id in the 0x18→0xC0→0xB5 chain
player.Properties.Ints[0x18u] = 4;
h.Player.OnSkillUpdate(skillId: 6u, ranks: 0u, status: 1u, xp: 0u,
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // untrained
@ -186,7 +188,7 @@ public sealed class CharacterSheetProviderTests
Assert.Equal((6u, 4u), h.SentTrain);
Assert.Equal(2u, h.Player.GetSkill(6u)!.Value.Status); // promoted to trained
Assert.Equal(0, player.Properties.GetInt(0xC0u)); // credits debited
Assert.Equal(0, player.Properties.GetInt(0x18u)); // credits debited
}
[Fact]

View file

@ -164,6 +164,8 @@ public sealed class IndicatorBarControllerTests
UiButton endSession = h.Button(IndicatorBarController.EndCharacterSessionButtonId);
Assert.False(miniGame.Enabled);
Assert.True(endSession.Enabled);
Assert.Equal(UiButtonStateMachine.Normal, endSession.ActiveRetailStateId);
controller.SetMiniGameActive(true);
Assert.True(miniGame.Enabled);
miniGame.OnEvent(new UiEvent(0, miniGame, UiEventType.Click));

View file

@ -10,6 +10,17 @@ public sealed class IndicatorDetailPanelControllerTests
{
private const uint Player = 0x50000001u;
[Fact]
public void SharedPanelCenter_IsNotPaintedTwiceForAuthoredDetailBodies()
{
Assert.True(RetailUiRuntime.AuthorsFullPanelCenter(
FixtureLoader.LoadVitaeInfos()));
Assert.True(RetailUiRuntime.AuthorsFullPanelCenter(
FixtureLoader.LoadCharacterInformationInfos()));
Assert.False(RetailUiRuntime.AuthorsFullPanelCenter(
FixtureLoader.LoadPositiveEffectsInfos()));
}
[Fact]
public void AuthoredFixtures_AreRetailMainPanelPages()
{
@ -113,7 +124,11 @@ public sealed class IndicatorDetailPanelControllerTests
Bucket: 4u));
string text = VisibleText(layout, VitaeUiController.MainTextId);
Assert.Contains("lost 10 %", text, StringComparison.Ordinal);
Assert.Contains("lost 10%", text, StringComparison.Ordinal);
Assert.Contains(
"health, stamina, mana, and skills are temporarily reduced by 10%",
text,
StringComparison.Ordinal);
Assert.Contains("earn 379 more experience", text, StringComparison.Ordinal);
Assert.Equal(479, VitaeUiController.VitaeCpPoolThreshold(0.9f, 10));
}

View file

@ -76,6 +76,24 @@ public sealed class RetailWindowFrameTests
Assert.Equal(0.75f, frame.Opacity);
}
[Fact]
public void NineSlice_CanSupplyBorderWithoutDuplicatingAuthoredCenter()
{
var root = new UiRoot { Width = 800, Height = 600 };
var content = new UiPanel { Width = 300, Height = 362 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root, content, NoTex,
new RetailWindowFrame.Options
{
WindowName = "character-info",
DrawChromeCenter = false,
});
var frame = Assert.IsType<UiNineSlicePanel>(handle.OuterFrame);
Assert.False(frame.DrawCenterFill);
}
[Fact]
public void CollapsibleMount_ReturnsToolbarFrameAndIndependentAxes()
{

View file

@ -20,6 +20,20 @@ public class BurdenMathTests
public void LoadRatio_is_burden_over_capacity(int cap, int burden, float expected)
=> Assert.Equal(expected, BurdenMath.LoadRatio(cap, burden), 4);
[Theory]
[InlineData(0.5f, 1f, 0)]
[InlineData(1f, 1f, 0)]
[InlineData(1.25f, 0.75f, 30)]
[InlineData(1.5f, 0.5f, 50)]
[InlineData(2f, 0f, 100)]
[InlineData(3f, 0f, 100)]
public void LoadModifier_and_character_info_penalty_match_retail(
float load, float modifier, int penalty)
{
Assert.Equal(modifier, BurdenMath.LoadModifier(load), 4);
Assert.Equal(penalty, BurdenMath.LoadPenaltyPercent(load));
}
[Theory]
[InlineData(0f, 0f)]
[InlineData(0.5f, 0.16667f)]