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

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

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;

View file

@ -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).

View file

@ -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

View file

@ -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,
// 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
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