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:
Erik 2026-08-25 00:02:11 +02:00
parent aa8106d57a
commit ed652ed8ad
10 changed files with 646 additions and 32 deletions

File diff suppressed because one or more lines are too long

View file

@ -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 and set the list box's authored 24px row height so wheel/line scroll
lands row-aligned. 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 <Heritage> <DisplayTitle>"; PK status line — authored fonts/colors
(pure white per probe), live refresh on display-title change and PK (pure white per probe), live refresh on display-title change and PK
status, identical on Attributes AND Skills pages. Level color from the status, identical on Attributes AND Skills pages. Level color from the
authored element. Retires the rest of AP-109's UI half. 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 **CT5 — Row alignment + value gutter.** Reconcile our hand-built
attribute/skill rows with the authored row templates from CT1: icon attribute/skill rows with the authored row templates from CT1: icon
placement, name/value columns, the authored right margin that reserves placement, name/value columns, the authored right margin that reserves

View file

@ -527,6 +527,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
var cursorManager = new RetailCursorManager(d.Dats, d.DatLock); var cursorManager = new RetailCursorManager(d.Dats, d.DatLock);
checkpoint(InteractionRetainedUiCompositionPoint.CursorAssetsCreated); 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( var characterSheet = new CharacterSheetProvider(
d.Inventory.Objects, d.Inventory.Objects,
d.Character.LocalPlayer, d.Character.LocalPlayer,
@ -553,12 +566,17 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
late.GameRuntime.Advance( late.GameRuntime.Advance(
RuntimeAdvancementKind.TrainSkill, RuntimeAdvancementKind.TrainSkill,
statId, statId,
credits)); credits),
// Campaign CT slice CT3 (2026-08-24): the Titles page's DAT titles: d.Character.Titles,
// id -> display-string chain (CT2). Constructed once — its own resolveDisplayTitle: titleId =>
// constructor does no DAT I/O (only .Resolve reads touch the {
// dats), matching the characterCreationStrings precedent below. lock (d.DatLock) return characterTitleResolver.Resolve(titleId);
var characterTitleResolver = new CharacterTitleResolver(d.Dats); },
resolveUiString: key =>
{
lock (d.DatLock)
return characterUiStrings.Resolve(0x23000001u, DatStringResolver.ComputeHash(key));
});
checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated); checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated);
uint MagicSkillLevel(MagicSchool school) uint MagicSkillLevel(MagicSchool school)

View file

@ -4,8 +4,37 @@ namespace AcDream.App.UI.Layout;
/// Retail character identity display helpers for gmStatManagementUI. /// Retail character identity display helpers for gmStatManagementUI.
/// Sources: gmStatManagementUI::UpdateCharacterInfo (0x004f0770) calls /// Sources: gmStatManagementUI::UpdateCharacterInfo (0x004f0770) calls
/// AppraisalSystem::InqGenderHeritageDisplay(gender 0x71, heritage 0xBC, 0), /// 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> /// </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 ("&lt;RankTitle&gt; &lt;Name&gt;", 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 internal static class CharacterIdentityText
{ {
public const uint GenderPropertyId = 0x71u; public const uint GenderPropertyId = 0x71u;

View file

@ -24,8 +24,15 @@ public sealed class CharacterSheet
/// <summary>Character name (first line of the report).</summary> /// <summary>Character name (first line of the report).</summary>
public string Name { get; init; } = string.Empty; public string Name { get; init; } = string.Empty;
/// <summary>Character level.</summary> /// <summary>
public int Level { get; init; } /// 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> /// <summary>Gender display string, e.g. "Female". Null = omit.</summary>
public string? Gender { get; init; } public string? Gender { get; init; }
@ -58,6 +65,20 @@ public sealed class CharacterSheet
/// 0x10000233 (m_pPKStatusText). Null = omit.</summary> /// 0x10000233 (m_pPKStatusText). Null = omit.</summary>
public string? PkStatus { get; init; } 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) ───────── // ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ─────────
/// <summary>Formatted birth date string (retail InqInt(0x62) → strftime). /// <summary>Formatted birth date string (retail InqInt(0x62) → strftime).

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using AcDream.App.Net; using AcDream.App.Net;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Player; using AcDream.Core.Player;
using AcDream.Runtime.Gameplay;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content; using AcDream.Content;
@ -48,6 +49,31 @@ public sealed class CharacterSheetProvider
private readonly Action<uint, ulong>? _sendRaiseSkill; private readonly Action<uint, ulong>? _sendRaiseSkill;
private readonly Action<uint, uint>? _sendTrainSkill; 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> /// <summary>Portal SkillTable (0x0E000004) — set by the host once dats load.</summary>
public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; } public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; }
@ -64,7 +90,10 @@ public sealed class CharacterSheetProvider
Action<uint, ulong>? sendRaiseAttribute = null, Action<uint, ulong>? sendRaiseAttribute = null,
Action<uint, ulong>? sendRaiseVital = null, Action<uint, ulong>? sendRaiseVital = null,
Action<uint, ulong>? sendRaiseSkill = 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)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer)); _localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
@ -76,6 +105,9 @@ public sealed class CharacterSheetProvider
_sendRaiseVital = sendRaiseVital; _sendRaiseVital = sendRaiseVital;
_sendRaiseSkill = sendRaiseSkill; _sendRaiseSkill = sendRaiseSkill;
_sendTrainSkill = sendTrainSkill; _sendTrainSkill = sendTrainSkill;
_titles = titles;
_resolveDisplayTitle = resolveDisplayTitle;
_resolveUiString = resolveUiString;
} }
/// <summary> /// <summary>
@ -114,7 +146,14 @@ public sealed class CharacterSheetProvider
return _fallbackSheet?.Invoke(CharacterName()) ?? new CharacterSheet { Name = CharacterName() }; return _fallbackSheet?.Invoke(CharacterName()) ?? new CharacterSheet { Name = CharacterName() };
var props = CurrentPlayerProperties(); 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 level = props.GetInt(0x19u);
int? displayLevel = props.Ints.ContainsKey(0x19u) ? level : null;
long totalXp = props.GetInt64(1u); long totalXp = props.GetInt64(1u);
long unassignedXp = props.GetInt64(UnassignedXpPropertyId); long unassignedXp = props.GetInt64(UnassignedXpPropertyId);
var xp = ComputeLevelXp(level, totalXp); var xp = ComputeLevelXp(level, totalXp);
@ -124,15 +163,28 @@ public sealed class CharacterSheetProvider
return new CharacterSheet return new CharacterSheet
{ {
Name = CharacterName(), Name = CharacterName(),
Level = level, Level = displayLevel,
Gender = CharacterIdentityText.GenderDisplayName( Gender = CharacterIdentityText.GenderDisplayName(
props.GetInt(CharacterIdentityText.GenderPropertyId)), props.GetInt(CharacterIdentityText.GenderPropertyId)),
Heritage = CharacterIdentityText.HeritageGroupDisplayName( Heritage = CharacterIdentityText.HeritageGroupDisplayName(
props.GetInt(CharacterIdentityText.HeritageGroupPropertyId)), 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, TotalXp = totalXp,
XpToNextLevel = xp.toNext, XpToNextLevel = xp.toNext,
XpFraction = xp.fraction, 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), HealthCurrent = VitalCurrent(LocalPlayerState.VitalKind.Health),
HealthMax = VitalMax(LocalPlayerState.VitalKind.Health), HealthMax = VitalMax(LocalPlayerState.VitalKind.Health),
@ -243,7 +295,18 @@ public sealed class CharacterSheetProvider
// not only on raw property/attribute updates. // not only on raw property/attribute updates.
if (owner._localPlayer.Spellbook is { } spellbook) if (owner._localPlayer.Spellbook is { } spellbook)
spellbook.EnchantmentsChanged += OnCleared; 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) private void OnObjectChanged(ClientObject value)
{ {
@ -300,6 +363,11 @@ public sealed class CharacterSheetProvider
owner._localPlayer.Changed -= OnVitalChanged; owner._localPlayer.Changed -= OnVitalChanged;
if (owner._localPlayer.Spellbook is { } spellbook) if (owner._localPlayer.Spellbook is { } spellbook)
spellbook.EnchantmentsChanged -= OnCleared; 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 // Panel unmount resets the one-in-flight raise gate — retail's
// awaiting flag lives on the panel instance and dies with it. // awaiting flag lives on the panel instance and dies with it.
owner.ReleaseAwaitingRaise(); owner.ReleaseAwaitingRaise();
@ -496,13 +564,28 @@ public sealed class CharacterSheetProvider
private static long ClampToLong(ulong value) => private static long ClampToLong(ulong value) =>
value > long.MaxValue ? long.MaxValue : (long)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", string key = (status & 0x4) != 0
0x4 => "Player Killer", ? "ID_StatManagement_Header_PKStatus_PK"
0x40 => "Player Killer Lite", : (status & 0x40) != 0
_ => null, ? "ID_StatManagement_Header_PKStatus_PKL"
}; : "ID_StatManagement_Header_PKStatus_NPK";
return resolveUiString?.Invoke(key);
}
/// <summary>Unenchanted base attribute value (Ranks + Start). Used for /// <summary>Unenchanted base attribute value (Ranks + Start). Used for
/// <see cref="CharacterSheet.AttributeBaseValues"/> — the retail /// <see cref="CharacterSheet.AttributeBaseValues"/> — the retail

View file

@ -70,6 +70,17 @@ public static class CharacterStatController
public const uint ListScrollbarId = 0x1000023Eu; // m_pListBox vertical scrollbar gutter public const uint ListScrollbarId = 0x1000023Eu; // m_pListBox vertical scrollbar gutter
public const uint ListDividerId = 0x1000023Fu; // bottom divider above footer 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 ────────────────────────────────────────── // ── Footer STATE-A container id ──────────────────────────────────────────
// 0x10000240 is the "nothing selected" footer group. Its children (0x1000024E label row, // 0x10000240 is the "nothing selected" footer group. Its children (0x1000024E label row,
// 0x100002420x10000245 labels+values) are the correct State-A versions with wider // 0x100002420x10000245 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 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 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 /// <summary>Row highlight color — semi-translucent gold, matches retail
/// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent.</summary> /// 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): // Name (18px from dat FontDid), Heritage (14px), PkStatus (14px):
// Fix C: pass null → Label's null-guard keeps the build-time dat font. // Fix C: pass null → Label's null-guard keeps the build-time dat font.
// Controllers still own the text color and the LinesProvider. // Campaign CT slice CT4 (2026-08-24): CT1's live-DAT pin
// Name = WHITE (retail "Horan" is white — confirmed 2026-06-26). // (HeaderElements_AuthorExpectedFontsAndColors) confirmed all FOUR
Label(layout, contentPage, NameId, null, Vector4.One, () => data().Name); // header identity elements — Name, Heritage, PkStatus, Level — carry
Label(layout, contentPage, HeritageId, null, Body, () => CharacterIdentityText.StatHeaderLine(data())); // their own authored FontColor (white/white/white/pale-gold with
Label(layout, contentPage, PkStatusId, null, Body, () => data().PkStatus ?? string.Empty); // 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) ────── // ── Header captions (new — retail labels above/left of each number) ──────
// LevelCaption (0x1000023A, 16px from dat): pass null → keep build-time dat font. // 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. // 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 // 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 // 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 // here for the level — the dat's own FontDid drives the font.
// set via LinesProvider. SYNTHESIZED elements (the 9 attribute rows built in // Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo
// BuildAttributeRows) continue to use datFont directly since they have no dat origin. // 0x004f0770. CT4 contract (PE-recovered 2026-08-24): InqInt(0x19) present formats
// Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo 0x004f0770. // with "%d" semantics (a bare integer, data_7a0184); absent shows the literal "???"
// runtime color, dat carries none. // (data_7b0f34) — CharacterSheet.Level is null in that case.
Label(layout, contentPage, LevelId, null, Gold, () => data().Level.ToString()); 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. // TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font.
LabelLeft(layout, contentPage, TotalXpLabelId, null, Body, static () => "Total Experience (XP):"); 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 // The tab visuals are already retained in the imported LayoutDesc. Controllers
// bind only click behavior and the active Open/Closed state below. // bind only click behavior and the active Open/Closed state below.
@ -656,6 +708,11 @@ public static class CharacterStatController
} }
RefreshActiveRaiseButtons(); 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); 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 /// <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 /// 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 /// <see cref="UiText"/> renders multiple lines oldest-first (top-to-bottom), so

View file

@ -387,4 +387,33 @@ public sealed class CharacterPanelLiveDatTests
string? resolved = resolver.Resolve(0x2300000Eu, hash); string? resolved = resolver.Resolve(0x2300000Eu, hash);
Assert.Equal("War Mage", resolved); 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);
}
} }

View file

@ -5,6 +5,7 @@ using AcDream.Core.Items;
using AcDream.Core.Player; using AcDream.Core.Player;
using AcDream.Core.Properties; using AcDream.Core.Properties;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
using Xunit; using Xunit;
namespace AcDream.App.Tests.UI.Layout; namespace AcDream.App.Tests.UI.Layout;
@ -420,4 +421,167 @@ public sealed class CharacterSheetProviderTests
Assert.Equal(0, changed); 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-&gt;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);
}
} }

View file

@ -157,6 +157,146 @@ public class CharacterStatControllerTests
Assert.Empty(hiddenXpNext.LinesProvider()); 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) &lt; 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 ──────────────────────────────────────────────────────── // ── XP meter fill ────────────────────────────────────────────────────────
[Fact] [Fact]