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

@ -157,6 +157,146 @@ public class CharacterStatControllerTests
Assert.Empty(hiddenXpNext.LinesProvider());
}
// ── Campaign CT slice CT4: header identity block ───────────────────────
/// <summary>CT4 item 2: the heritage line composes Gender + Heritage +
/// " " + the resolved display title (CharacterIdentityText.StatHeaderLine),
/// and — because the label's <c>text()</c> provider re-reads <c>data()</c>
/// on every draw — reflects a live title change with no rebind, exactly
/// how <c>CharacterSheetProvider</c>'s own <c>DisplayTitleChanged</c>/
/// <c>TableReplaced</c> subscription drives a real sheet rebuild in
/// production.</summary>
[Fact]
public void Bind_HeritageLine_ComposesGenderHeritageTitle_AndUpdatesLiveOnDisplayTitleChange()
{
var heritage = new UiText();
var layout = Fake((CharacterStatController.HeritageId, heritage));
CharacterSheet sheet = new() { Gender = "Female", Heritage = "Aluvian" };
CharacterStatController.Bind(layout, () => sheet);
Assert.Equal("Female Aluvian", heritage.LinesProvider()[0].Text);
// Simulates RuntimeCharacterTitleState.DisplayTitleChanged firing and
// CharacterSheetProvider rebuilding the sheet with the newly resolved
// title — CharacterStatController never rebinds, the label's own
// provider just re-reads the (reassigned) sheet.
sheet = new CharacterSheet { Gender = "Female", Heritage = "Aluvian", Title = "War Mage" };
Assert.Equal("Female Aluvian War Mage", heritage.LinesProvider()[0].Text);
}
/// <summary>CT4 item 1 ruling: the Name line ships the PLAIN-NAME case
/// only — retail's allegiance rank-title prefix
/// (<c>AllegianceData::GetFullName @0x005b6950</c>) needs a ~200-string
/// 22-function heritage×gender table judged out of reasonable size for
/// this slice (AP-109). The Name label must show exactly
/// <see cref="CharacterSheet.Name"/>, with no rank prefix synthesized
/// from anywhere.</summary>
[Fact]
public void Bind_NameLine_ShowsPlainNameOnly_NoRankPrefix()
{
var name = new UiText();
var layout = Fake((CharacterStatController.NameId, name));
CharacterStatController.Bind(layout, () => new CharacterSheet { Name = "Dww" });
Assert.Equal("Dww", name.LinesProvider()[0].Text);
}
/// <summary>CT4 item 4: the level shows a bare <c>"%d"</c>-formatted
/// integer when <see cref="CharacterSheet.Level"/> is present, and the
/// PE-recovered literal <c>"???"</c> when it is null (retail InqInt(0x19)
/// absent). Both cases use the WIDGET's own authored
/// <see cref="UiText.DefaultColor"/> — not a hardcoded constant — proving
/// the CT1 "authored color/font wins" fix (the former "Gold" constant is
/// deleted from the controller entirely).</summary>
[Theory]
[InlineData(126, "126")]
[InlineData(0, "0")]
[InlineData(null, "???")]
public void Bind_LevelLine_FormatsIntegerOrShowsQuestionMarks_InAuthoredColor(int? level, string expectedText)
{
var authoredColor = new Vector4(0.11f, 0.22f, 0.33f, 1f);
var levelText = new UiText { DefaultColor = authoredColor };
var layout = Fake((CharacterStatController.LevelId, levelText));
CharacterStatController.Bind(layout, () => new CharacterSheet { Level = level });
UiText.Line line = Assert.Single(levelText.LinesProvider());
Assert.Equal(expectedText, line.Text);
Assert.Equal(authoredColor, line.Color);
}
/// <summary>CT4 item 3: the PK line shows exactly whatever
/// <see cref="CharacterSheet.PkStatus"/> carries (the resolved
/// StringTable text — see <c>CharacterSheetProviderTests</c> for the
/// three-key resolution itself) in the widget's own authored color, not
/// the deleted parchment "Body" constant.</summary>
[Theory]
[InlineData("Player Killer")]
[InlineData("Player Killer Lite")]
[InlineData("Non-Player Killer")]
public void Bind_PkStatusLine_ShowsResolvedText_InAuthoredColor(string resolvedText)
{
var authoredColor = new Vector4(0.4f, 0.5f, 0.6f, 1f);
var pk = new UiText { DefaultColor = authoredColor };
var layout = Fake((CharacterStatController.PkStatusId, pk));
CharacterStatController.Bind(layout, () => new CharacterSheet { PkStatus = resolvedText });
UiText.Line line = Assert.Single(pk.LinesProvider());
Assert.Equal(resolvedText, line.Text);
Assert.Equal(authoredColor, line.Color);
}
/// <summary>An unresolved PK key (CT4 contract: "no invented English; if
/// a key fails to resolve, show nothing") shows an empty line rather
/// than a fabricated English fallback.</summary>
[Fact]
public void Bind_PkStatusLine_NullPkStatus_ShowsEmptyText()
{
var pk = new UiText();
var layout = Fake((CharacterStatController.PkStatusId, pk));
CharacterStatController.Bind(layout, () => new CharacterSheet { PkStatus = null });
Assert.Equal(string.Empty, pk.LinesProvider()[0].Text);
}
/// <summary>CT4 item 5: the luminance pair (0x100005C5/0x100005C6)
/// toggles Visible per retail's exact gate — <c>UpdateExperience</c>
/// (0x004f0a70): "InqInt(0x19) &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 ────────────────────────────────────────────────────────
[Fact]