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

View file

@ -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 ("&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
{
public const uint GenderPropertyId = 0x71u;