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)]