feat(ui): Campaign CT slice CT4 — header identity block retail-exact
Retires the rest of AP-109's UI half: the character panel's Name/Heritage/ PkStatus/Level header identity block is now live and DAT-faithful on both Attributes and Skills pages (verified: CharacterStatController.Bind already scopes Label/LabelAuthoredColor to the ONE physically-visible page container, so both tabs share the same bound widgets). - Name/Heritage/PkStatus/Level switch from hand-picked Body/Gold runtime colors to the widget's own authored DefaultColor (LabelAuthoredColor) — CT1's live-DAT pin (HeaderElements_AuthorExpectedFontsAndColors) confirmed all four already carry the correct FontColor (white/white/white/pale-gold with Outline); the former "runtime color, dat carries none" comment was false. - PkStatus resolves through StringTable 0x23000001 by key (ID_StatManagement_Header_PKStatus_PK/_PKL/_NPK) with a bitwise IsPK/IsPKLite test (gmStatManagementUI::UpdatePKStatus @0x004F00A0) instead of the prior exact-equality switch, which silently dropped combined-flag PlayerKillerStatus values. Live-DAT-verified strings: "Player Killer" / "Player Killer Lite" / "Non-Player Killer" (new InstalledDat pin PkStatusKeys_ResolveExpectedAuthoredStrings). - Level shows "%d"-formatted InqInt(0x19) or the PE-recovered literal "???" when absent (CharacterSheet.Level is now int?). - Heritage line appends CT2/CT3's resolved RuntimeCharacterTitleState display title through CharacterTitleResolver, refreshing live on both TableReplaced (0x0029) and DisplayTitleChanged (0x002B) — CharacterSheetProvider's ChangeBinding now subscribes to both. - Name-line ruling: ships the PLAIN-NAME case only. Retail's allegiance rank-title prefix (AllegianceData::GetFullName @0x005B6950 -> AllegianceSystem::GetTitle @0x005B8DD0) needs a ~200-string, 22-function heritage x gender table (verbatim decomp literals, e.g. GetAluvianMaleTitle @0x005B7BC0's Yeoman/Baronet/.../High King) judged out of reasonable size for this slice. RuntimeAllegianceState already carries the local player's own rank; only the string table is missing. Registered, not silently omitted. - Luminance pair (0x100005C5/0x100005C6): CharacterSheet.AvailableLuminance/ MaximumLuminance (PropertyInt64 6/7) already flow generically through both the PlayerDescription snapshot and the live 0x02CF private-update parsers (no wiring gap). The retail show/hide gate (Level >= 200 && MaximumLuminance != 0, UpdateExperience @0x004F0A70) is wired and toggles Visible on both elements every sheet refresh; the exact caption/value text could not be recovered this slice (retail's SetText source resolves through a Binary-Ninja-mislabeled data pointer, not a StringTable key — a DAT string-table sweep found no match), so content stays unbound rather than guessed. - AP-109 narrowed accordingly (register row amended in the same commit). Tests: CharacterStatControllerTests (heritage composition + live title update, name stays plain, level int/"???" with authored — not constant — color across 3 cases, PK line shows resolved text in authored color across 3 statuses, luminance visibility across 5 level/luminance combinations) and CharacterSheetProviderTests (PK key-by-status resolution including a combined-flag case, no-resolver leaves PkStatus null, Level null-vs-present, title resolution + live refresh on both title events + unsubscribe-on- dispose, luminance Int64 read-through). Full hermetic solution suite green under Release (0 failures across all 14 test projects); InstalledDat pins green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
aa8106d57a
commit
ed652ed8ad
10 changed files with 646 additions and 32 deletions
File diff suppressed because one or more lines are too long
|
|
@ -265,12 +265,60 @@ row-template resolver already takes (`RetailUiRuntime.MountCharacter`),
|
|||
and set the list box's authored 24px row height so wheel/line scroll
|
||||
lands row-aligned.
|
||||
|
||||
**CT4 — Header identity block.** Retail composition: name; "<Gender>
|
||||
**CT4 — Header identity block. CODE-COMPLETE 2026-08-24.** Retail composition: name; "<Gender>
|
||||
<Heritage> <DisplayTitle>"; PK status line — authored fonts/colors
|
||||
(pure white per probe), live refresh on display-title change and PK
|
||||
status, identical on Attributes AND Skills pages. Level color from the
|
||||
authored element. Retires the rest of AP-109's UI half.
|
||||
|
||||
**CT4 landing notes (2026-08-24).** Verified the existing `Label(...)` seam
|
||||
already covers both Attributes/Skills page copies — `CharacterStatController`
|
||||
binds the SAME physically-visible container (contentPage = the Attributes
|
||||
page chain) for both tabs; the Skills-page duplicate header subtree is never
|
||||
shown (a test now pins this: `Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated`).
|
||||
All four header identity elements (Name/Heritage/PkStatus/Level) switched
|
||||
from hand-picked `Body`/`Gold` runtime colors to the widget's own authored
|
||||
`DefaultColor` (`LabelAuthoredColor`), matching CT1's live-DAT pin exactly —
|
||||
the former "runtime color, dat carries none" comment was false. PK status now
|
||||
resolves through StringTable `0x23000001` by key with a bitwise IsPK/IsPKLite
|
||||
test (the prior exact-equality switch silently dropped combined-flag
|
||||
values); **live-DAT-verified authored strings**: `ID_StatManagement_Header_PKStatus_PK`
|
||||
→ "Player Killer", `_PKL` → "Player Killer Lite", `_NPK` → "Non-Player Killer"
|
||||
(pinned in `CharacterPanelLiveDatTests.PkStatusKeys_ResolveExpectedAuthoredStrings`).
|
||||
Level shows `"%d"`-formatted `InqInt(0x19)` or the PE-recovered literal
|
||||
`"???"` when absent (`CharacterSheet.Level` is now `int?`). The heritage
|
||||
line's appended title now comes from CT2/CT3's `RuntimeCharacterTitleState.DisplayTitleId`
|
||||
resolved through `CharacterTitleResolver`, refreshing live on both
|
||||
`TableReplaced` and `DisplayTitleChanged` (`CharacterSheetProvider`'s
|
||||
`ChangeBinding` now subscribes to both). **Name-line ruling:** ships the
|
||||
PLAIN-NAME case only — retail's allegiance rank-title prefix
|
||||
(`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle
|
||||
@0x005b8dd0`) needs a ~200-string, 22-function heritage×gender table
|
||||
(verbatim hardcoded literals in the decomp, not DAT-resolved — e.g.
|
||||
`GetAluvianMaleTitle @0x005b7bc0`'s "Yeoman"/"Baronet"/"Baron"/"Reeve"/
|
||||
"Thane"/"Ealdor"/"Duke"/"Aetheling"/"King"/"High King") judged out of
|
||||
reasonable size for this slice; `RuntimeAllegianceState` already carries the
|
||||
local player's own rank, so only the string table is missing. **Luminance
|
||||
(item 5):** the DATA (`CharacterSheet.AvailableLuminance`/`MaximumLuminance`,
|
||||
PropertyInt64 6/7) already flows generically through both the
|
||||
PlayerDescription snapshot parser and the live `0x02CF` private-update path
|
||||
— no wiring gap existed — and the retail show/hide gate
|
||||
(`Level >= 200 && MaximumLuminance != 0`, `UpdateExperience @0x004F0A70`) is
|
||||
wired and toggles `Visible` on both `0x100005C5`/`0x100005C6`, but the
|
||||
label's caption and the value's composed number format could not be
|
||||
recovered this slice (retail's `SetText` source resolves through a
|
||||
Binary-Ninja-mislabeled data pointer, not a StringTable key; a DAT
|
||||
string-table sweep found no match) — content stays unbound rather than
|
||||
guessed. AP-109 narrowed accordingly (register row updated in the same
|
||||
commit, not deleted — the two open items above remain). Tests:
|
||||
`CharacterStatControllerTests` (heritage composition + live update, name
|
||||
stays plain, level int/"???" with authored — not constant — color, PK line
|
||||
shows resolved text in authored color, luminance visibility across five
|
||||
level/luminance combinations) and `CharacterSheetProviderTests` (PK
|
||||
key-by-status resolution including a combined-flag case, no-resolver ⇒ null,
|
||||
Level null-vs-present, title resolution + live refresh on both title
|
||||
events + unsubscribe-on-dispose, luminance Int64 read-through).
|
||||
|
||||
**CT5 — Row alignment + value gutter.** Reconcile our hand-built
|
||||
attribute/skill rows with the authored row templates from CT1: icon
|
||||
placement, name/value columns, the authored right margin that reserves
|
||||
|
|
|
|||
|
|
@ -527,6 +527,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
var cursorManager = new RetailCursorManager(d.Dats, d.DatLock);
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.CursorAssetsCreated);
|
||||
|
||||
// Campaign CT slice CT3 (2026-08-24): the Titles page's DAT
|
||||
// id -> display-string chain (CT2). Constructed once — its own
|
||||
// constructor does no DAT I/O (only .Resolve reads touch the
|
||||
// dats), matching the characterCreationStrings precedent below.
|
||||
// CT4 (2026-08-24) also feeds this resolver's display-title text
|
||||
// into the character panel's own heritage line (CharacterSheet.Title).
|
||||
var characterTitleResolver = new CharacterTitleResolver(d.Dats);
|
||||
// CT4: the header identity block's PK-status line (StringTable
|
||||
// 0x23000001, ID_StatManagement_Header_PKStatus_* keys — the same
|
||||
// compute_str_hash mechanism ChatWindowController's chatStrings
|
||||
// delegate already uses). One instance, same DatLock discipline
|
||||
// as characterTitleResolver above.
|
||||
var characterUiStrings = new DatStringResolver(d.Dats);
|
||||
var characterSheet = new CharacterSheetProvider(
|
||||
d.Inventory.Objects,
|
||||
d.Character.LocalPlayer,
|
||||
|
|
@ -553,12 +566,17 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
late.GameRuntime.Advance(
|
||||
RuntimeAdvancementKind.TrainSkill,
|
||||
statId,
|
||||
credits));
|
||||
// Campaign CT slice CT3 (2026-08-24): the Titles page's DAT
|
||||
// id -> display-string chain (CT2). Constructed once — its own
|
||||
// constructor does no DAT I/O (only .Resolve reads touch the
|
||||
// dats), matching the characterCreationStrings precedent below.
|
||||
var characterTitleResolver = new CharacterTitleResolver(d.Dats);
|
||||
credits),
|
||||
titles: d.Character.Titles,
|
||||
resolveDisplayTitle: titleId =>
|
||||
{
|
||||
lock (d.DatLock) return characterTitleResolver.Resolve(titleId);
|
||||
},
|
||||
resolveUiString: key =>
|
||||
{
|
||||
lock (d.DatLock)
|
||||
return characterUiStrings.Resolve(0x23000001u, DatStringResolver.ComputeHash(key));
|
||||
});
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated);
|
||||
|
||||
uint MagicSkillLevel(MagicSchool school)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,37 @@ namespace AcDream.App.UI.Layout;
|
|||
/// Retail character identity display helpers for gmStatManagementUI.
|
||||
/// Sources: gmStatManagementUI::UpdateCharacterInfo (0x004f0770) calls
|
||||
/// AppraisalSystem::InqGenderHeritageDisplay(gender 0x71, heritage 0xBC, 0),
|
||||
/// then appends the current CharacterTitleTable title when one is active.
|
||||
/// then — when CharacterTitleTable::GetCharacterTitleFromID(m_titleID)
|
||||
/// resolves — AppendText(separator @data_794358) + AppendText(titleString).
|
||||
/// Campaign CT slice CT4 (2026-08-24) PE-read RECOVERED the separator as a
|
||||
/// single space " " (both here and for the allegiance-rank prefix below);
|
||||
/// <see cref="Join"/>'s existing <c>string.Join(" ", ...)</c> already
|
||||
/// matched it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Name-line ruling (CT4, 2026-08-24).</b> Retail's NAME line
|
||||
/// (<c>AllegianceData::GetFullName @0x005b6950</c>) prefixes an allegiance
|
||||
/// RANK title ("<RankTitle> <Name>", same space separator, PE-read
|
||||
/// @data_794098) when <c>AllegianceSystem::GetTitle(rank, heritage, gender)
|
||||
/// @0x005b8dd0</c> resolves one. <see cref="AcDream.Runtime.Gameplay.RuntimeAllegianceState"/>
|
||||
/// (Campaign FA) DOES carry the local player's own rank
|
||||
/// (<c>ApplyUpdate</c>'s <c>_rank</c>, seeded by <c>0x0020
|
||||
/// AllegianceUpdate</c> — always the local tree), so the DATA half exists.
|
||||
/// The STRING half does not: <c>GetTitle</c> dispatches on heritage×gender
|
||||
/// into 22 separate functions (<c>GetAluvianMaleTitle @0x005b7bc0</c>,
|
||||
/// <c>GetAluvianFemaleTitle @0x005b7cd0</c>, … one per heritage/gender pair
|
||||
/// through Undead), each a rank-indexed switch over ~10 HARDCODED literal
|
||||
/// strings (Aluvian male: "Yeoman"/"Baronet"/"Baron"/"Reeve"/"Thane"/
|
||||
/// "Ealdor"/"Duke"/"Aetheling"/"King"/"High King" — verbatim from the
|
||||
/// decomp, not DAT-resolved, not guessed) — roughly 200 title strings
|
||||
/// total. That is not "reasonable size" for this slice on top of its other
|
||||
/// four items, so <see cref="CharacterStatController.Bind"/>'s Name label
|
||||
/// ships the PLAIN-NAME case only (matching the owner's own retail
|
||||
/// screenshot, a rankless character, and every current test character).
|
||||
/// The missing rank-prefix path is registered
|
||||
/// (<c>docs/architecture/retail-divergence-register.md</c>) rather than
|
||||
/// silently omitted.
|
||||
/// </remarks>
|
||||
internal static class CharacterIdentityText
|
||||
{
|
||||
public const uint GenderPropertyId = 0x71u;
|
||||
|
|
|
|||
|
|
@ -24,8 +24,15 @@ public sealed class CharacterSheet
|
|||
/// <summary>Character name (first line of the report).</summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Character level.</summary>
|
||||
public int Level { get; init; }
|
||||
/// <summary>
|
||||
/// Character level. Null when retail PropertyInt 0x19 is absent — Campaign
|
||||
/// CT slice CT4: <c>gmStatManagementUI::UpdateCharacterInfo</c>
|
||||
/// (0x004f0770) shows the literal <c>"???"</c> (data_7b0f34, PE-recovered)
|
||||
/// in that case rather than an integer; a bare formatted level uses
|
||||
/// <c>"%d"</c> semantics (data_7a0184) — see
|
||||
/// <see cref="CharacterStatController.LevelId"/>'s binding.
|
||||
/// </summary>
|
||||
public int? Level { get; init; }
|
||||
|
||||
/// <summary>Gender display string, e.g. "Female". Null = omit.</summary>
|
||||
public string? Gender { get; init; }
|
||||
|
|
@ -58,6 +65,20 @@ public sealed class CharacterSheet
|
|||
/// 0x10000233 (m_pPKStatusText). Null = omit.</summary>
|
||||
public string? PkStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CT slice CT4: available Luminance points (retail
|
||||
/// PropertyInt64 6, <c>AvailableLuminance</c>). Header element
|
||||
/// 0x100005C5/0x100005C6 pair — shown only past level 200 with a
|
||||
/// nonzero <see cref="MaximumLuminance"/>
|
||||
/// (<c>gmStatManagementUI::UpdateExperience</c> 0x004f0a70's luminance
|
||||
/// branch).
|
||||
/// </summary>
|
||||
public long AvailableLuminance { get; init; }
|
||||
|
||||
/// <summary>Retail PropertyInt64 7, <c>MaximumLuminance</c>. See
|
||||
/// <see cref="AvailableLuminance"/>.</summary>
|
||||
public long MaximumLuminance { get; init; }
|
||||
|
||||
// ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ─────────
|
||||
|
||||
/// <summary>Formatted birth date string (retail InqInt(0x62) → strftime).
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using AcDream.App.Net;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using DatReaderWriter;
|
||||
using AcDream.Content;
|
||||
|
||||
|
|
@ -48,6 +49,31 @@ public sealed class CharacterSheetProvider
|
|||
private readonly Action<uint, ulong>? _sendRaiseSkill;
|
||||
private readonly Action<uint, uint>? _sendTrainSkill;
|
||||
|
||||
/// <summary>Campaign CT slice CT2's title owner — its
|
||||
/// <c>DisplayTitleId</c> feeds the CT4 heritage-line composition.
|
||||
/// Null (tests, no live session) leaves <see cref="CharacterSheet.Title"/>
|
||||
/// null.</summary>
|
||||
private readonly RuntimeCharacterTitleState? _titles;
|
||||
|
||||
/// <summary>CT2's <c>CharacterTitleTable::GetCharacterTitleFromID</c> DAT
|
||||
/// chain (<see cref="CharacterTitleResolver.Resolve"/> in production,
|
||||
/// DatLock-wrapped by the host). Returns null (retail's hardcoded
|
||||
/// <c>"Unknown"</c> substitution belongs to the Titles-page controller,
|
||||
/// not the header — the header line simply omits an unresolved title)
|
||||
/// when the id doesn't resolve.</summary>
|
||||
private readonly Func<uint, string?>? _resolveDisplayTitle;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CT slice CT4: retail <c>StringInfo</c> lookup through
|
||||
/// StringTable 0x23000001 by key (<c>ID_StatManagement_Header_PKStatus_*</c>
|
||||
/// — <c>gmStatManagementUI::UpdatePKStatus</c> 0x004f00a0), the same
|
||||
/// <c>compute_str_hash</c> mechanism <c>ChatWindowController</c>'s
|
||||
/// <c>chatStrings</c> delegate uses. Null (tests) or a resolution miss
|
||||
/// both leave <see cref="CharacterSheet.PkStatus"/> null — no invented
|
||||
/// English fallback for this specific line (CT4 contract).
|
||||
/// </summary>
|
||||
private readonly Func<string, string?>? _resolveUiString;
|
||||
|
||||
/// <summary>Portal SkillTable (0x0E000004) — set by the host once dats load.</summary>
|
||||
public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; }
|
||||
|
||||
|
|
@ -64,7 +90,10 @@ public sealed class CharacterSheetProvider
|
|||
Action<uint, ulong>? sendRaiseAttribute = null,
|
||||
Action<uint, ulong>? sendRaiseVital = null,
|
||||
Action<uint, ulong>? sendRaiseSkill = null,
|
||||
Action<uint, uint>? sendTrainSkill = null)
|
||||
Action<uint, uint>? sendTrainSkill = null,
|
||||
RuntimeCharacterTitleState? titles = null,
|
||||
Func<uint, string?>? resolveDisplayTitle = null,
|
||||
Func<string, string?>? resolveUiString = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
|
||||
|
|
@ -76,6 +105,9 @@ public sealed class CharacterSheetProvider
|
|||
_sendRaiseVital = sendRaiseVital;
|
||||
_sendRaiseSkill = sendRaiseSkill;
|
||||
_sendTrainSkill = sendTrainSkill;
|
||||
_titles = titles;
|
||||
_resolveDisplayTitle = resolveDisplayTitle;
|
||||
_resolveUiString = resolveUiString;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -114,7 +146,14 @@ public sealed class CharacterSheetProvider
|
|||
return _fallbackSheet?.Invoke(CharacterName()) ?? new CharacterSheet { Name = CharacterName() };
|
||||
|
||||
var props = CurrentPlayerProperties();
|
||||
// #431/CT4: retail's own PropertyInt 0x19 read (InqInt) — the header
|
||||
// level's "???" fallback (CharacterSheet.Level's own doc comment)
|
||||
// needs to distinguish "absent" from "present but zero", so this
|
||||
// stays a raw dictionary probe rather than GetInt's zero-defaulting
|
||||
// helper. The XP-curve math below still wants a concrete int, so it
|
||||
// keeps using the 0-defaulted local.
|
||||
int level = props.GetInt(0x19u);
|
||||
int? displayLevel = props.Ints.ContainsKey(0x19u) ? level : null;
|
||||
long totalXp = props.GetInt64(1u);
|
||||
long unassignedXp = props.GetInt64(UnassignedXpPropertyId);
|
||||
var xp = ComputeLevelXp(level, totalXp);
|
||||
|
|
@ -124,15 +163,28 @@ public sealed class CharacterSheetProvider
|
|||
return new CharacterSheet
|
||||
{
|
||||
Name = CharacterName(),
|
||||
Level = level,
|
||||
Level = displayLevel,
|
||||
Gender = CharacterIdentityText.GenderDisplayName(
|
||||
props.GetInt(CharacterIdentityText.GenderPropertyId)),
|
||||
Heritage = CharacterIdentityText.HeritageGroupDisplayName(
|
||||
props.GetInt(CharacterIdentityText.HeritageGroupPropertyId)),
|
||||
PkStatus = PkStatusText(props.GetInt(134u, 0)),
|
||||
// CT2/CT3's resolved display title — CT4's heritage line appends
|
||||
// this (CharacterIdentityText.StatHeaderLine).
|
||||
Title = _titles is not null && _resolveDisplayTitle is not null
|
||||
? _resolveDisplayTitle(_titles.DisplayTitleId)
|
||||
: null,
|
||||
PkStatus = PkStatusText(props.GetInt(134u, 0), _resolveUiString),
|
||||
TotalXp = totalXp,
|
||||
XpToNextLevel = xp.toNext,
|
||||
XpFraction = xp.fraction,
|
||||
// CT4 item 5: retail PropertyInt64 6/7 — the private-update
|
||||
// (0x02CF) and PlayerDescription (0x0013) parsers both already
|
||||
// copy every Int64 key generically (ReadInt64Table /
|
||||
// LocalPlayerState.OnInt64PropertyUpdate have no id whitelist),
|
||||
// so ids 6/7 flow through with zero additional wiring once ACE
|
||||
// sends them.
|
||||
AvailableLuminance = props.GetInt64(6u),
|
||||
MaximumLuminance = props.GetInt64(7u),
|
||||
|
||||
HealthCurrent = VitalCurrent(LocalPlayerState.VitalKind.Health),
|
||||
HealthMax = VitalMax(LocalPlayerState.VitalKind.Health),
|
||||
|
|
@ -243,8 +295,19 @@ public sealed class CharacterSheetProvider
|
|||
// not only on raw property/attribute updates.
|
||||
if (owner._localPlayer.Spellbook is { } spellbook)
|
||||
spellbook.EnchantmentsChanged += OnCleared;
|
||||
// CT4 contract: the heritage line's appended display title MUST
|
||||
// refresh live on BOTH RuntimeCharacterTitleState notices — CT2's
|
||||
// TableReplaced (0x0029, retail's own unconditional Refresh()) and
|
||||
// DisplayTitleChanged (the display half of 0x002B).
|
||||
if (owner._titles is { } titles)
|
||||
{
|
||||
titles.TableReplaced += OnCleared;
|
||||
titles.DisplayTitleChanged += OnDisplayTitleChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisplayTitleChanged(uint _) => OnCleared();
|
||||
|
||||
private void OnObjectChanged(ClientObject value)
|
||||
{
|
||||
CharacterSheetProvider? owner = _owner;
|
||||
|
|
@ -300,6 +363,11 @@ public sealed class CharacterSheetProvider
|
|||
owner._localPlayer.Changed -= OnVitalChanged;
|
||||
if (owner._localPlayer.Spellbook is { } spellbook)
|
||||
spellbook.EnchantmentsChanged -= OnCleared;
|
||||
if (owner._titles is { } titles)
|
||||
{
|
||||
titles.TableReplaced -= OnCleared;
|
||||
titles.DisplayTitleChanged -= OnDisplayTitleChanged;
|
||||
}
|
||||
// Panel unmount resets the one-in-flight raise gate — retail's
|
||||
// awaiting flag lives on the panel instance and dies with it.
|
||||
owner.ReleaseAwaitingRaise();
|
||||
|
|
@ -496,13 +564,28 @@ public sealed class CharacterSheetProvider
|
|||
private static long ClampToLong(ulong value) =>
|
||||
value > long.MaxValue ? long.MaxValue : (long)value;
|
||||
|
||||
private static string? PkStatusText(int status) => status switch
|
||||
/// <summary>
|
||||
/// Campaign CT slice CT4: <c>gmStatManagementUI::UpdatePKStatus</c>
|
||||
/// (0x004f00a0) — <c>IsPK()</c> tested first, then <c>IsPKLite()</c>,
|
||||
/// else "neither" resolves the NPK string (retail always shows exactly
|
||||
/// one of the three; there is no hidden/omitted case). ACE's
|
||||
/// <c>PlayerKillerStatus</c> is a <c>[Flags]</c> enum (PK=0x04,
|
||||
/// PKLite=0x40) — a bitwise test matches the derived-boolean retail
|
||||
/// semantics; the prior exact-equality switch silently showed nothing
|
||||
/// for any combined-flag value. Text resolves through StringTable
|
||||
/// 0x23000001 by key (<see cref="_resolveUiString"/>) — no hardcoded
|
||||
/// English fallback; a null resolver or a resolution miss both leave
|
||||
/// the line empty, matching the CT4 contract's "no invented English".
|
||||
/// </summary>
|
||||
private static string? PkStatusText(int status, Func<string, string?>? resolveUiString)
|
||||
{
|
||||
0x2 => "Non-Player Killer",
|
||||
0x4 => "Player Killer",
|
||||
0x40 => "Player Killer Lite",
|
||||
_ => null,
|
||||
};
|
||||
string key = (status & 0x4) != 0
|
||||
? "ID_StatManagement_Header_PKStatus_PK"
|
||||
: (status & 0x40) != 0
|
||||
? "ID_StatManagement_Header_PKStatus_PKL"
|
||||
: "ID_StatManagement_Header_PKStatus_NPK";
|
||||
return resolveUiString?.Invoke(key);
|
||||
}
|
||||
|
||||
/// <summary>Unenchanted base attribute value (Ranks + Start). Used for
|
||||
/// <see cref="CharacterSheet.AttributeBaseValues"/> — the retail
|
||||
|
|
|
|||
|
|
@ -70,6 +70,17 @@ public static class CharacterStatController
|
|||
public const uint ListScrollbarId = 0x1000023Eu; // m_pListBox vertical scrollbar gutter
|
||||
public const uint ListDividerId = 0x1000023Fu; // bottom divider above footer
|
||||
|
||||
/// <summary>Campaign CT slice CT4: the luminance pair
|
||||
/// (m_pLuminanceLabelText/m_pLuminanceText), shown only past level 200
|
||||
/// with nonzero MaximumLuminance — see
|
||||
/// <c>gmStatManagementUI::UpdateExperience</c> (0x004f0a70)'s luminance
|
||||
/// branch. The label's own retail caption/value StringInfo could not be
|
||||
/// recovered this slice (its SetText calls resolve through a
|
||||
/// Binary-Ninja-mislabeled data pointer, not a StringTable key — see the
|
||||
/// Bind method's own remarks); only the show/hide gate is wired here.</summary>
|
||||
public const uint LuminanceLabelId = 0x100005C5u;
|
||||
public const uint LuminanceValueId = 0x100005C6u;
|
||||
|
||||
// ── Footer STATE-A container id ──────────────────────────────────────────
|
||||
// 0x10000240 is the "nothing selected" footer group. Its children (0x1000024E label row,
|
||||
// 0x10000242–0x10000245 labels+values) are the correct State-A versions with wider
|
||||
|
|
@ -111,7 +122,10 @@ public static class CharacterStatController
|
|||
public const uint RaiseTenId = 0x100005EBu; // raise × 10
|
||||
|
||||
private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
|
||||
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
|
||||
// Campaign CT slice CT4 (2026-08-24): the former hand-picked "Gold"
|
||||
// header-level color constant is deleted — CT1's live-DAT pin confirmed
|
||||
// the level element authors its own pale-gold FontColor (+ Outline);
|
||||
// LabelAuthoredColor reads it from the widget instead.
|
||||
|
||||
/// <summary>Row highlight color — semi-translucent gold, matches retail
|
||||
/// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent.</summary>
|
||||
|
|
@ -292,11 +306,22 @@ public static class CharacterStatController
|
|||
|
||||
// Name (18px from dat FontDid), Heritage (14px), PkStatus (14px):
|
||||
// Fix C: pass null → Label's null-guard keeps the build-time dat font.
|
||||
// Controllers still own the text color and the LinesProvider.
|
||||
// Name = WHITE (retail "Horan" is white — confirmed 2026-06-26).
|
||||
Label(layout, contentPage, NameId, null, Vector4.One, () => data().Name);
|
||||
Label(layout, contentPage, HeritageId, null, Body, () => CharacterIdentityText.StatHeaderLine(data()));
|
||||
Label(layout, contentPage, PkStatusId, null, Body, () => data().PkStatus ?? string.Empty);
|
||||
// Campaign CT slice CT4 (2026-08-24): CT1's live-DAT pin
|
||||
// (HeaderElements_AuthorExpectedFontsAndColors) confirmed all FOUR
|
||||
// header identity elements — Name, Heritage, PkStatus, Level — carry
|
||||
// their own authored FontColor (white/white/white/pale-gold with
|
||||
// Outline). The "runtime color, dat carries none" reasoning this
|
||||
// block used to justify a hand-picked Body/Gold constant per element
|
||||
// was FALSIFIED by that pin: every element below now sources its
|
||||
// color from the widget's own DAT-set DefaultColor
|
||||
// (LabelAuthoredColor), matching the "authored color/font wins"
|
||||
// pattern CT3's CharacterTitlesController already established for
|
||||
// its row/display text. Level's Outline is likewise already applied
|
||||
// at import time (DatWidgetFactory.BuildText reads dat property
|
||||
// 0x21) — no controller-side Outline flag needed.
|
||||
LabelAuthoredColor(layout, contentPage, NameId, null, () => data().Name);
|
||||
LabelAuthoredColor(layout, contentPage, HeritageId, null, () => CharacterIdentityText.StatHeaderLine(data()));
|
||||
LabelAuthoredColor(layout, contentPage, PkStatusId, null, () => data().PkStatus ?? string.Empty);
|
||||
|
||||
// ── Header captions (new — retail labels above/left of each number) ──────
|
||||
// LevelCaption (0x1000023A, 16px from dat): pass null → keep build-time dat font.
|
||||
|
|
@ -305,12 +330,13 @@ public static class CharacterStatController
|
|||
// Level number: retail renders this as large gold centered text in the 65×50 element.
|
||||
// Fix C: the dat FontDid for the level element (0x1000023B) is now applied at build
|
||||
// time when the font resolver is provided (studio path). We no longer force rowDatFont
|
||||
// here for the level — the dat's own FontDid drives the font. The Gold color is still
|
||||
// set via LinesProvider. SYNTHESIZED elements (the 9 attribute rows built in
|
||||
// BuildAttributeRows) continue to use datFont directly since they have no dat origin.
|
||||
// Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo 0x004f0770.
|
||||
// runtime color, dat carries none.
|
||||
Label(layout, contentPage, LevelId, null, Gold, () => data().Level.ToString());
|
||||
// here for the level — the dat's own FontDid drives the font.
|
||||
// Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo
|
||||
// 0x004f0770. CT4 contract (PE-recovered 2026-08-24): InqInt(0x19) present formats
|
||||
// with "%d" semantics (a bare integer, data_7a0184); absent shows the literal "???"
|
||||
// (data_7b0f34) — CharacterSheet.Level is null in that case.
|
||||
LabelAuthoredColor(layout, contentPage, LevelId, null,
|
||||
() => data().Level is int lvl ? lvl.ToString(CultureInfo.InvariantCulture) : "???");
|
||||
|
||||
// TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font.
|
||||
LabelLeft(layout, contentPage, TotalXpLabelId, null, Body, static () => "Total Experience (XP):");
|
||||
|
|
@ -367,6 +393,32 @@ public static class CharacterStatController
|
|||
}
|
||||
}
|
||||
|
||||
// ── Luminance pair (0x100005C5/C6) — CT4 item 5 ───────────────────────
|
||||
// gmStatManagementUI::UpdateExperience (0x004f0a70): InqInt64(6)
|
||||
// (AvailableLuminance) and InqInt64(7) (MaximumLuminance) are read
|
||||
// unconditionally, but the pair is hidden — UIElement_Text::ClearAllText
|
||||
// on BOTH m_pLuminanceLabelText and m_pLuminanceText — whenever
|
||||
// "InqInt(0x19) < 0xc8 (200) || MaximumLuminance == 0". Only the
|
||||
// gate is ported this slice: the label's caption and the value's
|
||||
// composed "available / maximum" string both resolve through a
|
||||
// SetText call whose source string BN mislabels as a vftable slot
|
||||
// (not a StringTable key like the PK line) — recovering the exact
|
||||
// literal needs a PE-byte-decode pass this slice didn't budget for
|
||||
// (register row: AP-109 narrows to exactly this). Content is
|
||||
// intentionally left unbound (blank) rather than guessed; only
|
||||
// Visible is toggled, so a level-200+ character sees an empty
|
||||
// (not wrong) pair until a follow-up slice fills it in.
|
||||
UiElement? luminanceLabel = FindElementByDatId(layout, contentPage, LuminanceLabelId);
|
||||
UiElement? luminanceValue = FindElementByDatId(layout, contentPage, LuminanceValueId);
|
||||
void RefreshLuminanceVisibility()
|
||||
{
|
||||
var sheet = data();
|
||||
bool visible = sheet.Level is int lvl && lvl >= 200 && sheet.MaximumLuminance != 0;
|
||||
if (luminanceLabel is not null) luminanceLabel.Visible = visible;
|
||||
if (luminanceValue is not null) luminanceValue.Visible = visible;
|
||||
}
|
||||
RefreshLuminanceVisibility();
|
||||
|
||||
// The tab visuals are already retained in the imported LayoutDesc. Controllers
|
||||
// bind only click behavior and the active Open/Closed state below.
|
||||
|
||||
|
|
@ -656,6 +708,11 @@ public static class CharacterStatController
|
|||
}
|
||||
|
||||
RefreshActiveRaiseButtons();
|
||||
// CT4: the luminance gate reads Level/MaximumLuminance off the
|
||||
// CURRENT sheet, so it must re-run on every sheet-changed refresh
|
||||
// (level-up, a luminance-award quality change), not only at bind
|
||||
// time.
|
||||
RefreshLuminanceVisibility();
|
||||
}
|
||||
|
||||
return () => RefreshAfterRaise(null);
|
||||
|
|
@ -2009,6 +2066,31 @@ public static class CharacterStatController
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same binding shape as <see cref="Label"/>, but the per-line color is
|
||||
/// read from the widget's own <see cref="UiText.DefaultColor"/> — the
|
||||
/// value <c>DatWidgetFactory.BuildText</c> already seeded from the
|
||||
/// element's authored dat property 0x1B — instead of a caller-supplied
|
||||
/// constant. Campaign CT slice CT4 (2026-08-24): the header identity
|
||||
/// block's four elements (Name/Heritage/PkStatus/Level) all carry their
|
||||
/// own correct authored color (CT1's live-DAT pin), so "authored color
|
||||
/// wins" here is both simpler and more correct than hand-picking a
|
||||
/// runtime constant — the same precedent
|
||||
/// <see cref="CharacterTitlesController"/>'s row/display text already
|
||||
/// set (<c>rowText.DefaultColor</c>).
|
||||
/// </summary>
|
||||
private static void LabelAuthoredColor(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Func<string> text)
|
||||
{
|
||||
if (FindTextByDatId(layout, scope, id) is UiText t)
|
||||
{
|
||||
if (datFont is not null) t.DatFont = datFont;
|
||||
t.Centered = true;
|
||||
t.OneLine = true;
|
||||
t.ClickThrough = true;
|
||||
t.LinesProvider = () => new[] { new UiText.Line(text(), t.DefaultColor) };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Two-line centered label. Provides TWO lines from LinesProvider so both
|
||||
/// fit side-by-side in a narrow element without truncation. The scroll path in
|
||||
/// <see cref="UiText"/> renders multiple lines oldest-first (top-to-bottom), so
|
||||
|
|
|
|||
|
|
@ -387,4 +387,33 @@ public sealed class CharacterPanelLiveDatTests
|
|||
string? resolved = resolver.Resolve(0x2300000Eu, hash);
|
||||
Assert.Equal("War Mage", resolved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CT slice CT4: <c>gmStatManagementUI::UpdatePKStatus</c>
|
||||
/// (0x004f00a0) resolves its three-way PK status text through
|
||||
/// StringTable <c>0x23000001</c> by key (the same compute_str_hash
|
||||
/// mechanism <c>ChatWindowController</c>'s chat labels already use — no
|
||||
/// EnumMapper indirection needed here, unlike the title chain above).
|
||||
/// Pins the exact authored strings <c>CharacterSheetProvider.PkStatusText</c>
|
||||
/// resolves against, discovered by a live probe against the installed
|
||||
/// DAT set (not guessed): "Player Killer" / "Player Killer Lite" /
|
||||
/// "Non-Player Killer".
|
||||
/// </summary>
|
||||
[InstalledDatFact]
|
||||
public void PkStatusKeys_ResolveExpectedAuthoredStrings()
|
||||
{
|
||||
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
|
||||
var resolver = new DatStringResolver(dats);
|
||||
|
||||
string? pk = resolver.Resolve(0x23000001u,
|
||||
DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_PK"));
|
||||
string? pkLite = resolver.Resolve(0x23000001u,
|
||||
DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_PKL"));
|
||||
string? npk = resolver.Resolve(0x23000001u,
|
||||
DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_NPK"));
|
||||
|
||||
Assert.Equal("Player Killer", pk);
|
||||
Assert.Equal("Player Killer Lite", pkLite);
|
||||
Assert.Equal("Non-Player Killer", npk);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using AcDream.Core.Items;
|
|||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
|
@ -420,4 +421,167 @@ public sealed class CharacterSheetProviderTests
|
|||
|
||||
Assert.Equal(0, changed);
|
||||
}
|
||||
|
||||
// ── Campaign CT slice CT4: PK status / display title / luminance ───────
|
||||
|
||||
/// <summary>
|
||||
/// <c>gmStatManagementUI::UpdatePKStatus</c> (0x004f00a0): IsPK() tested
|
||||
/// first, then IsPKLite(), else "neither" resolves NPK. The resolver
|
||||
/// stub below echoes the KEY it was handed, so the assertion proves
|
||||
/// which of the three <c>ID_StatManagement_Header_PKStatus_*</c> keys
|
||||
/// was selected for each ACE <c>PlayerKillerStatus</c> value — the CT4
|
||||
/// contract's "PK line resolves the three keys by status".
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x4, "ID_StatManagement_Header_PKStatus_PK")]
|
||||
[InlineData(0x40, "ID_StatManagement_Header_PKStatus_PKL")]
|
||||
[InlineData(0x2, "ID_StatManagement_Header_PKStatus_NPK")] // plain NPK bit
|
||||
[InlineData(0x0, "ID_StatManagement_Header_PKStatus_NPK")] // Undef — still resolves NPK, not omitted
|
||||
// Bitwise test (not the prior exact-equality switch): PK combined with
|
||||
// an unrelated flag (Unprotected, 0x08) still resolves PK — IsPK() is
|
||||
// true regardless of the other bits.
|
||||
[InlineData(0x4 | 0x8, "ID_StatManagement_Header_PKStatus_PK")]
|
||||
public void BuildSheet_PkStatus_ResolvesCorrectKeyByStatus(int rawStatus, string expectedKey)
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
var player = new LocalPlayerState();
|
||||
string? capturedKey = null;
|
||||
var provider = new CharacterSheetProvider(
|
||||
objects, player,
|
||||
playerGuid: () => PlayerGuid,
|
||||
resolveUiString: key =>
|
||||
{
|
||||
capturedKey = key;
|
||||
return key; // echo — the test asserts on the KEY, not invented English
|
||||
});
|
||||
|
||||
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
|
||||
obj.Properties.Ints[134u] = rawStatus;
|
||||
objects.AddOrUpdate(obj);
|
||||
|
||||
CharacterSheet sheet = provider.BuildSheet();
|
||||
|
||||
Assert.Equal(expectedKey, capturedKey);
|
||||
Assert.Equal(expectedKey, sheet.PkStatus);
|
||||
}
|
||||
|
||||
/// <summary>CT4 contract: "no invented English; if a key fails to
|
||||
/// resolve, show nothing" — a null resolver (no live DAT session, e.g.
|
||||
/// the Studio path) must not synthesize any PK text.</summary>
|
||||
[Fact]
|
||||
public void BuildSheet_PkStatus_NoResolver_LeavesPkStatusNull()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
var player = new LocalPlayerState();
|
||||
var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid);
|
||||
|
||||
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
|
||||
obj.Properties.Ints[134u] = 0x4; // PK
|
||||
objects.AddOrUpdate(obj);
|
||||
|
||||
Assert.Null(provider.BuildSheet().PkStatus);
|
||||
}
|
||||
|
||||
/// <summary>CT4 contract item 4: Level is null (not 0) when retail
|
||||
/// InqInt(0x19) is absent, distinguishing "no property yet" from a
|
||||
/// genuinely-present value.</summary>
|
||||
[Fact]
|
||||
public void BuildSheet_Level_NullWhenPropertyAbsent_PresentOtherwise()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
var player = new LocalPlayerState();
|
||||
var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid);
|
||||
|
||||
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
|
||||
obj.Properties.Ints[0x18u] = 1; // some OTHER property present so HasLiveData() is true
|
||||
objects.AddOrUpdate(obj);
|
||||
Assert.Null(provider.BuildSheet().Level);
|
||||
|
||||
obj.Properties.Ints[0x19u] = 42;
|
||||
objects.AddOrUpdate(obj);
|
||||
Assert.Equal(42, provider.BuildSheet().Level);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CT4 item 2: the heritage line's appended title comes from CT2's
|
||||
/// <see cref="RuntimeCharacterTitleState.DisplayTitleId"/> resolved
|
||||
/// through the DAT id->string chain — <see cref="CharacterSheet.Title"/>
|
||||
/// tracks whatever the resolver returns for the CURRENT display id.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildSheet_Title_ResolvesDisplayTitleIdThroughResolver()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
var player = new LocalPlayerState();
|
||||
var titles = new RuntimeCharacterTitleState();
|
||||
var provider = new CharacterSheetProvider(
|
||||
objects, player,
|
||||
playerGuid: () => PlayerGuid,
|
||||
titles: titles,
|
||||
resolveDisplayTitle: id => id == 13u ? "War Mage" : null);
|
||||
|
||||
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
|
||||
obj.Properties.Ints[0x19u] = 1; // some property present so HasLiveData() is true
|
||||
objects.AddOrUpdate(obj);
|
||||
|
||||
Assert.Null(provider.BuildSheet().Title); // no display title seeded yet
|
||||
|
||||
titles.ReplaceTable(13u, new uint[] { 13u });
|
||||
|
||||
Assert.Equal("War Mage", provider.BuildSheet().Title);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CT4 contract: the heritage line MUST refresh live on both
|
||||
/// <see cref="RuntimeCharacterTitleState.TableReplaced"/> (0x0029) and
|
||||
/// <see cref="RuntimeCharacterTitleState.DisplayTitleChanged"/> (the
|
||||
/// display half of 0x002B) — both must fire the sheet-changed
|
||||
/// notification <see cref="CharacterSheetProvider.SubscribeChanged"/>
|
||||
/// exposes, and both must stop firing after disposal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SubscribeChanged_FiresOnTitlesTableReplacedAndDisplayTitleChanged_AndUnsubscribesOnDispose()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
var player = new LocalPlayerState();
|
||||
var titles = new RuntimeCharacterTitleState();
|
||||
var provider = new CharacterSheetProvider(
|
||||
objects, player, playerGuid: () => PlayerGuid, titles: titles);
|
||||
int changed = 0;
|
||||
IDisposable subscription = provider.SubscribeChanged(() => changed++);
|
||||
|
||||
titles.ReplaceTable(1u, new uint[] { 1u }); // TableReplaced (+ DisplayTitleChanged, id 0->1)
|
||||
Assert.True(changed >= 1);
|
||||
|
||||
int afterFirst = changed;
|
||||
titles.ApplyUpdateTitle(2u, setAsDisplay: true); // UpdateTitle → DisplayTitleChanged (1->2)
|
||||
Assert.True(changed > afterFirst);
|
||||
|
||||
subscription.Dispose();
|
||||
int afterDispose = changed;
|
||||
titles.ReplaceTable(3u, new uint[] { 3u });
|
||||
Assert.Equal(afterDispose, changed);
|
||||
}
|
||||
|
||||
/// <summary>CT4 item 5: retail PropertyInt64 6 (AvailableLuminance) / 7
|
||||
/// (MaximumLuminance) flow into the sheet exactly like TotalXp/
|
||||
/// UnassignedXp — the same generic, non-whitelisted Int64 property
|
||||
/// path.</summary>
|
||||
[Fact]
|
||||
public void BuildSheet_Luminance_ReadsInt64Properties6And7()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
var player = new LocalPlayerState();
|
||||
var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid);
|
||||
|
||||
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
|
||||
obj.Properties.Int64s[6u] = 1_500_000L;
|
||||
obj.Properties.Int64s[7u] = 25_000_000L;
|
||||
objects.AddOrUpdate(obj);
|
||||
|
||||
var sheet = provider.BuildSheet();
|
||||
|
||||
Assert.Equal(1_500_000L, sheet.AvailableLuminance);
|
||||
Assert.Equal(25_000_000L, sheet.MaximumLuminance);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,146 @@ public class CharacterStatControllerTests
|
|||
Assert.Empty(hiddenXpNext.LinesProvider());
|
||||
}
|
||||
|
||||
// ── Campaign CT slice CT4: header identity block ───────────────────────
|
||||
|
||||
/// <summary>CT4 item 2: the heritage line composes Gender + Heritage +
|
||||
/// " " + the resolved display title (CharacterIdentityText.StatHeaderLine),
|
||||
/// and — because the label's <c>text()</c> provider re-reads <c>data()</c>
|
||||
/// on every draw — reflects a live title change with no rebind, exactly
|
||||
/// how <c>CharacterSheetProvider</c>'s own <c>DisplayTitleChanged</c>/
|
||||
/// <c>TableReplaced</c> subscription drives a real sheet rebuild in
|
||||
/// production.</summary>
|
||||
[Fact]
|
||||
public void Bind_HeritageLine_ComposesGenderHeritageTitle_AndUpdatesLiveOnDisplayTitleChange()
|
||||
{
|
||||
var heritage = new UiText();
|
||||
var layout = Fake((CharacterStatController.HeritageId, heritage));
|
||||
CharacterSheet sheet = new() { Gender = "Female", Heritage = "Aluvian" };
|
||||
|
||||
CharacterStatController.Bind(layout, () => sheet);
|
||||
|
||||
Assert.Equal("Female Aluvian", heritage.LinesProvider()[0].Text);
|
||||
|
||||
// Simulates RuntimeCharacterTitleState.DisplayTitleChanged firing and
|
||||
// CharacterSheetProvider rebuilding the sheet with the newly resolved
|
||||
// title — CharacterStatController never rebinds, the label's own
|
||||
// provider just re-reads the (reassigned) sheet.
|
||||
sheet = new CharacterSheet { Gender = "Female", Heritage = "Aluvian", Title = "War Mage" };
|
||||
Assert.Equal("Female Aluvian War Mage", heritage.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
/// <summary>CT4 item 1 ruling: the Name line ships the PLAIN-NAME case
|
||||
/// only — retail's allegiance rank-title prefix
|
||||
/// (<c>AllegianceData::GetFullName @0x005b6950</c>) needs a ~200-string
|
||||
/// 22-function heritage×gender table judged out of reasonable size for
|
||||
/// this slice (AP-109). The Name label must show exactly
|
||||
/// <see cref="CharacterSheet.Name"/>, with no rank prefix synthesized
|
||||
/// from anywhere.</summary>
|
||||
[Fact]
|
||||
public void Bind_NameLine_ShowsPlainNameOnly_NoRankPrefix()
|
||||
{
|
||||
var name = new UiText();
|
||||
var layout = Fake((CharacterStatController.NameId, name));
|
||||
|
||||
CharacterStatController.Bind(layout, () => new CharacterSheet { Name = "Dww" });
|
||||
|
||||
Assert.Equal("Dww", name.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
/// <summary>CT4 item 4: the level shows a bare <c>"%d"</c>-formatted
|
||||
/// integer when <see cref="CharacterSheet.Level"/> is present, and the
|
||||
/// PE-recovered literal <c>"???"</c> when it is null (retail InqInt(0x19)
|
||||
/// absent). Both cases use the WIDGET's own authored
|
||||
/// <see cref="UiText.DefaultColor"/> — not a hardcoded constant — proving
|
||||
/// the CT1 "authored color/font wins" fix (the former "Gold" constant is
|
||||
/// deleted from the controller entirely).</summary>
|
||||
[Theory]
|
||||
[InlineData(126, "126")]
|
||||
[InlineData(0, "0")]
|
||||
[InlineData(null, "???")]
|
||||
public void Bind_LevelLine_FormatsIntegerOrShowsQuestionMarks_InAuthoredColor(int? level, string expectedText)
|
||||
{
|
||||
var authoredColor = new Vector4(0.11f, 0.22f, 0.33f, 1f);
|
||||
var levelText = new UiText { DefaultColor = authoredColor };
|
||||
var layout = Fake((CharacterStatController.LevelId, levelText));
|
||||
|
||||
CharacterStatController.Bind(layout, () => new CharacterSheet { Level = level });
|
||||
|
||||
UiText.Line line = Assert.Single(levelText.LinesProvider());
|
||||
Assert.Equal(expectedText, line.Text);
|
||||
Assert.Equal(authoredColor, line.Color);
|
||||
}
|
||||
|
||||
/// <summary>CT4 item 3: the PK line shows exactly whatever
|
||||
/// <see cref="CharacterSheet.PkStatus"/> carries (the resolved
|
||||
/// StringTable text — see <c>CharacterSheetProviderTests</c> for the
|
||||
/// three-key resolution itself) in the widget's own authored color, not
|
||||
/// the deleted parchment "Body" constant.</summary>
|
||||
[Theory]
|
||||
[InlineData("Player Killer")]
|
||||
[InlineData("Player Killer Lite")]
|
||||
[InlineData("Non-Player Killer")]
|
||||
public void Bind_PkStatusLine_ShowsResolvedText_InAuthoredColor(string resolvedText)
|
||||
{
|
||||
var authoredColor = new Vector4(0.4f, 0.5f, 0.6f, 1f);
|
||||
var pk = new UiText { DefaultColor = authoredColor };
|
||||
var layout = Fake((CharacterStatController.PkStatusId, pk));
|
||||
|
||||
CharacterStatController.Bind(layout, () => new CharacterSheet { PkStatus = resolvedText });
|
||||
|
||||
UiText.Line line = Assert.Single(pk.LinesProvider());
|
||||
Assert.Equal(resolvedText, line.Text);
|
||||
Assert.Equal(authoredColor, line.Color);
|
||||
}
|
||||
|
||||
/// <summary>An unresolved PK key (CT4 contract: "no invented English; if
|
||||
/// a key fails to resolve, show nothing") shows an empty line rather
|
||||
/// than a fabricated English fallback.</summary>
|
||||
[Fact]
|
||||
public void Bind_PkStatusLine_NullPkStatus_ShowsEmptyText()
|
||||
{
|
||||
var pk = new UiText();
|
||||
var layout = Fake((CharacterStatController.PkStatusId, pk));
|
||||
|
||||
CharacterStatController.Bind(layout, () => new CharacterSheet { PkStatus = null });
|
||||
|
||||
Assert.Equal(string.Empty, pk.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
/// <summary>CT4 item 5: the luminance pair (0x100005C5/0x100005C6)
|
||||
/// toggles Visible per retail's exact gate — <c>UpdateExperience</c>
|
||||
/// (0x004f0a70): "InqInt(0x19) < 200 || MaximumLuminance == 0" hides
|
||||
/// both elements; otherwise both show. The elements' own content is left
|
||||
/// unbound this slice (register row AP-109) — only visibility is
|
||||
/// asserted here.</summary>
|
||||
[Theory]
|
||||
[InlineData(126, 0L, false)] // below level 200 — hidden regardless of luminance
|
||||
[InlineData(200, 0L, false)] // level gate met, but MaximumLuminance == 0 — hidden
|
||||
[InlineData(200, 1_000_000L, true)] // both conditions met — visible
|
||||
[InlineData(275, 500L, true)]
|
||||
[InlineData(null, 500L, false)] // absent level — treated as "not level 200+"
|
||||
public void Bind_LuminancePair_TogglesVisibility_PerRetailGate(int? level, long maxLuminance, bool expectedVisible)
|
||||
{
|
||||
var label = new UiDatElement(
|
||||
new ElementInfo { Id = CharacterStatController.LuminanceLabelId, Type = 3 },
|
||||
static _ => (0u, 0, 0));
|
||||
var value = new UiDatElement(
|
||||
new ElementInfo { Id = CharacterStatController.LuminanceValueId, Type = 3 },
|
||||
static _ => (0u, 0, 0));
|
||||
var layout = Fake(
|
||||
(CharacterStatController.LuminanceLabelId, label),
|
||||
(CharacterStatController.LuminanceValueId, value));
|
||||
|
||||
CharacterStatController.Bind(layout, () => new CharacterSheet
|
||||
{
|
||||
Level = level,
|
||||
MaximumLuminance = maxLuminance,
|
||||
});
|
||||
|
||||
Assert.Equal(expectedVisible, label.Visible);
|
||||
Assert.Equal(expectedVisible, value.Visible);
|
||||
}
|
||||
|
||||
// ── XP meter fill ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue