fix(ui): Campaign CT4 fix round — luminance text, verbatim title, PK PWD bits

Opus dual-lens review of ed652ed8 found 2 blockers + 5 should-fix. All applied.

BLOCKERS:
- Bind the luminance pair (0x100005C5/0x100005C6): caption "Luminance:"
  (UTF-16, PE-byte-decoded from the gmStatManagementUI vftable-adjacent
  data at @0x007c3dd4) and value "<available> / <maximum>" (narrow
  "%s / %s" @0x007c3dcc) — both literals independently re-derived from the
  raw acclient.exe bytes and confirmed byte-exact against the review's
  claim. Numbers format through a new shared FormatXp helper
  (.ToString("N0", InvariantCulture) — retail's ExperienceSystem::XPToString
  equivalent), also now used by Total XP / XP-to-next-level (previously an
  un-invariant bare "N0"). Hide path switched from Visible=false to
  retail's own UIElement_Text::ClearAllText mechanism
  (@0x004f0e31/@0x004f0e3c — empty LinesProvider, leave layout); each
  LinesProvider re-reads data() on every draw, so no separate refresh call
  is needed.
- CharacterIdentityText.StripLeadingArticle deleted: retail AppendText's
  the resolved title VERBATIM (@0x004f0990); 26 real ACE CharacterTitle
  entries begin with "The" and were being mangled. The dead
  CharacterSheet.Race fallback is deleted alongside it — retail's
  InqGenderHeritageDisplay creature-type argument is a hardcoded literal 0
  (@0x004f08db), no producer exists.

SHOULD-FIX:
- PK line re-sourced: classifies off the live ClientObject.PublicWeenieBitfield
  PWD bits (0x20 IsPK / 0x02000000 IsPKLite — ACCWeenieObject::IsPK/IsPKLite
  @0x0058c8b0/@0x0058c8a0) instead of a bitwise test against raw
  PropertyInt 134, which carries ACE's own PlayerKillerStatus enum bit
  layout, not the PWD layout. PropertyInt 134 already drives the correct
  bits via the existing PlayerKillerStatusBitfield.Apply; this is a
  re-source, not new wiring. Deleted the 0x4|0x8 combined-flag test case,
  which asserted a non-retail answer.
- Register AP-109 row: restores CT3's Titles-page narrowing paragraph
  (CT4's edit had compressed it to a bare pointer phrase), corrects the
  rank-prefix source to PropertyInt 0x1E (AllegianceRank) read live off
  the qualities bundle — not RuntimeAllegianceState, which is a different
  UI's (SocialAllegiancePageController) own documented substitute —
  corrects the title-table size from an estimated 22 functions/~200
  strings to the actual 17 functions/~170 strings (AllegianceSystem::GetTitle's
  dispatch switch read directly), and downgrades the evidence claim.
  Filed AP-235 for the gender/heritage hardcoded-table-vs-live-EnumMapper
  mechanism divergence, pointing at the ALREADY-EXISTING
  RetailDataIdResolver.Resolve helper as CT5's unification seam.
- CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors
  extended with the luminance pair's own occurrence-count + font/color
  pins, matching every other header id's pattern.

Also landed: an InstalledDat pin
(GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain) proving
CharacterIdentityText.GenderDisplayName/HeritageGroupDisplayName match the
live retail EnumMapper chain (master map category 1 ->
ClientEnumToID[0x10000001]/[0x10000002] -> EnumMapper DIDs
0x2200000A/0x2200000B) byte-exact, including the two entries the review
flagged as unverified guesses (10 "Penumbraen", 12 "Olthoi" — both
correct). CharacterSheetProvider.BuildSheet's level read switched from a
GetInt+ContainsKey double lookup to one TryGetValue. Plan ledger's
test-provenance sentence corrected (Bind_HeaderElements_... predates CT4,
extended to cover PkStatusId).

Tests: CharacterStatControllerTests (verbatim title incl. "The Noob",
luminance content/gate, luminance text binding, extended
Bind_HeaderElements_... covering PkStatusId), CharacterSheetProviderTests
(PK status driven through ClientObjectTable.UpdateIntProperty instead of
a raw property write), CharacterPanelLiveDatTests (luminance pin, gender/
heritage EnumMapper pin). Full hermetic solution suite green under Release
(0 failures, 15 projects); InstalledDat pins green (197/197, excluding one
confirmed pre-existing unrelated failure — TowerAscentReplayTests, verified
to fail identically with these changes stashed out).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-25 00:54:33 +02:00
parent ed652ed8ad
commit e7e32409c2
9 changed files with 472 additions and 117 deletions

View file

@ -12,47 +12,72 @@ namespace AcDream.App.UI.Layout;
/// matched it.
/// </summary>
/// <remarks>
/// <b>Name-line ruling (CT4, 2026-08-24).</b> Retail's NAME line
/// <b>Name-line ruling (CT4, 2026-08-24; corrected at the CT4 fix round,
/// 2026-08-25 — AP-109).</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.
/// @0x005b8dd0</c> resolves one. The RANK value is PropertyInt <c>0x1E</c>
/// (<c>AllegianceRank</c>) read LIVE off the qualities bundle
/// (<c>CBaseQualities::InqInt(qualities, 0x1e)</c>) — NOT
/// <see cref="AcDream.Runtime.Gameplay.RuntimeAllegianceState"/>. That state
/// class carries a numerically-equivalent rank for a DIFFERENT UI
/// (<c>SocialAllegiancePageController</c>, which has no qualities-bundle
/// access of its own); <see cref="CharacterSheetProvider.BuildSheet"/>
/// already reads every other header property straight off
/// <c>props.GetInt(...)</c>, so a future port reads
/// <c>props.GetInt(0x1Eu)</c> directly instead. The STRING half is missing:
/// <c>GetTitle</c>'s own dispatch switch (read directly, not estimated) has
/// exactly 17 <c>Get*Title</c> functions, not 22 — Gearknight/Tumerok author
/// only a MALE function (reused for both genders' dispatch branches) and
/// Lugian only a FEMALE one (likewise reused both ways), so 11 heritages
/// produce 17 functions; Olthoi/OlthoiAcid have none at all (the dispatch's
/// own unsigned range check excludes heritage ids 12/13). Each function is 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 170 title strings total. That is not "reasonable
/// size" for one slice on top of its other work, 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>, AP-109) rather
/// than silently omitted.
/// </remarks>
internal static class CharacterIdentityText
{
public const uint GenderPropertyId = 0x71u;
public const uint HeritageGroupPropertyId = 0xBCu;
/// <summary>
/// CT4 fix round (2026-08-25, BLOCKER 2): retail's <c>AppendText</c> at
/// <c>@0x004f0990</c> appends the resolved <c>CharacterTitleTable</c>
/// string VERBATIM — no article stripping. 26 real ACE
/// <c>CharacterTitle</c> entries begin with "The" (e.g. "The Noob"), so
/// the former <c>StripLeadingArticle</c> call mangled every one of them.
/// Heritage also drops its <see cref="CharacterSheet.Race"/> fallback the
/// same round: <c>InqGenderHeritageDisplay</c>'s third argument
/// (creature type) is a hardcoded literal <c>0</c> at
/// <c>@0x004f08db</c>, not sourced from any producer — retail has no
/// "race" input to this line at all.
/// </summary>
public static string StatHeaderLine(CharacterSheet sheet)
{
string? heritage = !string.IsNullOrWhiteSpace(sheet.Heritage)
? sheet.Heritage
: sheet.Race;
string? title = StripLeadingArticle(sheet.Title);
if (string.IsNullOrWhiteSpace(sheet.Gender))
return Join(heritage, title);
return Join(sheet.Gender, heritage, title);
return Join(sheet.Heritage, sheet.Title);
return Join(sheet.Gender, sheet.Heritage, sheet.Title);
}
/// <summary>
/// Retail: <c>AppraisalSystem::InqGenderDisplayName @0x005b47c0</c> →
/// <c>EnumMapper::GetString(0x10000001, gender, ...)</c> →
/// <c>DBObj::GetDIDByEnum</c> (master map category 1, EnumMapper DID
/// <c>0x2200000A</c>) — a LIVE DAT read. This table is a hardcoded C#
/// mechanism substitute (register row AP-235); its content is verified
/// byte-exact against the live EnumMapper by the InstalledDat pin
/// <c>CharacterPanelLiveDatTests.GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain</c>.
/// </summary>
public static string? GenderDisplayName(int gender) => gender switch
{
1 => "Male",
@ -60,6 +85,19 @@ internal static class CharacterIdentityText
_ => null,
};
/// <summary>
/// Retail: <c>AppraisalSystem::InqHeritageGroupDisplayName @0x005b4710</c>
/// hardcodes ids 2/5/0xd to "Gharu'ndim"/"Umbraen"/"Olthoi", else falls
/// through to <c>EnumMapper::GetString(0x10000002, heritage, ...)</c> →
/// <c>DBObj::GetDIDByEnum</c> (master map category 1, EnumMapper DID
/// <c>0x2200000B</c>) — a LIVE DAT read whose raw entries are the
/// internal names ("Gharundim", "Shadowbound", "OlthoiAcid" for those
/// same three ids). This table is a hardcoded C# mechanism substitute
/// (register row AP-235); every entry, including the ones the CT4 review
/// flagged as unverified guesses (10 "Penumbraen", 12 "Olthoi"), is
/// verified byte-exact against the live EnumMapper chain by
/// <see cref="GenderDisplayName"/>'s sibling InstalledDat pin.
/// </summary>
public static string? HeritageGroupDisplayName(int heritageGroup) => heritageGroup switch
{
1 => "Aluvian",
@ -84,14 +122,4 @@ internal static class CharacterIdentityText
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p!.Trim()));
}
private static string? StripLeadingArticle(string? title)
{
if (string.IsNullOrWhiteSpace(title)) return null;
string trimmed = title.Trim();
return trimmed.StartsWith("the ", System.StringComparison.OrdinalIgnoreCase)
? trimmed[4..]
: trimmed;
}
}

View file

@ -37,7 +37,15 @@ public sealed class CharacterSheet
/// <summary>Gender display string, e.g. "Female". Null = omit.</summary>
public string? Gender { get; init; }
/// <summary>Race string, e.g. "Aluvian". Null = omit.</summary>
/// <summary>Race string, e.g. "Aluvian". Null = omit.
/// CT4 fix round (2026-08-25): no longer read by
/// <see cref="CharacterIdentityText.StatHeaderLine"/> — retail's
/// <c>InqGenderHeritageDisplay</c> creature-type argument is a hardcoded
/// literal <c>0</c> (<c>@0x004f08db</c>), with no producer for a "race"
/// value distinct from <see cref="Heritage"/>. Left in place as an
/// unpopulated field rather than deleted, since no current producer sets
/// it either; a future consumer needing a genuinely distinct race value
/// should confirm a real retail source exists first.</summary>
public string? Race { get; init; }
/// <summary>Heritage group display string, e.g. "Aluvian". Null = omit.</summary>

View file

@ -151,9 +151,11 @@ public sealed class CharacterSheetProvider
// 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;
// keeps using the 0-defaulted local. CT4 fix round: one TryGetValue
// instead of a ContainsKey-then-indexer double lookup.
bool hasLevel = props.Ints.TryGetValue(0x19u, out int levelValue);
int level = hasLevel ? levelValue : 0;
int? displayLevel = hasLevel ? levelValue : null;
long totalXp = props.GetInt64(1u);
long unassignedXp = props.GetInt64(UnassignedXpPropertyId);
var xp = ComputeLevelXp(level, totalXp);
@ -173,7 +175,7 @@ public sealed class CharacterSheetProvider
Title = _titles is not null && _resolveDisplayTitle is not null
? _resolveDisplayTitle(_titles.DisplayTitleId)
: null,
PkStatus = PkStatusText(props.GetInt(134u, 0), _resolveUiString),
PkStatus = PkStatusText(CurrentPlayerBitfield(), _resolveUiString),
TotalXp = totalXp,
XpToNextLevel = xp.toNext,
XpFraction = xp.fraction,
@ -270,6 +272,25 @@ public sealed class CharacterSheetProvider
: _localPlayer.Properties;
}
/// <summary>
/// CT4 fix round (2026-08-25, SHOULD-FIX 3): the local player's live
/// <c>PublicWeenieDesc</c> bitfield — the ONLY source retail's PK line
/// actually reads (<c>ACCWeenieObject::IsPK</c>/<c>IsPKLite</c>, PWD bits
/// 0x20/0x02000000). Only available once the live <see cref="ClientObject"/>
/// has arrived (CreateObject); the PlayerDescription snapshot on
/// <see cref="LocalPlayerState"/> carries no bitfield, so this returns 0
/// (retail's own "neither" → NPK default) until then — matching
/// <see cref="AcDream.App.Streaming.LocalPlayerTeleportController.IsLocalPlayerKiller"/>'s
/// precedent read.
/// </summary>
private uint CurrentPlayerBitfield()
{
uint guid = _playerGuid();
return guid != 0u && _objects.Get(guid) is { } player
? player.PublicWeenieBitfield ?? 0u
: 0u;
}
private sealed class ChangeBinding : IDisposable
{
private CharacterSheetProvider? _owner;
@ -565,28 +586,48 @@ public sealed class CharacterSheetProvider
value > long.MaxValue ? long.MaxValue : (long)value;
/// <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".
/// Campaign CT slice CT4, re-sourced at the CT4 fix round (2026-08-25,
/// SHOULD-FIX 3): <c>gmStatManagementUI::UpdatePKStatus</c>
/// (0x004f00a0) — <c>eax->vtable->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). <c>ACCWeenieObject::IsPK</c>/<c>IsPKLite</c>
/// (<c>@0x0058c8b0</c>/<c>@0x0058c8a0</c>) read the live
/// <c>PublicWeenieDesc</c> BITFIELD directly — bit 5 (<c>0x20</c>) and
/// bit 0x19 (<c>0x02000000</c>) — NOT PropertyInt 134
/// (<c>PlayerKillerStatus</c>) bitwise-tested against ACE's own enum
/// values (a prior version of this method did that; ACE's enum bit
/// layout is not the PWD bit layout, so a combined-flag PropertyInt
/// value like <c>0x4 | 0x8</c> would misclassify). PropertyInt 134
/// already drives the correct bits via
/// <see cref="PlayerKillerStatusBitfield.Apply"/> — see
/// <see cref="ClientObjectTable.UpdateIntProperty"/> — so this method
/// only needs to read <see cref="ClientObject.PublicWeenieBitfield"/>,
/// matching the precedent read at
/// <see cref="AcDream.App.Streaming.LocalPlayerTeleportController.IsLocalPlayerKiller"/>.
/// 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)
private static string? PkStatusText(uint publicWeenieBitfield, Func<string, string?>? resolveUiString)
{
string key = (status & 0x4) != 0
string key = (publicWeenieBitfield & PkPwdBit) != 0u
? "ID_StatManagement_Header_PKStatus_PK"
: (status & 0x40) != 0
: (publicWeenieBitfield & PkLitePwdBit) != 0u
? "ID_StatManagement_Header_PKStatus_PKL"
: "ID_StatManagement_Header_PKStatus_NPK";
return resolveUiString?.Invoke(key);
}
/// <summary>PWD bit 5 — <c>ACCWeenieObject::IsPK @0x0058c8b0</c>:
/// <c>(bitfield &gt;&gt; 5) &amp; 1</c>.</summary>
private const uint PkPwdBit = 0x20u;
/// <summary>PWD bit 0x19 (25) — <c>ACCWeenieObject::IsPKLite @0x0058c8a0</c>:
/// <c>(bitfield &gt;&gt; 0x19) &amp; 1</c>.</summary>
private const uint PkLitePwdBit = 0x02000000u;
/// <summary>Unenchanted base attribute value (Ranks + Start). Used for
/// <see cref="CharacterSheet.AttributeBaseValues"/> — the retail
/// footer-title delta parenthetical compares this against

View file

@ -74,13 +74,20 @@ public static class CharacterStatController
/// (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>
/// branch. CT4 fix round (2026-08-25, BLOCKER 1): the caption/value
/// SetText calls PE-byte-decoded from the <c>gmStatManagementUI</c>
/// vftable-adjacent data region — caption UTF-16 <c>"Luminance:"</c> at
/// <c>@0x007c3dd4</c>, value narrow <c>"%s / %s"</c> at
/// <c>@0x007c3dcc</c> — are now bound; see the Bind method's own
/// remarks.</summary>
public const uint LuminanceLabelId = 0x100005C5u;
public const uint LuminanceValueId = 0x100005C6u;
/// <summary>Retail literal <c>"Luminance:"</c> — see
/// <see cref="LuminanceLabelId"/>'s remarks for the PE-byte-decode
/// citation.</summary>
private const string LuminanceCaption = "Luminance:";
// ── 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
@ -340,7 +347,7 @@ public static class CharacterStatController
// TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font.
LabelLeft(layout, contentPage, TotalXpLabelId, null, Body, static () => "Total Experience (XP):");
LabelRight(layout, contentPage, TotalXpId, null, Body, () => data().TotalXp.ToString("N0"));
LabelRight(layout, contentPage, TotalXpId, null, Body, () => FormatXp(data().TotalXp));
// XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70).
// Fix 5: child elements 0x10000237 (label) and 0x10000238 (value) are now built by
@ -389,35 +396,51 @@ public static class CharacterStatController
xpValue.RightAligned = true;
xpValue.OneLine = true;
xpValue.Padding = 0f; // avoid scroll clip
xpValue.LinesProvider = () => new[] { new UiText.Line(data().XpToNextLevel.ToString("N0"), Body) };
xpValue.LinesProvider = () => new[] { new UiText.Line(FormatXp(data().XpToNextLevel), Body) };
}
}
// ── Luminance pair (0x100005C5/C6) — CT4 item 5 ───────────────────────
// ── Luminance pair (0x100005C5/C6) — CT4 item 5, text bound at the ──
// ── CT4 fix round (2026-08-25, BLOCKER 1) ────────────────────────────
// 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()
// unconditionally; the pair is hidden — UIElement_Text::ClearAllText
// (@0x004f0e31/@0x004f0e3c) on BOTH m_pLuminanceLabelText and
// m_pLuminanceText — whenever "InqInt(0x19) < 0xc8 (200) ||
// MaximumLuminance == 0". ClearAllText empties the widget's text and
// leaves layout/Visible untouched, so this binds the SAME way every
// other dynamic label in this method does: a LinesProvider that
// re-reads data() on every draw and returns an EMPTY line set when
// the gate is closed (retail's ClearAllText) or the resolved content
// when it is open — no separate "refresh" call is needed, and no
// Visible flag is touched. Retail's SetText calls (recovered by
// PE-byte-decoding the gmStatManagementUI vftable-adjacent data
// region, since Binary Ninja mislabels the two string pointers as
// vftable slots rather than a StringTable key like the PK line):
// caption = literal "Luminance:" (UTF-16 @0x007c3dd4); value =
// narrow "%s / %s" (@0x007c3dcc) with (available, maximum) in that
// order, both formatted through ExperienceSystem::XPToString — ported
// as the shared FormatXp helper below (the same one Total XP / XP-to-
// next-level already use).
bool LuminanceVisible(CharacterSheet sheet) =>
sheet.Level is int lvl && lvl >= 200 && sheet.MaximumLuminance != 0;
if (FindTextByDatId(layout, contentPage, LuminanceLabelId) is UiText luminanceLabel)
{
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;
luminanceLabel.LinesProvider = () => LuminanceVisible(data())
? new[] { new UiText.Line(LuminanceCaption, luminanceLabel.DefaultColor) }
: Array.Empty<UiText.Line>();
}
if (FindTextByDatId(layout, contentPage, LuminanceValueId) is UiText luminanceValue)
{
luminanceValue.LinesProvider = () =>
{
var sheet = data();
if (!LuminanceVisible(sheet)) return Array.Empty<UiText.Line>();
string text = $"{FormatXp(sheet.AvailableLuminance)} / {FormatXp(sheet.MaximumLuminance)}";
return new[] { new UiText.Line(text, luminanceValue.DefaultColor) };
};
}
RefreshLuminanceVisibility();
// The tab visuals are already retained in the imported LayoutDesc. Controllers
// bind only click behavior and the active Open/Closed state below.
@ -708,11 +731,10 @@ 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();
// CT4 fix round: the luminance pair's LinesProvider re-reads
// data() on every draw (same as every other dynamic label here),
// so no explicit refresh call is needed for a level-up or a
// luminance-award quality change.
}
return () => RefreshAfterRaise(null);
@ -2066,6 +2088,19 @@ public static class CharacterStatController
}
}
/// <summary>
/// Retail-equivalent of <c>ExperienceSystem::XPToString</c>
/// (<c>sprintf("%I64d", value)</c> → <c>GetNumberFormatA</c>'s
/// locale-grouped-decimal formatting) — shared by every field that
/// formats a retail XP-shaped 64-bit count: Total XP, XP-to-next-level,
/// and (CT4 fix round, 2026-08-25) the luminance available/maximum pair.
/// Retail text is US-formatted for everyone (the project's locale-
/// independence rule), so this is <c>InvariantCulture</c>, not
/// <c>CurrentCulture</c> — the pre-CT4-fix-round call sites used a bare
/// <c>.ToString("N0")</c>, which silently followed the host OS locale.
/// </summary>
private static string FormatXp(long value) => value.ToString("N0", CultureInfo.InvariantCulture);
/// <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
@ -2077,7 +2112,17 @@ public static class CharacterStatController
/// 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>).
/// set (<c>rowText.DefaultColor</c>). CT4 fix-round consistency note
/// (2026-08-25): this helper unconditionally forces
/// <c>Centered = true</c>/<c>OneLine = true</c>, which is correct for
/// the four elements it is actually called on (Name/Heritage/PkStatus/
/// Level, all centered in the DAT), but would be WRONG for a
/// left/right-justified authored element (e.g. the luminance pair,
/// which is deliberately bound with its own inline LinesProvider below
/// rather than through this helper, precisely to preserve its authored
/// Left/Right justification). Left as-is rather than parameterizing
/// Centered/OneLine, since no current caller needs the non-centered
/// case — a future caller that does should not reuse this helper as-is.
/// </summary>
private static void LabelAuthoredColor(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Func<string> text)
{