From 16c21e299c530cca89c36e6425db8dd1bae67a4a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 17 Jul 2026 12:06:14 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 9 + docs/plans/2026-05-12-milestones.md | 4 + ...26-06-25-character-window-faithful-spec.md | 11 +- ...-retail-vitae-character-info-pseudocode.md | 128 +++++ src/AcDream.App/Studio/SampleData.cs | 7 +- .../UI/Layout/CharacterController.cs | 513 +++++++++++++----- src/AcDream.App/UI/Layout/CharacterSheet.cs | 24 +- .../UI/Layout/CharacterSheetProvider.cs | 25 +- .../UI/Layout/DatStringResolver.cs | 17 + .../UI/Layout/IndicatorBarController.cs | 3 + .../UI/Layout/RetailWindowFrame.cs | 4 + .../UI/Layout/VitaeUiController.cs | 9 +- src/AcDream.App/UI/RetailUiRuntime.cs | 51 +- src/AcDream.App/UI/UiNineSlicePanel.cs | 10 +- src/AcDream.Core/Items/ClientObject.cs | 16 + .../UI/Layout/CharacterControllerTests.cs | 184 ++----- .../UI/Layout/CharacterSheetProviderTests.cs | 8 +- .../UI/Layout/IndicatorBarControllerTests.cs | 2 + .../IndicatorDetailPanelControllerTests.cs | 17 +- .../UI/Layout/RetailWindowFrameTests.cs | 18 + .../Items/BurdenMathTests.cs | 14 + 21 files changed, 771 insertions(+), 303 deletions(-) create mode 100644 docs/research/2026-07-17-retail-vitae-character-info-pseudocode.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index d255eb9b..58d15625 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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; diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 32a144cd..698b3a3e 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -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, diff --git a/docs/research/2026-06-25-character-window-faithful-spec.md b/docs/research/2026-06-25-character-window-faithful-spec.md index 9f19e56a..feb7894b 100644 --- a/docs/research/2026-06-25-character-window-faithful-spec.md +++ b/docs/research/2026-06-25-character-window-faithful-spec.md @@ -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) diff --git a/docs/research/2026-07-17-retail-vitae-character-info-pseudocode.md b/docs/research/2026-07-17-retail-vitae-character-info-pseudocode.md new file mode 100644 index 00000000..c340b78a --- /dev/null +++ b/docs/research/2026-07-17-retail-vitae-character-info-pseudocode.md @@ -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. diff --git a/src/AcDream.App/Studio/SampleData.cs b/src/AcDream.App/Studio/SampleData.cs index f70c468c..e523fc8e 100644 --- a/src/AcDream.App/Studio/SampleData.cs +++ b/src/AcDream.App/Studio/SampleData.cs @@ -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 + { + [0x162u] = 2, // Swords melee mastery + }, BurdenCurrent = 1200, BurdenMax = 4500, diff --git a/src/AcDream.App/UI/Layout/CharacterController.cs b/src/AcDream.App/UI/Layout/CharacterController.cs index b63b2708..10bf522a 100644 --- a/src/AcDream.App/UI/Layout/CharacterController.cs +++ b/src/AcDream.App/UI/Layout/CharacterController.cs @@ -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; /// -/// Per-window controller for the Character Information child in the production -/// gmPanelUI layout (LayoutDesc 0x2100006E, root 0x10000183). -/// -/// 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. +/// Retained port of retail gmCharacterInfoUI. Retail writes one +/// localized report into m_pMainText; it is not a second character +/// sheet and therefore does not repeat name, level, title, or current vitals. /// public static class CharacterController { public const uint LayoutId = 0x2100006Eu; public const uint RootId = 0x10000183u; - - /// Dat element id for the main report text (m_pMainText). Confirmed in - /// gmCharacterInfoUI::PostInit 0x004b86f0: GetChildRecursive(this, 0x1000011d). - public const uint MainTextId = 0x1000011du; + public const uint MainTextId = 0x1000011Du; public const uint ScrollbarId = 0x1000011Eu; public const uint CloseId = 0x100000FCu; - /// White body text — matches retail's default UIElement_Text foreground. - 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), + ]; - /// Yellow section header — retail uses a different StringInfo color per section header. - private static readonly Vector4 HeaderColor = new(1f, 0.85f, 0.35f, 1f); + internal static IReadOnlyList AugmentationStringKeys + => Augmentations.Select(descriptor => descriptor.StringKey).ToArray(); - /// - /// 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. - /// - /// Production data resolves the element as a - /// (Type 12). A synthetic fallback is created only for deliberately reduced - /// test layouts. - /// - /// Imported 0x2100006E / 0x10000183 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, - 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 ──────────────────────────────────────────────────────── - - /// - /// Build the complete character report as a line list. - /// Order mirrors gmCharacterInfoUI::Update (0x004ba790). - /// - private static IReadOnlyList BuildReport(CharacterSheet s) + internal static string BuildReport(CharacterSheet sheet, CharacterInfoStrings strings) { - var lines = new List(); + 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 lines, string text) - => lines.Add(new UiText.Line(text, HeaderColor)); + private static void AppendMastery( + StringBuilder body, + string[] tokens, + int value, + Func name) + { + if (value > 0) + AppendTokens(body, tokens, name(value)); + } - private static void Append(List 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 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(); + 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 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 AugmentationText) +{ + public static CharacterInfoStrings FromDat( + Func 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(); + 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()); } diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index 9687f8c5..2da63597 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace AcDream.App.UI.Layout; @@ -70,6 +71,12 @@ public sealed class CharacterSheet /// Total deaths (retail InqInt(0x2b) = NumDeaths). public int Deaths { get; init; } + /// Raw retail PropertyInt 0x62. Null means the quality was absent. + public int? BirthTimestamp { get; init; } + + /// Raw retail PropertyInt 0x7D seconds. Null means the quality was absent. + 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; } + /// Retail Character Information PropertyInt 0xB5. + public int ChessRank { get; init; } + + /// Retail Character Information PropertyInt 0xC0. + public int FishingSkill { get; init; } + /// /// 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". public string? AugmentationName { get; init; } + /// + /// Raw PropertyInt snapshot used by retail's data-driven mastery, + /// luminance, and augmentation report. The controller reads only the + /// quality ids cited by gmCharacterInfoUI::UpdateAugmentations. + /// + public IReadOnlyDictionary CharacterInfoProperties { get; init; } + = new Dictionary(); + // ── 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 diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index 4283189f..2c986023 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -31,12 +31,10 @@ public sealed class CharacterSheetProvider private const uint UnassignedXpPropertyId = 2u; /// - /// 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. /// - 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(props.Ints), }; } diff --git a/src/AcDream.App/UI/Layout/DatStringResolver.cs b/src/AcDream.App/UI/Layout/DatStringResolver.cs index a14c0775..8a12d321 100644 --- a/src/AcDream.App/UI/Layout/DatStringResolver.cs +++ b/src/AcDream.App/UI/Layout/DatStringResolver.cs @@ -43,6 +43,23 @@ public sealed class DatStringResolver return entry.Strings[index].Value; } + /// Returns every literal token for one retail StringInfo entry. + public string[]? ResolveAll(uint tableId, uint stringId) + { + if (tableId == 0u || stringId == 0u) + return null; + if (!_tables.TryGetValue(tableId, out StringTable? table)) + { + table = _dats.Get(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; + } + /// /// Exact retail ELF-style string hash used for StringInfo keys. /// Ported line-for-line from compute_str_hash @ 0x00413110. diff --git a/src/AcDream.App/UI/Layout/IndicatorBarController.cs b/src/AcDream.App/UI/Layout/IndicatorBarController.cs index 1cf19182..60e6b741 100644 --- a/src/AcDream.App/UI/Layout/IndicatorBarController.cs +++ b/src/AcDream.App/UI/Layout/IndicatorBarController.cs @@ -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(); } diff --git a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs index aabae4e0..15b40179 100644 --- a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs +++ b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs @@ -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; diff --git a/src/AcDream.App/UI/Layout/VitaeUiController.cs b/src/AcDream.App/UI/Layout/VitaeUiController.cs index e647848b..8dc2a8ca 100644 --- a/src/AcDream.App/UI/Layout/VitaeUiController.cs +++ b/src/AcDream.App/UI/Layout/VitaeUiController.cs @@ -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."); } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index e69ee869..5eede127 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -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); } + /// + /// 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. + /// + 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); diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs index 1c4c207b..a87ce024 100644 --- a/src/AcDream.App/UI/UiNineSlicePanel.cs +++ b/src/AcDream.App/UI/UiNineSlicePanel.cs @@ -22,6 +22,13 @@ public class UiNineSlicePanel : UiPanel private readonly System.Func _resolve; + /// + /// 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. + /// + public bool DrawCenterFill { get; set; } = true; + public UiNineSlicePanel(System.Func 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) diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index 6247696a..7cef7b3f 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -389,6 +389,22 @@ public static class BurdenMath public static float LoadRatio(int capacity, int burden) => capacity <= 0 ? 0f : (float)burden / capacity; + /// + /// Retail EncumbranceSystem::LoadMod @ 0x004FCC70: full + /// effectiveness through 100% load, a linear falloff to zero at 200%, + /// then zero above it. + /// + public static float LoadModifier(float load) + => load <= 1f ? 1f : load < 2f ? 2f - load : 0f; + + /// + /// Percentage printed by gmCharacterInfoUI::UpdateLoad @ 0x004B8A20. + /// Retail truncates LoadMod * 10 before converting the ten steps to + /// a percentage. + /// + public static int LoadPenaltyPercent(float load) + => (10 - (int)(LoadModifier(load) * 10f)) * 10; + /// /// Retail gmBackpackUI::SetLoadLevel bar fill (decomp 176542, /// 0x004a6ea6): clamp(load * 0.3333…, 0, 1) — the bar is 1/3 full at diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs index c137c7dd..534093c8 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs @@ -4,16 +4,10 @@ using AcDream.App.UI.Layout; namespace AcDream.App.Tests.UI.Layout; -/// -/// Unit tests for and . -/// 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. -/// -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( layout.FindElement(CharacterController.MainTextId)); UiScrollbar scrollbar = Assert.IsType( 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( layout.FindElement(CharacterController.CloseId)); @@ -37,136 +29,76 @@ public class CharacterControllerTests Assert.Equal(1, closes); } - /// Bind on an empty layout, then return the created m_pMainText element. - private static UiText BindAndGetText(System.Func data) + [Fact] + public void Report_UsesRetailContentInsteadOfInventedCharacterSheetSummary() { - var layout = FakeLayout(); - CharacterController.Bind(layout, data); - return layout.Root.Children.OfType() - .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 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(), - 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(); - 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()); } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs index e9daf7c1..b2e00561 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs @@ -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] diff --git a/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs index 810e85c9..8e5c01f9 100644 --- a/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs @@ -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)); diff --git a/tests/AcDream.App.Tests/UI/Layout/IndicatorDetailPanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/IndicatorDetailPanelControllerTests.cs index 424bc5f7..7f7aa848 100644 --- a/tests/AcDream.App.Tests/UI/Layout/IndicatorDetailPanelControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/IndicatorDetailPanelControllerTests.cs @@ -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)); } diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs index ee204abd..c48167aa 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs @@ -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(handle.OuterFrame); + Assert.False(frame.DrawCenterFill); + } + [Fact] public void CollapsibleMount_ReturnsToolbarFrameAndIndependentAxes() { diff --git a/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs b/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs index 7d9bcec7..522b12ee 100644 --- a/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs +++ b/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs @@ -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)]