fix(ui): match retail vitae and character info
Restore Vitae's omitted penalty paragraph, replace the invented character summary with gmCharacterInfoUI's ordered report and property meanings, preserve authored translucent body surfaces, and initialize the end-session button in its visible Normal DAT state. Release build and all 5,830 tests pass with five intentional skips. Connected visual gate pending. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
d1d603105f
commit
16c21e299c
21 changed files with 771 additions and 303 deletions
|
|
@ -72,12 +72,21 @@ Link ping RTT and Vitae
|
||||||
recovery XP are live; transport packet-loss averaging and Mini Game gameplay
|
recovery XP are live; transport packet-loss averaging and Mini Game gameplay
|
||||||
remain the explicitly narrowed AP-110 residual.
|
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`;
|
**Files:** `src/AcDream.App/UI/Layout/IndicatorBarController.cs`;
|
||||||
`src/AcDream.App/UI/Layout/DatWidgetFactory.cs`;
|
`src/AcDream.App/UI/Layout/DatWidgetFactory.cs`;
|
||||||
`src/AcDream.Core.Net/WorldSession.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`.
|
`src/AcDream.Core.Net/WorldSession.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`.
|
||||||
|
|
||||||
**Research:**
|
**Research:**
|
||||||
`docs/research/2026-07-17-retail-indicator-bar-pseudocode.md`.
|
`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
|
**Acceptance:** All seven authored controls render their retail sprites. Link
|
||||||
quality follows the 5/20/40-second thresholds and bad-link flash cadence;
|
quality follows the 5/20/40-second thresholds and bad-link flash cadence;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
information presentation. The burden icon sends panel 3 (Character Information),
|
||||||
not panel 11 (Attributes/Skills). Link uses retail's payload-free ping/RTT
|
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.
|
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:
|
The first connected gate exposed and corrected two retained-import defects:
|
||||||
spellbook tabs are authored stateful text controls (not buttons), and retail
|
spellbook tabs are authored stateful text controls (not buttons), and retail
|
||||||
text starts with zero margins. Real-DAT fixtures now pin spellbook binding,
|
text starts with zero margins. Real-DAT fixtures now pin spellbook binding,
|
||||||
|
|
|
||||||
|
|
@ -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
|
`UpdatePKStatus` 0x004f00a0). The tab sub-layout `0x2100002C` nests `0x10000226` which
|
||||||
chains again into `0x21000045` — the real header/list content is **two inheritance levels deep**.
|
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
|
The **text report** ("birth/playtime/deaths, resist grades, innate attributes,
|
||||||
pilot guessed is a SEPARATE sub-panel: LayoutDesc `0x2100001A`, `gmCharacterInfoUI`
|
chess/fishing, earned augmentations, load") is a SEPARATE child panel:
|
||||||
(register 0x004b8c90), whose `m_pMainText` (`0x1000011d`) is created at RUNTIME. That window
|
`gmCharacterInfoUI` (register `0x004b8c90`), root `0x10000183` under the
|
||||||
is handled by `CharacterController` (create-the-element path) — different layout, not 0x2100002E.
|
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)
|
## Importer reality (dumped live, 2026-06-25)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -160,6 +160,8 @@ public static class SampleData
|
||||||
|
|
||||||
UnspentSkillCredits = 12,
|
UnspentSkillCredits = 12,
|
||||||
SpecializedSkillCredits = 4,
|
SpecializedSkillCredits = 4,
|
||||||
|
ChessRank = 12,
|
||||||
|
FishingSkill = 4,
|
||||||
|
|
||||||
// Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A.
|
// Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A.
|
||||||
SkillCredits = 96,
|
SkillCredits = 96,
|
||||||
|
|
@ -196,7 +198,10 @@ public static class SampleData
|
||||||
new(37, "Fletching", 0x06001A55u, CharacterSkillAdvancementClass.Untrained, 10, 10, false, 4, 8, 0L),
|
new(37, "Fletching", 0x06001A55u, CharacterSkillAdvancementClass.Untrained, 10, 10, false, 4, 8, 0L),
|
||||||
},
|
},
|
||||||
|
|
||||||
AugmentationName = "Swords",
|
CharacterInfoProperties = new Dictionary<uint, int>
|
||||||
|
{
|
||||||
|
[0x162u] = 2, // Swords melee mastery
|
||||||
|
},
|
||||||
|
|
||||||
BurdenCurrent = 1200,
|
BurdenCurrent = 1200,
|
||||||
BurdenMax = 4500,
|
BurdenMax = 4500,
|
||||||
|
|
|
||||||
|
|
@ -1,197 +1,410 @@
|
||||||
using System;
|
using System.Globalization;
|
||||||
using System.Collections.Generic;
|
using System.Text;
|
||||||
using System.Numerics;
|
using AcDream.Core.Items;
|
||||||
using AcDream.App.UI;
|
|
||||||
|
|
||||||
namespace AcDream.App.UI.Layout;
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-window controller for the Character Information child in the production
|
/// Retained port of retail <c>gmCharacterInfoUI</c>. Retail writes one
|
||||||
/// gmPanelUI layout (LayoutDesc 0x2100006E, root 0x10000183).
|
/// localized report into <c>m_pMainText</c>; it is not a second character
|
||||||
///
|
/// sheet and therefore does not repeat name, level, title, or current vitals.
|
||||||
/// <para>Retail fills a single scrollable <c>UIElement_Text</c> element (id 0x1000011d,
|
|
||||||
/// <c>m_pMainText</c>) by calling a sequence of Update* methods that each
|
|
||||||
/// <c>AppendStringInfo</c> into the same element. This controller replicates that report
|
|
||||||
/// structure using acdream's <see cref="UiText.LinesProvider"/>.</para>
|
|
||||||
///
|
|
||||||
/// <para>Section order (ported from <c>gmCharacterInfoUI::Update</c> 0x004ba790):
|
|
||||||
/// <list type="number">
|
|
||||||
/// <item>Birth / age / deaths (<c>UpdatePlayerBirthAgeDeaths</c> 0x004b8cb0)</item>
|
|
||||||
/// <item>Vitals / endurance (<c>UpdateEnduranceInfo</c> 0x004b8eb0)</item>
|
|
||||||
/// <item>Innate attributes (<c>UpdateInnateAttributeInfo</c> 0x004b87e0):
|
|
||||||
/// InqAttribute 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self</item>
|
|
||||||
/// <item>Skills summary (<c>UpdateFakeSkills</c> 0x004b8930)</item>
|
|
||||||
/// <item>Augmentations (<c>UpdateAugmentations</c> 0x004b9000)</item>
|
|
||||||
/// <item>Burden / load (<c>UpdateLoad</c> 0x004b8a20)</item>
|
|
||||||
/// </list>
|
|
||||||
/// Each section is separated by a blank line (retail: <c>AppendStringInfo(m_pMainText, &var_120)</c>
|
|
||||||
/// with an empty StringInfo between sections).</para>
|
|
||||||
///
|
|
||||||
/// <para>StringInfo resolution is NOT ported — the full dat string-table lookup is a
|
|
||||||
/// later refinement. For this pilot, labels are composed directly from the well-known
|
|
||||||
/// AC attribute / stat names confirmed in the decomp string literals and data identifiers.</para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class CharacterController
|
public static class CharacterController
|
||||||
{
|
{
|
||||||
public const uint LayoutId = 0x2100006Eu;
|
public const uint LayoutId = 0x2100006Eu;
|
||||||
public const uint RootId = 0x10000183u;
|
public const uint RootId = 0x10000183u;
|
||||||
|
public const uint MainTextId = 0x1000011Du;
|
||||||
/// <summary>Dat element id for the main report text (m_pMainText). Confirmed in
|
|
||||||
/// <c>gmCharacterInfoUI::PostInit</c> 0x004b86f0: <c>GetChildRecursive(this, 0x1000011d)</c>.</summary>
|
|
||||||
public const uint MainTextId = 0x1000011du;
|
|
||||||
public const uint ScrollbarId = 0x1000011Eu;
|
public const uint ScrollbarId = 0x1000011Eu;
|
||||||
public const uint CloseId = 0x100000FCu;
|
public const uint CloseId = 0x100000FCu;
|
||||||
|
|
||||||
/// <summary>White body text — matches retail's default UIElement_Text foreground.</summary>
|
private static readonly AugmentationDescriptor[] Augmentations =
|
||||||
private static readonly Vector4 BodyColor = new(1f, 1f, 1f, 1f);
|
[
|
||||||
|
new(0xDAu, "ID_CharacterInfo_Augmentation_Attribute_Strength", true),
|
||||||
|
new(0xDBu, "ID_CharacterInfo_Augmentation_Attribute_Endurance", true),
|
||||||
|
new(0xDCu, "ID_CharacterInfo_Augmentation_Attribute_Coordination", true),
|
||||||
|
new(0xDDu, "ID_CharacterInfo_Augmentation_Attribute_Quickness", true),
|
||||||
|
new(0xDEu, "ID_CharacterInfo_Augmentation_Attribute_Focus", true),
|
||||||
|
new(0xDFu, "ID_CharacterInfo_Augmentation_Attribute_Self", true),
|
||||||
|
new(0xF0u, "ID_CharacterInfo_Augmentation_Resist_Slash", true),
|
||||||
|
new(0xF1u, "ID_CharacterInfo_Augmentation_Resist_Pierce", true),
|
||||||
|
new(0xF2u, "ID_CharacterInfo_Augmentation_Resist_Blunt", true),
|
||||||
|
new(0xF3u, "ID_CharacterInfo_Augmentation_Resist_Acid", true),
|
||||||
|
new(0x147u, "ID_CharacterInfo_Augmentation_Resist_Nether", true),
|
||||||
|
new(0xF4u, "ID_CharacterInfo_Augmentation_Resist_Fire", true),
|
||||||
|
new(0xF5u, "ID_CharacterInfo_Augmentation_Resist_Frost", true),
|
||||||
|
new(0xF6u, "ID_CharacterInfo_Augmentation_Resist_Lightning", true),
|
||||||
|
new(0xE0u, "ID_CharacterInfo_Augmentation_Spec_Gearcraft", false),
|
||||||
|
new(0xE1u, "ID_CharacterInfo_Augmentation_Spec_WeaponTinkering", false),
|
||||||
|
new(0xE2u, "ID_CharacterInfo_Augmentation_Spec_MagicItemTinkering", false),
|
||||||
|
new(0xE3u, "ID_CharacterInfo_Augmentation_Spec_ArmorTinkering", false),
|
||||||
|
new(0xE4u, "ID_CharacterInfo_Augmentation_Spec_ItemTinkering", false),
|
||||||
|
new(0x125u, "ID_CharacterInfo_Augmentation_Spec_Salvaging", false),
|
||||||
|
new(0xE5u, "ID_CharacterInfo_Augmentation_ExtraPackSlot", false),
|
||||||
|
new(0xE6u, "ID_CharacterInfo_Augmentation_IncreasedCarryingCapacity", true),
|
||||||
|
new(0xE7u, "ID_CharacterInfo_Augmentation_LessDeathItemLoss", true),
|
||||||
|
new(0xE8u, "ID_CharacterInfo_Augmentation_SpellsRemainPastDeath", false),
|
||||||
|
new(0xE9u, "ID_CharacterInfo_Augmentation_CriticalDefense", false),
|
||||||
|
new(0xEAu, "ID_CharacterInfo_Augmentation_BonusXP", false),
|
||||||
|
new(0xEBu, "ID_CharacterInfo_Augmentation_BonusSalvage", true),
|
||||||
|
new(0xECu, "ID_CharacterInfo_Augmentation_BonusImbueChance", false),
|
||||||
|
new(0xEDu, "ID_CharacterInfo_Augmentation_FasterRegen", true),
|
||||||
|
new(0xEEu, "ID_CharacterInfo_Augmentation_IncreasedSpellDuration", true),
|
||||||
|
new(0x126u, "ID_CharacterInfo_Augmentation_Infused_CreatureMagic", true),
|
||||||
|
new(0x127u, "ID_CharacterInfo_Augmentation_Infused_ItemMagic", true),
|
||||||
|
new(0x128u, "ID_CharacterInfo_Augmentation_Infused_LifeMagic", true),
|
||||||
|
new(0x129u, "ID_CharacterInfo_Augmentation_Infused_WarMagic", true),
|
||||||
|
new(0x148u, "ID_CharacterInfo_Augmentation_Infused_VoidMagic", true),
|
||||||
|
new(0x12Cu, "ID_CharacterInfo_Augmentation_SkilledMelee", true),
|
||||||
|
new(0x12Du, "ID_CharacterInfo_Augmentation_SkilledMissile", true),
|
||||||
|
new(0x12Eu, "ID_CharacterInfo_Augmentation_SkilledMagic", true),
|
||||||
|
new(0x135u, "ID_CharacterInfo_Augmentation_DamageBonus", true),
|
||||||
|
new(0x136u, "ID_CharacterInfo_Augmentation_DamageResist", true),
|
||||||
|
new(0x12Au, "ID_CharacterInfo_Augmentation_CriticalExpertise", false),
|
||||||
|
new(0x12Bu, "ID_CharacterInfo_Augmentation_CriticalPower", false),
|
||||||
|
new(0x146u, "ID_CharacterInfo_Augmentation_JackOfAllTrades", false),
|
||||||
|
];
|
||||||
|
|
||||||
/// <summary>Yellow section header — retail uses a different StringInfo color per section header.</summary>
|
internal static IReadOnlyList<string> AugmentationStringKeys
|
||||||
private static readonly Vector4 HeaderColor = new(1f, 0.85f, 0.35f, 1f);
|
=> Augmentations.Select(descriptor => descriptor.StringKey).ToArray();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bind the character report text element found in <paramref name="layout"/> to
|
|
||||||
/// <paramref name="data"/>. The report is regenerated each frame via
|
|
||||||
/// <see cref="UiText.LinesProvider"/> so the Studio or a live session can push a fresh
|
|
||||||
/// <see cref="CharacterSheet"/> without re-binding.
|
|
||||||
///
|
|
||||||
/// <para>Production data resolves the element as a <see cref="UiText"/>
|
|
||||||
/// (Type 12). A synthetic fallback is created only for deliberately reduced
|
|
||||||
/// test layouts.</para>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="layout">Imported 0x2100006E / 0x10000183 layout tree.</param>
|
|
||||||
/// <param name="data">Provider returning the current <see cref="CharacterSheet"/>.</param>
|
|
||||||
/// <param name="datFont">Retail dat font forwarded from the render stack. May be null
|
|
||||||
/// (falls back to the BitmapFont debug path).</param>
|
|
||||||
public static void Bind(
|
public static void Bind(
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
Func<CharacterSheet> data,
|
Func<CharacterSheet> data,
|
||||||
UiDatFont? datFont = null,
|
UiDatFont? datFont = null,
|
||||||
Action? close = null)
|
Action? close = null,
|
||||||
|
CharacterInfoStrings? strings = null)
|
||||||
{
|
{
|
||||||
// End-of-retail data authors m_pMainText under the Character Information
|
ArgumentNullException.ThrowIfNull(layout);
|
||||||
// child. The fallback exists only so focused controller tests can bind a
|
ArgumentNullException.ThrowIfNull(data);
|
||||||
// deliberately reduced tree without fabricating production structure.
|
UiElement root = layout.Root;
|
||||||
var root = layout.Root;
|
|
||||||
if (root is null) return;
|
|
||||||
|
|
||||||
UiText text = layout.FindElement(MainTextId) as UiText ?? new UiText
|
UiText text = layout.FindElement(MainTextId) as UiText ?? new UiText
|
||||||
{
|
{
|
||||||
EventId = MainTextId,
|
EventId = MainTextId,
|
||||||
Name = "m_pMainText",
|
Name = "m_pMainText",
|
||||||
Left = 12f,
|
Left = 12f,
|
||||||
Top = 44f, // below the window header row
|
Top = 44f,
|
||||||
Width = System.Math.Max(40f, root.Width - 24f),
|
Width = Math.Max(40f, root.Width - 24f),
|
||||||
Height = System.Math.Max(40f, root.Height - 56f),
|
Height = Math.Max(40f, root.Height - 56f),
|
||||||
Anchors = AnchorEdges.None,
|
ZOrder = 1_000_000,
|
||||||
ZOrder = 1_000_000, // draw above the static chrome
|
DatFont = datFont,
|
||||||
DatFont = datFont,
|
|
||||||
ClickThrough = false,
|
ClickThrough = false,
|
||||||
};
|
};
|
||||||
if (text.Parent is null)
|
if (text.Parent is null)
|
||||||
root.AddChild(text);
|
root.AddChild(text);
|
||||||
else if (text.DatFont is null && datFont is not null)
|
else if (text.DatFont is null && datFont is not null)
|
||||||
text.DatFont = datFont;
|
text.DatFont = datFont;
|
||||||
|
|
||||||
|
CharacterInfoStrings copy = strings ?? CharacterInfoStrings.English;
|
||||||
text.PreserveEndOnLayout = false;
|
text.PreserveEndOnLayout = false;
|
||||||
|
text.LinesProvider = () => IndicatorDetailText.Shape(text, BuildReport(data(), copy));
|
||||||
if (layout.FindElement(ScrollbarId) is UiScrollbar scrollbar)
|
if (layout.FindElement(ScrollbarId) is UiScrollbar scrollbar)
|
||||||
scrollbar.Model = text.Scroll;
|
scrollbar.Model = text.Scroll;
|
||||||
text.LinesProvider = () => BuildReport(data());
|
|
||||||
|
|
||||||
if (layout.FindElement(CloseId) is UiButton closeButton)
|
if (layout.FindElement(CloseId) is UiButton closeButton)
|
||||||
closeButton.OnClick = close;
|
closeButton.OnClick = close;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Report builder ────────────────────────────────────────────────────────
|
internal static string BuildReport(CharacterSheet sheet, CharacterInfoStrings strings)
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Build the complete character report as a line list.
|
|
||||||
/// Order mirrors <c>gmCharacterInfoUI::Update</c> (0x004ba790).
|
|
||||||
/// </summary>
|
|
||||||
private static IReadOnlyList<UiText.Line> BuildReport(CharacterSheet s)
|
|
||||||
{
|
{
|
||||||
var lines = new List<UiText.Line>();
|
var body = new StringBuilder();
|
||||||
|
|
||||||
// ── Section 1: Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ──
|
// gmCharacterInfoUI::UpdatePlayerBirthAgeDeaths @ 0x004B8CB0.
|
||||||
// Retail InqInt(0x62) = DateOfBirth (unix timestamp); InqInt(0x7d) = TotalPlayTime;
|
string? birth = sheet.BirthTimestamp is int timestamp
|
||||||
// InqInt(0x2b) = NumDeaths.
|
? FormatRetailDate(timestamp)
|
||||||
AppendHeader(lines, s.Name);
|
: sheet.BirthDate;
|
||||||
Append(lines, $"Level {s.Level}");
|
if (!string.IsNullOrEmpty(birth))
|
||||||
if (s.Race is not null)
|
AppendTokens(body, strings.Birth, birth);
|
||||||
Append(lines, s.Race);
|
|
||||||
if (s.Heritage is not null)
|
|
||||||
Append(lines, s.Heritage);
|
|
||||||
if (s.Title is not null)
|
|
||||||
Append(lines, s.Title);
|
|
||||||
AppendBlank(lines);
|
|
||||||
|
|
||||||
if (s.BirthDate is not null)
|
string? played = sheet.TotalPlayTimeSeconds is int seconds
|
||||||
Append(lines, $"Birth: {s.BirthDate}");
|
? FormatRetailDuration(seconds)
|
||||||
if (s.PlayTime is not null)
|
: sheet.PlayTime;
|
||||||
Append(lines, $"Age: {s.PlayTime}");
|
if (!string.IsNullOrEmpty(played))
|
||||||
Append(lines, $"Deaths: {s.Deaths}");
|
AppendTokens(body, strings.Played, played);
|
||||||
AppendBlank(lines);
|
|
||||||
|
|
||||||
// ── Section 2: Vitals / endurance (UpdateEnduranceInfo 0x004b8eb0) ──
|
switch (sheet.Deaths)
|
||||||
// Retail InqAttribute(1) = Strength base, InqAttribute(2) = Endurance base.
|
|
||||||
// Computes max-stamina tier and max-health tier from (Str+End) and (End+2*End) sums.
|
|
||||||
AppendHeader(lines, "Vitals");
|
|
||||||
Append(lines, $"Health: {s.HealthCurrent} / {s.HealthMax}");
|
|
||||||
Append(lines, $"Stamina: {s.StaminaCurrent} / {s.StaminaMax}");
|
|
||||||
Append(lines, $"Mana: {s.ManaCurrent} / {s.ManaMax}");
|
|
||||||
AppendBlank(lines);
|
|
||||||
|
|
||||||
// ── Section 3: Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ──
|
|
||||||
// InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination,
|
|
||||||
// Focus, Self (confirmed from the AddVariable_Int sequence in the decomp).
|
|
||||||
AppendHeader(lines, "Attributes");
|
|
||||||
Append(lines, $"Strength: {s.Strength}");
|
|
||||||
Append(lines, $"Endurance: {s.Endurance}");
|
|
||||||
Append(lines, $"Quickness: {s.Quickness}");
|
|
||||||
Append(lines, $"Coordination: {s.Coordination}");
|
|
||||||
Append(lines, $"Focus: {s.Focus}");
|
|
||||||
Append(lines, $"Self: {s.Self}");
|
|
||||||
AppendBlank(lines);
|
|
||||||
|
|
||||||
// ── Section 4: Skills summary (UpdateFakeSkills 0x004b8930) ──
|
|
||||||
// Retail InqInt(0xb5) = SkillCredits remaining; InqInt(0xc0) = AvailableSkillCredits.
|
|
||||||
AppendHeader(lines, "Skills");
|
|
||||||
Append(lines, $"Unspent skill credits: {s.UnspentSkillCredits}");
|
|
||||||
if (s.SpecializedSkillCredits > 0)
|
|
||||||
Append(lines, $"Specialized credits: {s.SpecializedSkillCredits}");
|
|
||||||
AppendBlank(lines);
|
|
||||||
|
|
||||||
// ── Section 5: Augmentations (UpdateAugmentations 0x004b9000) ──
|
|
||||||
// Retail InqInt(0x162) = AugmentationStat; string-switch on value 1..0xb + Unknown.
|
|
||||||
AppendHeader(lines, "Augmentations");
|
|
||||||
if (s.AugmentationName is not null)
|
|
||||||
Append(lines, s.AugmentationName);
|
|
||||||
else
|
|
||||||
Append(lines, "None");
|
|
||||||
AppendBlank(lines);
|
|
||||||
|
|
||||||
// ── Section 6: Burden / load (UpdateLoad 0x004b8a20) ──
|
|
||||||
// Retail InqLoad → EncumbranceCapacity(Strength, AugEncumbrance).
|
|
||||||
// When load >= 1.0 (overloaded): shows "X burden over capacity; Y% speed penalty".
|
|
||||||
// When aug > 0: shows "N augmentations (X.X% bonus)".
|
|
||||||
AppendHeader(lines, "Encumbrance");
|
|
||||||
Append(lines, $"Burden: {s.BurdenCurrent}");
|
|
||||||
Append(lines, $"Capacity: {s.BurdenMax}");
|
|
||||||
if (s.BurdenMax > 0)
|
|
||||||
{
|
{
|
||||||
int pct = (int)Math.Round(100.0 * s.BurdenCurrent / s.BurdenMax);
|
case 0: body.Append(strings.DeathsNone); break;
|
||||||
Append(lines, $"Load: {pct}%");
|
case 1: body.Append(strings.DeathsOne); break;
|
||||||
|
case 2: body.Append(strings.DeathsTwo); break;
|
||||||
|
default: AppendTokens(body, strings.DeathsMany, sheet.Deaths); break;
|
||||||
}
|
}
|
||||||
|
AppendSectionBreak(body);
|
||||||
|
|
||||||
return lines;
|
// gmCharacterInfoUI::UpdateEnduranceInfo @ 0x004B8EB0.
|
||||||
|
string resist = ResistanceGrade(sheet.Strength + sheet.Endurance,
|
||||||
|
200, 260, 320, 380, 440);
|
||||||
|
string regeneration = ResistanceGrade(
|
||||||
|
sheet.Strength + (2 * sheet.Endurance),
|
||||||
|
200, 346, 470, 580, 690);
|
||||||
|
// ID_CharacterInfo_Resists references the named RESIST variable twice
|
||||||
|
// (Natural and Drain) and REGEN once.
|
||||||
|
body.Append(strings.Resists[0]).Append(resist)
|
||||||
|
.Append(strings.Resists[1]).Append(resist)
|
||||||
|
.Append(strings.Resists[2]).Append(regeneration)
|
||||||
|
.Append(strings.Resists[3]);
|
||||||
|
AppendSectionBreak(body);
|
||||||
|
|
||||||
|
// gmCharacterInfoUI::UpdateInnateAttributeInfo @ 0x004B87E0.
|
||||||
|
AppendTokens(body, strings.Innates,
|
||||||
|
sheet.Strength, sheet.Endurance, sheet.Coordination,
|
||||||
|
sheet.Quickness, sheet.Focus, sheet.Self);
|
||||||
|
AppendSectionBreak(body);
|
||||||
|
|
||||||
|
// gmCharacterInfoUI::UpdateFakeSkills @ 0x004B8930.
|
||||||
|
AppendTokens(body, strings.Chess, sheet.ChessRank);
|
||||||
|
AppendTokens(body, strings.Fishing, sheet.FishingSkill);
|
||||||
|
AppendSectionBreak(body);
|
||||||
|
|
||||||
|
// gmCharacterInfoUI::UpdateAugmentations @ 0x004B9000: no heading
|
||||||
|
// and no synthetic "None" line; only qualities greater than zero.
|
||||||
|
AppendMastery(body, strings.MeleeMastery,
|
||||||
|
Get(sheet, 0x162u), MeleeMasteryName);
|
||||||
|
AppendMastery(body, strings.RangedMastery,
|
||||||
|
Get(sheet, 0x163u), RangedMasteryName);
|
||||||
|
AppendMastery(body, strings.SummoningMastery,
|
||||||
|
Get(sheet, 0x16Au), SummoningMasteryName);
|
||||||
|
foreach (AugmentationDescriptor descriptor in Augmentations)
|
||||||
|
{
|
||||||
|
int value = Get(sheet, descriptor.PropertyId);
|
||||||
|
if (value <= 0
|
||||||
|
|| !strings.AugmentationText.TryGetValue(
|
||||||
|
descriptor.StringKey, out string[]? tokens))
|
||||||
|
continue;
|
||||||
|
if (descriptor.IncludeValue)
|
||||||
|
AppendTokens(body, tokens, value);
|
||||||
|
else
|
||||||
|
body.Append(ResolvePlural(tokens[0], value));
|
||||||
|
}
|
||||||
|
AppendSectionBreak(body);
|
||||||
|
|
||||||
|
// gmCharacterInfoUI::UpdateLoad @ 0x004B8A20.
|
||||||
|
int capacity = BurdenMath.EncumbranceCapacity(
|
||||||
|
sheet.Strength, sheet.EncumbranceAugmentations);
|
||||||
|
float load = BurdenMath.LoadRatio(capacity, sheet.BurdenCurrent);
|
||||||
|
if (load < 1f)
|
||||||
|
body.Append(strings.LoadNone);
|
||||||
|
else
|
||||||
|
AppendTokens(body, strings.LoadBurdened,
|
||||||
|
sheet.BurdenCurrent - capacity,
|
||||||
|
BurdenMath.LoadPenaltyPercent(load));
|
||||||
|
if (sheet.EncumbranceAugmentations > 0)
|
||||||
|
AppendTokens(body, strings.LoadAugmentations,
|
||||||
|
sheet.EncumbranceAugmentations,
|
||||||
|
sheet.EncumbranceAugmentations * 20f);
|
||||||
|
|
||||||
|
return body.ToString().TrimEnd('\r', '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Line helpers ──────────────────────────────────────────────────────────
|
private static int Get(CharacterSheet sheet, uint id)
|
||||||
|
=> sheet.CharacterInfoProperties.TryGetValue(id, out int value) ? value : 0;
|
||||||
|
|
||||||
private static void AppendHeader(List<UiText.Line> lines, string text)
|
private static void AppendMastery(
|
||||||
=> lines.Add(new UiText.Line(text, HeaderColor));
|
StringBuilder body,
|
||||||
|
string[] tokens,
|
||||||
|
int value,
|
||||||
|
Func<int, string> name)
|
||||||
|
{
|
||||||
|
if (value > 0)
|
||||||
|
AppendTokens(body, tokens, name(value));
|
||||||
|
}
|
||||||
|
|
||||||
private static void Append(List<UiText.Line> lines, string text)
|
private static string MeleeMasteryName(int value) => value switch
|
||||||
=> lines.Add(new UiText.Line(text, BodyColor));
|
{
|
||||||
|
1 => "Unarmed Weapons",
|
||||||
|
2 => "Swords",
|
||||||
|
3 => "Axes",
|
||||||
|
4 => "Maces",
|
||||||
|
5 => "Spears",
|
||||||
|
6 => "Daggers",
|
||||||
|
7 => "Staves",
|
||||||
|
11 => "Two Handed Weapons",
|
||||||
|
_ => "Unknown",
|
||||||
|
};
|
||||||
|
|
||||||
private static void AppendBlank(List<UiText.Line> lines)
|
private static string RangedMasteryName(int value) => value switch
|
||||||
=> lines.Add(new UiText.Line(string.Empty, BodyColor));
|
{
|
||||||
|
8 => "Bows",
|
||||||
|
9 => "Crossbows",
|
||||||
|
10 => "Thrown Weapons",
|
||||||
|
12 => "Magical Spells",
|
||||||
|
_ => "Unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string SummoningMasteryName(int value) => value switch
|
||||||
|
{
|
||||||
|
1 => "Primalist",
|
||||||
|
2 => "Necromancer",
|
||||||
|
3 => "Naturalist",
|
||||||
|
_ => "Unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static string ResistanceGrade(
|
||||||
|
int value,
|
||||||
|
int none,
|
||||||
|
int poor,
|
||||||
|
int mediocre,
|
||||||
|
int hardy,
|
||||||
|
int resilient)
|
||||||
|
=> value <= none ? "None"
|
||||||
|
: value <= poor ? "Poor"
|
||||||
|
: value <= mediocre ? "Mediocre"
|
||||||
|
: value <= hardy ? "Hardy"
|
||||||
|
: value <= resilient ? "Resilient"
|
||||||
|
: "Indomitable";
|
||||||
|
|
||||||
|
internal static string FormatRetailDuration(int totalSeconds)
|
||||||
|
{
|
||||||
|
long remaining = Math.Max(0, totalSeconds);
|
||||||
|
(long seconds, string singular, string plural)[] units =
|
||||||
|
[
|
||||||
|
(31_536_000, "year", "years"),
|
||||||
|
(2_592_000, "month", "months"),
|
||||||
|
(604_800, "week", "weeks"),
|
||||||
|
(86_400, "day", "days"),
|
||||||
|
(3_600, "hour", "hours"),
|
||||||
|
(60, "minute", "minutes"),
|
||||||
|
(1, "second", "seconds"),
|
||||||
|
];
|
||||||
|
var parts = new List<string>();
|
||||||
|
foreach (var unit in units)
|
||||||
|
{
|
||||||
|
long count = remaining / unit.seconds;
|
||||||
|
remaining %= unit.seconds;
|
||||||
|
if (count != 0)
|
||||||
|
parts.Add($"{count} {(count == 1 ? unit.singular : unit.plural)}");
|
||||||
|
}
|
||||||
|
return string.Join(' ', parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatRetailDate(int unixSeconds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return DateTimeOffset.FromUnixTimeSeconds(unixSeconds)
|
||||||
|
.LocalDateTime.ToString("G", CultureInfo.CurrentCulture);
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException)
|
||||||
|
{
|
||||||
|
return unixSeconds.ToString(CultureInfo.CurrentCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendTokens(
|
||||||
|
StringBuilder body,
|
||||||
|
IReadOnlyList<string> tokens,
|
||||||
|
params object[] values)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < tokens.Count; i++)
|
||||||
|
{
|
||||||
|
int pluralValue = values.Length == 0 ? 0
|
||||||
|
: NumericValue(values[Math.Min(i, values.Length - 1)]);
|
||||||
|
body.Append(ResolvePlural(tokens[i], pluralValue));
|
||||||
|
if (i < values.Length)
|
||||||
|
body.Append(Convert.ToString(values[i], CultureInfo.CurrentCulture));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolvePlural(string text, int value)
|
||||||
|
=> text.Replace("{time[1]|times}", value == 1 ? "time" : "times",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static int NumericValue(object value) => value switch
|
||||||
|
{
|
||||||
|
byte v => v,
|
||||||
|
short v => v,
|
||||||
|
int v => v,
|
||||||
|
long v => checked((int)v),
|
||||||
|
float v => (int)v,
|
||||||
|
double v => (int)v,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void AppendSectionBreak(StringBuilder body)
|
||||||
|
{
|
||||||
|
if (body.Length != 0 && body[^1] != '\n') body.Append('\n');
|
||||||
|
body.Append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct AugmentationDescriptor(
|
||||||
|
uint PropertyId,
|
||||||
|
string StringKey,
|
||||||
|
bool IncludeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CharacterInfoStrings(
|
||||||
|
string[] Birth,
|
||||||
|
string[] Played,
|
||||||
|
string DeathsNone,
|
||||||
|
string DeathsOne,
|
||||||
|
string DeathsTwo,
|
||||||
|
string[] DeathsMany,
|
||||||
|
string[] Resists,
|
||||||
|
string[] Innates,
|
||||||
|
string[] Chess,
|
||||||
|
string[] Fishing,
|
||||||
|
string LoadNone,
|
||||||
|
string[] LoadBurdened,
|
||||||
|
string[] LoadAugmentations,
|
||||||
|
string[] MeleeMastery,
|
||||||
|
string[] RangedMastery,
|
||||||
|
string[] SummoningMastery,
|
||||||
|
IReadOnlyDictionary<string, string[]> AugmentationText)
|
||||||
|
{
|
||||||
|
public static CharacterInfoStrings FromDat(
|
||||||
|
Func<string, string[]?> resolve)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(resolve);
|
||||||
|
CharacterInfoStrings fallback = English;
|
||||||
|
string[] Get(string key, string[] defaultValue)
|
||||||
|
=> resolve(key) is { Length: > 0 } value ? value : defaultValue;
|
||||||
|
string GetOne(string key, string defaultValue)
|
||||||
|
=> Get(key, [defaultValue])[0];
|
||||||
|
|
||||||
|
var augmentations = new Dictionary<string, string[]>();
|
||||||
|
foreach (string key in CharacterController.AugmentationStringKeys)
|
||||||
|
if (resolve(key) is { Length: > 0 } value)
|
||||||
|
augmentations[key] = value;
|
||||||
|
|
||||||
|
return new CharacterInfoStrings(
|
||||||
|
Get("ID_CharacterInfo_Birth", fallback.Birth),
|
||||||
|
Get("ID_CharacterInfo_Played", fallback.Played),
|
||||||
|
GetOne("ID_CharacterInfo_Deaths_None", fallback.DeathsNone),
|
||||||
|
GetOne("ID_CharacterInfo_Deaths_One", fallback.DeathsOne),
|
||||||
|
GetOne("ID_CharacterInfo_Deaths_Two", fallback.DeathsTwo),
|
||||||
|
Get("ID_CharacterInfo_Deaths_Many", fallback.DeathsMany),
|
||||||
|
Get("ID_CharacterInfo_Resists", fallback.Resists),
|
||||||
|
Get("ID_CharacterInfo_Innates", fallback.Innates),
|
||||||
|
Get("ID_CharacterInfo_Chess", fallback.Chess),
|
||||||
|
Get("ID_CharacterInfo_Fishing", fallback.Fishing),
|
||||||
|
GetOne("ID_CharacterInfo_Load_None", fallback.LoadNone),
|
||||||
|
Get("ID_CharacterInfo_Load_Burdened", fallback.LoadBurdened),
|
||||||
|
Get("ID_CharacterInfo_Load_Augmentations", fallback.LoadAugmentations),
|
||||||
|
Get("ID_CharacterInfo_Mastery_Melee", fallback.MeleeMastery),
|
||||||
|
Get("ID_CharacterInfo_Mastery_Ranged", fallback.RangedMastery),
|
||||||
|
Get("ID_CharacterInfo_Mastery_Summoning", fallback.SummoningMastery),
|
||||||
|
augmentations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CharacterInfoStrings English { get; } = new(
|
||||||
|
["You were born on ", ".\n"],
|
||||||
|
["You have played for ", ".\n"],
|
||||||
|
"You have never died!\n",
|
||||||
|
"You have died only once!\n",
|
||||||
|
"You have died twice.\n",
|
||||||
|
["You have died ", " times.\n"],
|
||||||
|
["Natural Resistances: ", "\nDrain Resistances: ", "\nRegeneration Bonus: ", "\n"],
|
||||||
|
["Innate Strength: ", "\nInnate Endurance: ", "\nInnate Coordination: ",
|
||||||
|
"\nInnate Quickness: ", "\nInnate Focus: ", "\nInnate Self: ", "\n"],
|
||||||
|
["Chess Rank: ", "\n"],
|
||||||
|
["Fishing Skill: ", "\n"],
|
||||||
|
"You are not overburdened at this time.\n",
|
||||||
|
["You are currently overburdened by ",
|
||||||
|
" burden units. This is reducing your Run, Jump, Melee Defense and Missile Defense skills by ",
|
||||||
|
"%.\n"],
|
||||||
|
["You have been augmented ",
|
||||||
|
" times with the Might of the Seventh Mule. This has increased your carrying capacity by ",
|
||||||
|
"%.\n"],
|
||||||
|
["Your melee mastery is ", ".\n"],
|
||||||
|
["Your ranged mastery is ", ".\n\n"],
|
||||||
|
["Your summoning mastery is ", ".\n\n"],
|
||||||
|
new Dictionary<string, string[]>());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace AcDream.App.UI.Layout;
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
|
@ -70,6 +71,12 @@ public sealed class CharacterSheet
|
||||||
/// <summary>Total deaths (retail InqInt(0x2b) = NumDeaths).</summary>
|
/// <summary>Total deaths (retail InqInt(0x2b) = NumDeaths).</summary>
|
||||||
public int Deaths { get; init; }
|
public int Deaths { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Raw retail PropertyInt 0x62. Null means the quality was absent.</summary>
|
||||||
|
public int? BirthTimestamp { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Raw retail PropertyInt 0x7D seconds. Null means the quality was absent.</summary>
|
||||||
|
public int? TotalPlayTimeSeconds { get; init; }
|
||||||
|
|
||||||
// ── Vitals (UpdateEnduranceInfo 0x004b8eb0) ─────────────────────────────
|
// ── Vitals (UpdateEnduranceInfo 0x004b8eb0) ─────────────────────────────
|
||||||
|
|
||||||
public int HealthCurrent { get; init; }
|
public int HealthCurrent { get; init; }
|
||||||
|
|
@ -90,12 +97,18 @@ public sealed class CharacterSheet
|
||||||
public int Self { get; init; }
|
public int Self { get; init; }
|
||||||
|
|
||||||
// ── Skills (UpdateFakeSkills 0x004b8930) ────────────────────────────────
|
// ── 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.
|
// InqInt(0x18) = available skill credits — footer 0x10000245 in the Attributes tab.
|
||||||
|
|
||||||
public int UnspentSkillCredits { get; init; }
|
public int UnspentSkillCredits { get; init; }
|
||||||
public int SpecializedSkillCredits { get; init; }
|
public int SpecializedSkillCredits { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Retail Character Information PropertyInt 0xB5.</summary>
|
||||||
|
public int ChessRank { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Retail Character Information PropertyInt 0xC0.</summary>
|
||||||
|
public int FishingSkill { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Available (unspent) skill credits shown in the Attributes tab footer State-A.
|
/// Available (unspent) skill credits shown in the Attributes tab footer State-A.
|
||||||
/// Retail InqInt(0x18) — gmStatManagementUI::DisplayDefaultFooter (0x0049cde0).
|
/// Retail InqInt(0x18) — gmStatManagementUI::DisplayDefaultFooter (0x0049cde0).
|
||||||
|
|
@ -144,11 +157,20 @@ public sealed class CharacterSheet
|
||||||
/// e.g. "Swords", "Two Handed Weapons". Null = "None".</summary>
|
/// e.g. "Swords", "Two Handed Weapons". Null = "None".</summary>
|
||||||
public string? AugmentationName { get; init; }
|
public string? AugmentationName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raw PropertyInt snapshot used by retail's data-driven mastery,
|
||||||
|
/// luminance, and augmentation report. The controller reads only the
|
||||||
|
/// quality ids cited by <c>gmCharacterInfoUI::UpdateAugmentations</c>.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<uint, int> CharacterInfoProperties { get; init; }
|
||||||
|
= new Dictionary<uint, int>();
|
||||||
|
|
||||||
// ── Burden / load (UpdateLoad 0x004b8a20) ───────────────────────────────
|
// ── Burden / load (UpdateLoad 0x004b8a20) ───────────────────────────────
|
||||||
// Retail InqLoad + EncumbranceCapacity(Strength, AugEncumbrance).
|
// Retail InqLoad + EncumbranceCapacity(Strength, AugEncumbrance).
|
||||||
|
|
||||||
public int BurdenCurrent { get; init; }
|
public int BurdenCurrent { get; init; }
|
||||||
public int BurdenMax { get; init; }
|
public int BurdenMax { get; init; }
|
||||||
|
public int EncumbranceAugmentations { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum CharacterSkillAdvancementClass
|
public enum CharacterSkillAdvancementClass
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,10 @@ public sealed class CharacterSheetProvider
|
||||||
private const uint UnassignedXpPropertyId = 2u;
|
private const uint UnassignedXpPropertyId = 2u;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Skill-credit debit fallback chain, most-specific first. Retail reads
|
/// Retail PropertyInt 0x18 = available skill credits. Properties 0xB5 and
|
||||||
/// InqInt(0x18) for the Attributes-tab footer, InqInt(0xc0) for available
|
/// 0xC0 are Chess Rank and Fishing Skill and must never be debited.
|
||||||
/// and InqInt(0xb5) for total skill credits (see CharacterSheet docs);
|
|
||||||
/// the live server population varies, so debit whichever is present.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly uint[] SkillCreditPropertyIds = { 0x18u, 0xC0u, 0xB5u };
|
private static readonly uint[] SkillCreditPropertyIds = { 0x18u };
|
||||||
|
|
||||||
private readonly ClientObjectTable _objects;
|
private readonly ClientObjectTable _objects;
|
||||||
private readonly LocalPlayerState _localPlayer;
|
private readonly LocalPlayerState _localPlayer;
|
||||||
|
|
@ -109,7 +107,7 @@ public sealed class CharacterSheetProvider
|
||||||
long unassignedXp = props.GetInt64(UnassignedXpPropertyId);
|
long unassignedXp = props.GetInt64(UnassignedXpPropertyId);
|
||||||
var xp = ComputeLevelXp(level, totalXp);
|
var xp = ComputeLevelXp(level, totalXp);
|
||||||
|
|
||||||
int skillCredits = props.GetInt(0x18u, props.GetInt(0xC0u, props.GetInt(0xB5u)));
|
int skillCredits = props.GetInt(0x18u);
|
||||||
|
|
||||||
return new CharacterSheet
|
return new CharacterSheet
|
||||||
{
|
{
|
||||||
|
|
@ -138,8 +136,17 @@ public sealed class CharacterSheetProvider
|
||||||
Focus = AttrCurrent(LocalPlayerState.AttributeKind.Focus),
|
Focus = AttrCurrent(LocalPlayerState.AttributeKind.Focus),
|
||||||
Self = AttrCurrent(LocalPlayerState.AttributeKind.Self),
|
Self = AttrCurrent(LocalPlayerState.AttributeKind.Self),
|
||||||
|
|
||||||
UnspentSkillCredits = props.GetInt(0xB5u, skillCredits),
|
UnspentSkillCredits = skillCredits,
|
||||||
SpecializedSkillCredits = props.GetInt(0xC0u),
|
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,
|
SkillCredits = skillCredits,
|
||||||
UnassignedXp = unassignedXp,
|
UnassignedXp = unassignedXp,
|
||||||
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
|
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
|
||||||
|
|
@ -147,6 +154,8 @@ public sealed class CharacterSheetProvider
|
||||||
Skills = BuildLiveCharacterSkills(),
|
Skills = BuildLiveCharacterSkills(),
|
||||||
BurdenCurrent = props.GetInt(5u),
|
BurdenCurrent = props.GetInt(5u),
|
||||||
BurdenMax = props.GetInt(96u),
|
BurdenMax = props.GetInt(96u),
|
||||||
|
EncumbranceAugmentations = props.GetInt(0xE6u),
|
||||||
|
CharacterInfoProperties = new Dictionary<uint, int>(props.Ints),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,23 @@ public sealed class DatStringResolver
|
||||||
return entry.Strings[index].Value;
|
return entry.Strings[index].Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns every literal token for one retail StringInfo entry.</summary>
|
||||||
|
public string[]? ResolveAll(uint tableId, uint stringId)
|
||||||
|
{
|
||||||
|
if (tableId == 0u || stringId == 0u)
|
||||||
|
return null;
|
||||||
|
if (!_tables.TryGetValue(tableId, out StringTable? table))
|
||||||
|
{
|
||||||
|
table = _dats.Get<StringTable>(tableId);
|
||||||
|
_tables[tableId] = table;
|
||||||
|
}
|
||||||
|
return table is not null
|
||||||
|
&& table.Strings.TryGetValue(stringId, out var entry)
|
||||||
|
&& entry.Strings.Count != 0
|
||||||
|
? entry.Strings.Select(value => value.Value).ToArray()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exact retail ELF-style string hash used for StringInfo keys.
|
/// Exact retail ELF-style string hash used for StringInfo keys.
|
||||||
/// Ported line-for-line from <c>compute_str_hash @ 0x00413110</c>.
|
/// Ported line-for-line from <c>compute_str_hash @ 0x00413110</c>.
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,9 @@ public sealed class IndicatorBarController : IRetainedPanelController
|
||||||
_lastLinkFlash = now;
|
_lastLinkFlash = now;
|
||||||
_link.TrySetRetailState(ConnectionGoodState);
|
_link.TrySetRetailState(ConnectionGoodState);
|
||||||
_miniGame.TrySetRetailState(UiButtonStateMachine.Ghosted);
|
_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();
|
UpdateEnchantments();
|
||||||
UpdateBurden();
|
UpdateBurden();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ public static class RetailWindowFrame
|
||||||
public bool ConstrainResizeToParent { get; init; }
|
public bool ConstrainResizeToParent { get; init; }
|
||||||
|
|
||||||
public float Opacity { get; init; } = 1f;
|
public float Opacity { get; init; } = 1f;
|
||||||
|
public bool DrawChromeCenter { get; init; } = true;
|
||||||
public bool Visible { get; init; } = true;
|
public bool Visible { get; init; } = true;
|
||||||
|
|
||||||
public AnchorEdges ContentAnchors { get; init; } =
|
public AnchorEdges ContentAnchors { get; init; } =
|
||||||
|
|
@ -99,6 +100,9 @@ public static class RetailWindowFrame
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(options.Chrome)),
|
_ => throw new ArgumentOutOfRangeException(nameof(options.Chrome)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (outerFrame is UiNineSlicePanel nineSlice)
|
||||||
|
nineSlice.DrawCenterFill = options.DrawChromeCenter;
|
||||||
|
|
||||||
const int border = RetailChromeSprites.Border;
|
const int border = RetailChromeSprites.Border;
|
||||||
content.Left = border;
|
content.Left = border;
|
||||||
content.Top = border;
|
content.Top = border;
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,9 @@ public sealed class VitaeUiController : IRetainedPanelController
|
||||||
body = _strings.LostPrefix
|
body = _strings.LostPrefix
|
||||||
+ lostPercent
|
+ lostPercent
|
||||||
+ _strings.LostSuffix
|
+ _strings.LostSuffix
|
||||||
|
+ _strings.SkillsPrefix
|
||||||
|
+ lostPercent
|
||||||
|
+ _strings.SkillsSuffix
|
||||||
+ _strings.RecoveryPrefix
|
+ _strings.RecoveryPrefix
|
||||||
+ remaining
|
+ remaining
|
||||||
+ _strings.RecoverySuffix;
|
+ _strings.RecoverySuffix;
|
||||||
|
|
@ -146,13 +149,17 @@ public sealed record VitaeStrings(
|
||||||
string FullStrength,
|
string FullStrength,
|
||||||
string LostPrefix,
|
string LostPrefix,
|
||||||
string LostSuffix,
|
string LostSuffix,
|
||||||
|
string SkillsPrefix,
|
||||||
|
string SkillsSuffix,
|
||||||
string RecoveryPrefix,
|
string RecoveryPrefix,
|
||||||
string RecoverySuffix)
|
string RecoverySuffix)
|
||||||
{
|
{
|
||||||
public static VitaeStrings English { get; } = new(
|
public static VitaeStrings English { get; } = new(
|
||||||
"Your Vitae, or life force, is at full strength.",
|
"Your Vitae, or life force, is at full strength.",
|
||||||
"Due to your recent death, you have temporarily lost ",
|
"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 ",
|
"\n\nYou will regain 1% of your Vitae once you earn ",
|
||||||
" more experience.");
|
" more experience.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -919,6 +919,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
|
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
|
||||||
| AnchorEdges.Right | AnchorEdges.Bottom,
|
| AnchorEdges.Right | AnchorEdges.Bottom,
|
||||||
ContentClickThrough = false,
|
ContentClickThrough = false,
|
||||||
|
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
|
||||||
Controller = controller,
|
Controller = controller,
|
||||||
});
|
});
|
||||||
_panelUi.RegisterMainPanel(
|
_panelUi.RegisterMainPanel(
|
||||||
|
|
@ -943,12 +944,14 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
{
|
{
|
||||||
ElementInfo? rootInfo;
|
ElementInfo? rootInfo;
|
||||||
ImportedLayout? layout;
|
ImportedLayout? layout;
|
||||||
|
CharacterInfoStrings strings;
|
||||||
lock (_bindings.Assets.DatLock)
|
lock (_bindings.Assets.DatLock)
|
||||||
{
|
{
|
||||||
rootInfo = LayoutImporter.ImportInfos(
|
rootInfo = LayoutImporter.ImportInfos(
|
||||||
_bindings.Assets.Dats,
|
_bindings.Assets.Dats,
|
||||||
CharacterController.LayoutId,
|
CharacterController.LayoutId,
|
||||||
CharacterController.RootId);
|
CharacterController.RootId);
|
||||||
|
var resolver = new DatStringResolver(_bindings.Assets.Dats);
|
||||||
layout = rootInfo is null
|
layout = rootInfo is null
|
||||||
? null
|
? null
|
||||||
: LayoutImporter.Build(
|
: LayoutImporter.Build(
|
||||||
|
|
@ -956,7 +959,9 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_bindings.Assets.ResolveSprite,
|
_bindings.Assets.ResolveSprite,
|
||||||
_bindings.Assets.DefaultFont,
|
_bindings.Assets.DefaultFont,
|
||||||
_bindings.Assets.ResolveFont,
|
_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;
|
if (rootInfo is null || layout is null) return;
|
||||||
|
|
||||||
|
|
@ -964,7 +969,8 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
layout,
|
layout,
|
||||||
_bindings.Character.Provider.BuildSheet,
|
_bindings.Character.Provider.BuildSheet,
|
||||||
_bindings.Assets.DefaultFont,
|
_bindings.Assets.DefaultFont,
|
||||||
() => CloseWindow(WindowNames.CharacterInformation));
|
() => CloseWindow(WindowNames.CharacterInformation),
|
||||||
|
strings);
|
||||||
RegisterIndicatorDetailPanel(
|
RegisterIndicatorDetailPanel(
|
||||||
RetailPanelCatalog.CharacterInformation,
|
RetailPanelCatalog.CharacterInformation,
|
||||||
WindowNames.CharacterInformation,
|
WindowNames.CharacterInformation,
|
||||||
|
|
@ -1042,11 +1048,19 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
resolver.Resolve);
|
resolver.Resolve);
|
||||||
VitaeStrings fallback = VitaeStrings.English;
|
VitaeStrings fallback = VitaeStrings.English;
|
||||||
strings = new VitaeStrings(
|
strings = new VitaeStrings(
|
||||||
resolver.Resolve(0x23000001u, 0x0142DDECu) ?? fallback.FullStrength,
|
Resolve("ID_Vitae_Text_Full", 0, fallback.FullStrength),
|
||||||
resolver.Resolve(0x23000001u, 0x045D7665u, 0) ?? fallback.LostPrefix,
|
Resolve("ID_Vitae_Text_Vitae", 0, fallback.LostPrefix),
|
||||||
resolver.Resolve(0x23000001u, 0x045D7665u, 1) ?? fallback.LostSuffix,
|
Resolve("ID_Vitae_Text_Vitae", 1, fallback.LostSuffix),
|
||||||
resolver.Resolve(0x23000001u, 0x0DCD8C35u, 0) ?? fallback.RecoveryPrefix,
|
Resolve("ID_Vitae_Text_Skills", 0, fallback.SkillsPrefix),
|
||||||
resolver.Resolve(0x23000001u, 0x0DCD8C35u, 1) ?? fallback.RecoverySuffix);
|
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;
|
if (rootInfo is null || layout is null) return;
|
||||||
|
|
||||||
|
|
@ -1127,6 +1141,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
|
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
|
||||||
| AnchorEdges.Right | AnchorEdges.Bottom,
|
| AnchorEdges.Right | AnchorEdges.Bottom,
|
||||||
ContentClickThrough = false,
|
ContentClickThrough = false,
|
||||||
|
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
|
||||||
Controller = controller,
|
Controller = controller,
|
||||||
});
|
});
|
||||||
_panelUi.RegisterMainPanel(
|
_panelUi.RegisterMainPanel(
|
||||||
|
|
@ -1139,6 +1154,28 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
&& restorePrevious);
|
&& restorePrevious);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's Vitae and Character Information children carry their own
|
||||||
|
/// 0x06004CC2 body surface below the 25px title row. The standalone shared
|
||||||
|
/// window contributes the border only in that case; painting the same
|
||||||
|
/// translucent center twice makes the panel look opaque.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool AuthorsFullPanelCenter(ElementInfo rootInfo)
|
||||||
|
{
|
||||||
|
const float RetailTitleHeight = 25f;
|
||||||
|
return Visit(rootInfo);
|
||||||
|
|
||||||
|
bool Visit(ElementInfo info)
|
||||||
|
{
|
||||||
|
bool coversBody = info.Width >= rootInfo.Width
|
||||||
|
&& info.Height >= rootInfo.Height - RetailTitleHeight;
|
||||||
|
if (coversBody && info.StateMedia.Values.Any(
|
||||||
|
media => media.File == RetailChromeSprites.CenterFill))
|
||||||
|
return true;
|
||||||
|
return info.Children.Any(Visit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void MountIndicators()
|
private void MountIndicators()
|
||||||
{
|
{
|
||||||
ImportedLayout? layout = Import(IndicatorBarController.LayoutId);
|
ImportedLayout? layout = Import(IndicatorBarController.LayoutId);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,13 @@ public class UiNineSlicePanel : UiPanel
|
||||||
|
|
||||||
private readonly System.Func<uint, (uint tex, int w, int h)> _resolve;
|
private readonly System.Func<uint, (uint tex, int w, int h)> _resolve;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this wrapper paints the shared center surface. Some retail child
|
||||||
|
/// panels already author that same full-height surface; their standalone
|
||||||
|
/// wrapper supplies only the border so translucency is not compounded.
|
||||||
|
/// </summary>
|
||||||
|
public bool DrawCenterFill { get; set; } = true;
|
||||||
|
|
||||||
public UiNineSlicePanel(System.Func<uint, (uint, int, int)> resolve)
|
public UiNineSlicePanel(System.Func<uint, (uint, int, int)> resolve)
|
||||||
{
|
{
|
||||||
_resolve = resolve;
|
_resolve = resolve;
|
||||||
|
|
@ -66,7 +73,8 @@ public class UiNineSlicePanel : UiPanel
|
||||||
// and draws in OnDrawAfterChildren (over the content's edges) so content can
|
// 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).
|
// never poke through the frame (e.g. the toolbar's 2px bottom-right cap overhang).
|
||||||
var r = ComputeFrameRects(Width, Height, RetailChromeSprites.Border);
|
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)
|
protected override void OnDrawAfterChildren(UiRenderContext ctx)
|
||||||
|
|
|
||||||
|
|
@ -389,6 +389,22 @@ public static class BurdenMath
|
||||||
public static float LoadRatio(int capacity, int burden)
|
public static float LoadRatio(int capacity, int burden)
|
||||||
=> capacity <= 0 ? 0f : (float)burden / capacity;
|
=> capacity <= 0 ? 0f : (float)burden / capacity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>EncumbranceSystem::LoadMod @ 0x004FCC70</c>: full
|
||||||
|
/// effectiveness through 100% load, a linear falloff to zero at 200%,
|
||||||
|
/// then zero above it.
|
||||||
|
/// </summary>
|
||||||
|
public static float LoadModifier(float load)
|
||||||
|
=> load <= 1f ? 1f : load < 2f ? 2f - load : 0f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Percentage printed by <c>gmCharacterInfoUI::UpdateLoad @ 0x004B8A20</c>.
|
||||||
|
/// Retail truncates <c>LoadMod * 10</c> before converting the ten steps to
|
||||||
|
/// a percentage.
|
||||||
|
/// </summary>
|
||||||
|
public static int LoadPenaltyPercent(float load)
|
||||||
|
=> (10 - (int)(LoadModifier(load) * 10f)) * 10;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail <c>gmBackpackUI::SetLoadLevel</c> bar fill (decomp 176542,
|
/// Retail <c>gmBackpackUI::SetLoadLevel</c> bar fill (decomp 176542,
|
||||||
/// <c>0x004a6ea6</c>): <c>clamp(load * 0.3333…, 0, 1)</c> — the bar is 1/3 full at
|
/// <c>0x004a6ea6</c>): <c>clamp(load * 0.3333…, 0, 1)</c> — the bar is 1/3 full at
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,10 @@ using AcDream.App.UI.Layout;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.UI.Layout;
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
/// <summary>
|
public sealed class CharacterControllerTests
|
||||||
/// Unit tests for <see cref="CharacterController"/> and <see cref="SampleData.SampleCharacter"/>.
|
|
||||||
/// Production Character Information data authors m_pMainText (0x1000011d);
|
|
||||||
/// reduced unit-test layouts exercise the controller's fallback element.
|
|
||||||
/// No GL — pure data-wiring + report-content tests.
|
|
||||||
/// </summary>
|
|
||||||
public class CharacterControllerTests
|
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuthoredCharacterInformationPanel_BindsTextScrollbarAndClose()
|
public void AuthoredPanel_BindsTextScrollbarAndClose()
|
||||||
{
|
{
|
||||||
ImportedLayout layout = FixtureLoader.LoadCharacterInformation();
|
ImportedLayout layout = FixtureLoader.LoadCharacterInformation();
|
||||||
int closes = 0;
|
int closes = 0;
|
||||||
|
|
@ -21,15 +15,13 @@ public class CharacterControllerTests
|
||||||
CharacterController.Bind(
|
CharacterController.Bind(
|
||||||
layout, SampleData.SampleCharacter, close: () => closes++);
|
layout, SampleData.SampleCharacter, close: () => closes++);
|
||||||
|
|
||||||
Assert.Equal(CharacterController.RootId, layout.Root.DatElementId);
|
|
||||||
Assert.Equal((300f, 362f), (layout.Root.Width, layout.Root.Height));
|
|
||||||
UiText text = Assert.IsType<UiText>(
|
UiText text = Assert.IsType<UiText>(
|
||||||
layout.FindElement(CharacterController.MainTextId));
|
layout.FindElement(CharacterController.MainTextId));
|
||||||
UiScrollbar scrollbar = Assert.IsType<UiScrollbar>(
|
UiScrollbar scrollbar = Assert.IsType<UiScrollbar>(
|
||||||
layout.FindElement(CharacterController.ScrollbarId));
|
layout.FindElement(CharacterController.ScrollbarId));
|
||||||
Assert.Same(text.Scroll, scrollbar.Model);
|
Assert.Same(text.Scroll, scrollbar.Model);
|
||||||
Assert.False(text.PreserveEndOnLayout);
|
Assert.False(text.PreserveEndOnLayout);
|
||||||
Assert.Contains("Studio Player", text.LinesProvider().Select(line => line.Text));
|
Assert.Contains("You were born on", VisibleText(text));
|
||||||
|
|
||||||
UiButton close = Assert.IsType<UiButton>(
|
UiButton close = Assert.IsType<UiButton>(
|
||||||
layout.FindElement(CharacterController.CloseId));
|
layout.FindElement(CharacterController.CloseId));
|
||||||
|
|
@ -37,136 +29,76 @@ public class CharacterControllerTests
|
||||||
Assert.Equal(1, closes);
|
Assert.Equal(1, closes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Bind on an empty layout, then return the created m_pMainText element.</summary>
|
[Fact]
|
||||||
private static UiText BindAndGetText(System.Func<CharacterSheet> data)
|
public void Report_UsesRetailContentInsteadOfInventedCharacterSheetSummary()
|
||||||
{
|
{
|
||||||
var layout = FakeLayout();
|
string report = Report(SampleData.SampleCharacter());
|
||||||
CharacterController.Bind(layout, data);
|
|
||||||
return layout.Root.Children.OfType<UiText>()
|
Assert.DoesNotContain("Studio Player", report);
|
||||||
.First(t => t.EventId == CharacterController.MainTextId);
|
Assert.DoesNotContain("Level 126", report);
|
||||||
|
Assert.DoesNotContain("Health:", report);
|
||||||
|
Assert.DoesNotContain("Augmentations", report);
|
||||||
|
Assert.DoesNotContain("Encumbrance", report);
|
||||||
|
|
||||||
|
Assert.Contains("You have died 42 times.", report);
|
||||||
|
Assert.Contains("Natural Resistances:", report);
|
||||||
|
Assert.Contains("Drain Resistances:", report);
|
||||||
|
Assert.Contains("Regeneration Bonus:", report);
|
||||||
|
Assert.Contains("Innate Strength: 200", report);
|
||||||
|
Assert.Contains("Innate Coordination: 10", report);
|
||||||
|
Assert.Contains("Chess Rank: 12", report);
|
||||||
|
Assert.Contains("Fishing Skill: 4", report);
|
||||||
|
Assert.Contains("Your melee mastery is Swords.", report);
|
||||||
|
Assert.Contains("You are not overburdened at this time.", report);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Report(System.Func<CharacterSheet> data)
|
[Theory]
|
||||||
=> string.Join("\n", BindAndGetText(data).LinesProvider().Select(l => l.Text));
|
[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]
|
[Fact]
|
||||||
public void Bind_CreatesMainTextWithReport()
|
public void DeathCount_UsesRetailSpecialCases()
|
||||||
{
|
{
|
||||||
var text = BindAndGetText(SampleData.SampleCharacter);
|
Assert.Contains("never died", Report(new CharacterSheet { Deaths = 0 }));
|
||||||
Assert.True(text.LinesProvider().Count > 0, "Expected non-empty report after Bind");
|
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]
|
[Fact]
|
||||||
public void Bind_ReportContainsAllSixAttributes()
|
public void EmptyLayout_GetsFallbackMainText()
|
||||||
{
|
{
|
||||||
var allText = Report(SampleData.SampleCharacter);
|
ImportedLayout layout = FakeLayout();
|
||||||
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
|
|
||||||
CharacterController.Bind(layout, SampleData.SampleCharacter);
|
CharacterController.Bind(layout, SampleData.SampleCharacter);
|
||||||
Assert.Contains(layout.Root.Children.OfType<UiText>(),
|
Assert.Contains(layout.Root.Children.OfType<UiText>(),
|
||||||
t => t.EventId == CharacterController.MainTextId);
|
text => text.EventId == CharacterController.MainTextId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Test 7: The created element carries the m_pMainText id ───────────────
|
private static string Report(CharacterSheet sheet)
|
||||||
|
=> CharacterController.BuildReport(sheet, CharacterInfoStrings.English);
|
||||||
|
|
||||||
[Fact]
|
private static string VisibleText(UiText text)
|
||||||
public void Bind_CreatedElementHasMainTextId()
|
=> string.Join('\n', text.LinesProvider().Select(line => line.Text));
|
||||||
=> Assert.Equal(CharacterController.MainTextId, BindAndGetText(SampleData.SampleCharacter).EventId);
|
|
||||||
|
|
||||||
// ── Test 8: SampleCharacter fixture has non-zero attribute values ─────────
|
private static ImportedLayout FakeLayout()
|
||||||
|
=> new(new UiPanel { Width = 300f, Height = 362f },
|
||||||
[Fact]
|
new Dictionary<uint, UiElement>());
|
||||||
public void SampleCharacter_AttributesAreNonZero()
|
|
||||||
{
|
|
||||||
var s = SampleData.SampleCharacter();
|
|
||||||
Assert.True(s.Strength > 0, "Strength must be > 0");
|
|
||||||
Assert.True(s.Endurance > 0, "Endurance must be > 0");
|
|
||||||
Assert.True(s.Quickness > 0, "Quickness must be > 0");
|
|
||||||
Assert.True(s.Coordination > 0, "Coordination must be > 0");
|
|
||||||
Assert.True(s.Focus > 0, "Focus must be > 0");
|
|
||||||
Assert.True(s.Self > 0, "Self must be > 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Test 9: SampleCharacter fixture has coherent vitals ──────────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SampleCharacter_VitalsAreCoherent()
|
|
||||||
{
|
|
||||||
var s = SampleData.SampleCharacter();
|
|
||||||
Assert.True(s.HealthCurrent > 0 && s.HealthCurrent <= s.HealthMax, "Health cur must be in [1, max]");
|
|
||||||
Assert.True(s.StaminaCurrent > 0 && s.StaminaCurrent <= s.StaminaMax, "Stamina cur must be in [1, max]");
|
|
||||||
Assert.True(s.ManaCurrent > 0 && s.ManaCurrent <= s.ManaMax, "Mana cur must be in [1, max]");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Test 10: Report contains attribute VALUES from the fixture ────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Bind_ReportContainsSampleAttributeValues()
|
|
||||||
{
|
|
||||||
var sheet = SampleData.SampleCharacter();
|
|
||||||
var allText = Report(() => sheet);
|
|
||||||
Assert.Contains(sheet.Strength.ToString(), allText);
|
|
||||||
Assert.Contains(sheet.Endurance.ToString(), allText);
|
|
||||||
Assert.Contains(sheet.Quickness.ToString(), allText);
|
|
||||||
Assert.Contains(sheet.Coordination.ToString(), allText);
|
|
||||||
Assert.Contains(sheet.Focus.ToString(), allText);
|
|
||||||
Assert.Contains(sheet.Self.ToString(), allText);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static ImportedLayout FakeLayout(params (uint id, UiElement e)[] items)
|
|
||||||
{
|
|
||||||
var dict = new Dictionary<uint, UiElement>();
|
|
||||||
var root = new UiPanel();
|
|
||||||
foreach (var (id, e) in items)
|
|
||||||
{
|
|
||||||
root.AddChild(e);
|
|
||||||
dict[id] = e;
|
|
||||||
}
|
|
||||||
return new ImportedLayout(root, dict);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ public sealed class CharacterSheetProviderTests
|
||||||
player.Properties.Ints[0x19u] = 1; // level
|
player.Properties.Ints[0x19u] = 1; // level
|
||||||
player.Properties.Int64s[1u] = 150L; // total XP — mid 100..250 band
|
player.Properties.Int64s[1u] = 150L; // total XP — mid 100..250 band
|
||||||
player.Properties.Int64s[2u] = unassignedXp;
|
player.Properties.Int64s[2u] = unassignedXp;
|
||||||
|
player.Properties.Ints[0x18u] = 4; // available skill credits
|
||||||
Table.AddOrUpdate(player);
|
Table.AddOrUpdate(player);
|
||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
@ -94,6 +95,7 @@ public sealed class CharacterSheetProviderTests
|
||||||
Assert.Equal(100L, sheet.XpToNextLevel);
|
Assert.Equal(100L, sheet.XpToNextLevel);
|
||||||
Assert.Equal(1f / 3f, sheet.XpFraction, precision: 4);
|
Assert.Equal(1f / 3f, sheet.XpFraction, precision: 4);
|
||||||
Assert.Equal(11, sheet.Strength); // ranks + start
|
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.
|
// Raise x1: Attributes[2] − xpSpent = 30 − 10; x10 clamps at curve end: 100 − 10.
|
||||||
Assert.Equal(20L, sheet.AttributeRaiseCosts[0]);
|
Assert.Equal(20L, sheet.AttributeRaiseCosts[0]);
|
||||||
Assert.Equal(90L, sheet.AttributeRaise10Costs[0]);
|
Assert.Equal(90L, sheet.AttributeRaise10Costs[0]);
|
||||||
|
|
@ -173,11 +175,11 @@ public sealed class CharacterSheetProviderTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleRaiseRequest_TrainSkill_DebitsFirstPresentCreditProperty()
|
public void HandleRaiseRequest_TrainSkill_DebitsRetailSkillCreditProperty()
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
var player = h.AddPlayerObject();
|
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,
|
h.Player.OnSkillUpdate(skillId: 6u, ranks: 0u, status: 1u, xp: 0u,
|
||||||
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // untrained
|
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((6u, 4u), h.SentTrain);
|
||||||
Assert.Equal(2u, h.Player.GetSkill(6u)!.Value.Status); // promoted to trained
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,8 @@ public sealed class IndicatorBarControllerTests
|
||||||
UiButton endSession = h.Button(IndicatorBarController.EndCharacterSessionButtonId);
|
UiButton endSession = h.Button(IndicatorBarController.EndCharacterSessionButtonId);
|
||||||
|
|
||||||
Assert.False(miniGame.Enabled);
|
Assert.False(miniGame.Enabled);
|
||||||
|
Assert.True(endSession.Enabled);
|
||||||
|
Assert.Equal(UiButtonStateMachine.Normal, endSession.ActiveRetailStateId);
|
||||||
controller.SetMiniGameActive(true);
|
controller.SetMiniGameActive(true);
|
||||||
Assert.True(miniGame.Enabled);
|
Assert.True(miniGame.Enabled);
|
||||||
miniGame.OnEvent(new UiEvent(0, miniGame, UiEventType.Click));
|
miniGame.OnEvent(new UiEvent(0, miniGame, UiEventType.Click));
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,17 @@ public sealed class IndicatorDetailPanelControllerTests
|
||||||
{
|
{
|
||||||
private const uint Player = 0x50000001u;
|
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]
|
[Fact]
|
||||||
public void AuthoredFixtures_AreRetailMainPanelPages()
|
public void AuthoredFixtures_AreRetailMainPanelPages()
|
||||||
{
|
{
|
||||||
|
|
@ -113,7 +124,11 @@ public sealed class IndicatorDetailPanelControllerTests
|
||||||
Bucket: 4u));
|
Bucket: 4u));
|
||||||
|
|
||||||
string text = VisibleText(layout, VitaeUiController.MainTextId);
|
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.Contains("earn 379 more experience", text, StringComparison.Ordinal);
|
||||||
Assert.Equal(479, VitaeUiController.VitaeCpPoolThreshold(0.9f, 10));
|
Assert.Equal(479, VitaeUiController.VitaeCpPoolThreshold(0.9f, 10));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,24 @@ public sealed class RetailWindowFrameTests
|
||||||
Assert.Equal(0.75f, frame.Opacity);
|
Assert.Equal(0.75f, frame.Opacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NineSlice_CanSupplyBorderWithoutDuplicatingAuthoredCenter()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
var content = new UiPanel { Width = 300, Height = 362 };
|
||||||
|
|
||||||
|
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||||
|
root, content, NoTex,
|
||||||
|
new RetailWindowFrame.Options
|
||||||
|
{
|
||||||
|
WindowName = "character-info",
|
||||||
|
DrawChromeCenter = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
var frame = Assert.IsType<UiNineSlicePanel>(handle.OuterFrame);
|
||||||
|
Assert.False(frame.DrawCenterFill);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CollapsibleMount_ReturnsToolbarFrameAndIndependentAxes()
|
public void CollapsibleMount_ReturnsToolbarFrameAndIndependentAxes()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,20 @@ public class BurdenMathTests
|
||||||
public void LoadRatio_is_burden_over_capacity(int cap, int burden, float expected)
|
public void LoadRatio_is_burden_over_capacity(int cap, int burden, float expected)
|
||||||
=> Assert.Equal(expected, BurdenMath.LoadRatio(cap, burden), 4);
|
=> 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]
|
[Theory]
|
||||||
[InlineData(0f, 0f)]
|
[InlineData(0f, 0f)]
|
||||||
[InlineData(0.5f, 0.16667f)]
|
[InlineData(0.5f, 0.16667f)]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue