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

@ -110,6 +110,28 @@ public sealed class CharacterPanelLiveDatTests
Assert.Equal(2, xpValueOccurrences.Count);
foreach (var xpValue in xpValueOccurrences)
Assert.Equal(0x40000000u, xpValue.FontDid);
// CT4 fix round (2026-08-25, SHOULD-FIX 5): the luminance pair's own
// binding-seam pin — same occurrence-count pattern as every other
// header id above (Attributes-page + Skills-page duplicate chains).
// Font/color ground truth from
// docs/research/2026-08-24-campaign-ct-dat-ground-truth.md: both
// elements author font 0x40000000, pure white, no outline.
var luminanceLabelOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.LuminanceLabelId).ToList();
Assert.Equal(2, luminanceLabelOccurrences.Count);
foreach (var luminanceLabel in luminanceLabelOccurrences)
{
Assert.Equal(0x40000000u, luminanceLabel.FontDid);
Assert.Equal(Vector4.One, luminanceLabel.FontColor);
}
var luminanceValueOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.LuminanceValueId).ToList();
Assert.Equal(2, luminanceValueOccurrences.Count);
foreach (var luminanceValue in luminanceValueOccurrences)
{
Assert.Equal(0x40000000u, luminanceValue.FontDid);
Assert.Equal(Vector4.One, luminanceValue.FontColor);
}
}
/// <summary>
@ -388,6 +410,79 @@ public sealed class CharacterPanelLiveDatTests
Assert.Equal("War Mage", resolved);
}
/// <summary>
/// CT4 fix round item 6: retail resolves gender via
/// <c>AppraisalSystem::InqGenderDisplayName @0x005b47c0</c> and heritage
/// via <c>InqHeritageGroupDisplayName @0x005b4710</c>, both through the
/// STATIC <c>EnumMapper::GetString(uint32_t enumValue, uint32_t queryId,
/// PStringBase&lt;char&gt;*) @0x0041ac40</c> overload, which itself calls
/// <c>DBObj::GetDIDByEnum(&amp;did, enumValue, 1)</c> — master map
/// (<c>0x25000000</c>) category-1 sub-map (<c>0x25000001</c>), then
/// <c>ClientEnumToID[0x10000001]</c> (gender) / <c>[0x10000002]</c>
/// (heritage) resolve to EnumMapper DIDs <c>0x2200000A</c> /
/// <c>0x2200000B</c>. Both mappers' <c>IdToStringMap</c> entries are used
/// VERBATIM as final display text (no further StringTable hash step, per
/// the decomp) EXCEPT heritage ids 2/5/0xd, which retail hardcodes to
/// "Gharu'ndim"/"Umbraen"/"Olthoi" instead of the raw
/// "Gharundim"/"Shadowbound"/"OlthoiAcid" internal names. This pin proves
/// every <see cref="CharacterIdentityText.GenderDisplayName"/> /
/// <see cref="CharacterIdentityText.HeritageGroupDisplayName"/> table
/// entry matches that exact algorithm against the live installed DAT —
/// catching a wrong guess (none found: ids 10 "Penumbraen" and 12
/// "Olthoi", flagged as unverified guesses in the CT4 review, both come
/// back byte-exact). See the register's CT4 mechanism-divergence row for
/// why the tables stay hardcoded rather than reading this chain live.
/// </summary>
[InstalledDatFact]
public void GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain()
{
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
bool gotMaster = dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(
(uint)dats.Portal.Header.MasterMapId, out var master);
Assert.True(gotMaster);
Assert.NotNull(master);
Assert.True(master!.ClientEnumToID.TryGetValue(1u, out uint categoryDid));
bool gotCategoryMap = dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(categoryDid, out var categoryMap);
Assert.True(gotCategoryMap);
Assert.NotNull(categoryMap);
Assert.True(categoryMap!.ClientEnumToID.TryGetValue(0x10000001u, out uint genderDid));
Assert.Equal(0x2200000Au, genderDid);
Assert.True(dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumMapper>(genderDid, out var genderMapper));
Assert.NotNull(genderMapper);
foreach (var (id, raw) in genderMapper!.IdToStringMap)
{
string? expected = raw.Value == "Invalid" ? null : raw.Value;
Assert.Equal(expected, CharacterIdentityText.GenderDisplayName((int)id));
}
Assert.True(categoryMap.ClientEnumToID.TryGetValue(0x10000002u, out uint heritageDid));
Assert.Equal(0x2200000Bu, heritageDid);
Assert.True(dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumMapper>(heritageDid, out var heritageMapper));
Assert.NotNull(heritageMapper);
// AppraisalSystem::InqHeritageGroupDisplayName's three hardcoded
// overrides (@0x005b4718/0x005b4732/0x005b474c) — applied ahead of
// the raw EnumMapper text, exactly like the retail branch order.
var overrides = new Dictionary<uint, string>
{
[2u] = "Gharu'ndim",
[5u] = "Umbraen",
[0xDu] = "Olthoi",
};
foreach (var (id, raw) in heritageMapper!.IdToStringMap)
{
string? expected = overrides.TryGetValue(id, out string? overridden)
? overridden
: raw.Value == "Invalid" ? null : raw.Value;
Assert.Equal(expected, CharacterIdentityText.HeritageGroupDisplayName((int)id));
}
}
/// <summary>
/// Campaign CT slice CT4: <c>gmStatManagementUI::UpdatePKStatus</c>
/// (0x004f00a0) resolves its three-way PK status text through

View file

@ -431,16 +431,27 @@ public sealed class CharacterSheetProviderTests
/// 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".
/// <para>
/// CT4 fix round (2026-08-25, SHOULD-FIX 3): drives the wire property
/// through <see cref="ClientObjectTable.UpdateIntProperty"/> — the SAME
/// path the live <c>0x02CE</c>/<c>0x02CD</c> PropertyInt handler uses,
/// which applies <see cref="PlayerKillerStatusBitfield.Apply"/> to
/// <see cref="ClientObject.PublicWeenieBitfield"/> — instead of writing
/// PropertyInt 134 directly into the bundle. Retail's PK line reads the
/// PWD bits (<c>ACCWeenieObject::IsPK</c>/<c>IsPKLite</c>), never
/// PropertyInt 134 itself; the deleted combined-flag case (<c>0x4 |
/// 0x8</c>) asserted a non-retail bitwise-on-property mapping — ACE only
/// ever sends an EXACT <c>PlayerKillerStatus</c> enum value, and
/// <see cref="PlayerKillerStatusBitfield.Apply"/> matches by exact
/// equality, so an unrecognized combined value falls to its "clear all
/// three" default (NPK), not PK.
/// </para>
/// </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();
@ -456,8 +467,8 @@ public sealed class CharacterSheetProviderTests
});
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
obj.Properties.Ints[134u] = rawStatus;
objects.AddOrUpdate(obj);
objects.UpdateIntProperty(PlayerGuid, 134u, rawStatus);
CharacterSheet sheet = provider.BuildSheet();
@ -476,8 +487,8 @@ public sealed class CharacterSheetProviderTests
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);
objects.UpdateIntProperty(PlayerGuid, 134u, 0x4); // PK
Assert.Null(provider.BuildSheet().PkStatus);
}

View file

@ -49,7 +49,10 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Female Aluvian Adventurer", heritage.LinesProvider()[0].Text);
// CT4 fix round (BLOCKER 2): retail AppendText's the title VERBATIM
// (@0x004f0990) — SampleData's "the Adventurer" keeps its lowercase
// article, unlike the pre-fix-round stripped "Adventurer".
Assert.Equal("Female Aluvian the Adventurer", heritage.LinesProvider()[0].Text);
Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text);
}
@ -76,7 +79,27 @@ public class CharacterStatControllerTests
Title = "the Adventurer",
};
Assert.Equal("Female Aluvian Adventurer", CharacterIdentityText.StatHeaderLine(sheet));
// CT4 fix round (BLOCKER 2): AppendText @0x004f0990 is verbatim — no
// leading-article stripping.
Assert.Equal("Female Aluvian the Adventurer", CharacterIdentityText.StatHeaderLine(sheet));
}
/// <summary>CT4 fix round (BLOCKER 2): 26 real ACE
/// <c>CharacterTitle</c> entries begin with "The" (capital T, e.g.
/// "The Noob") — retail's verbatim AppendText must not mangle them the
/// way the deleted <c>StripLeadingArticle</c> (which only matched a
/// lowercase "the ") would have left half-stripped anyway.</summary>
[Fact]
public void CharacterIdentityText_StatHeaderLine_KeepsCapitalTheTitleUnmangled()
{
var sheet = new CharacterSheet
{
Gender = "Male",
Heritage = "Aluvian",
Title = "The Noob",
};
Assert.Equal("Male Aluvian The Noob", CharacterIdentityText.StatHeaderLine(sheet));
}
[Theory]
@ -106,6 +129,8 @@ public class CharacterStatControllerTests
var hiddenName = new UiText { ElementId = CharacterStatController.NameId };
var visibleHeritage = new UiText { ElementId = CharacterStatController.HeritageId };
var hiddenHeritage = new UiText { ElementId = CharacterStatController.HeritageId };
var visiblePk = new UiText { ElementId = CharacterStatController.PkStatusId };
var hiddenPk = new UiText { ElementId = CharacterStatController.PkStatusId };
var visibleLevel = new UiText { ElementId = CharacterStatController.LevelId };
var hiddenLevel = new UiText { ElementId = CharacterStatController.LevelId };
var visibleTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId };
@ -121,12 +146,14 @@ public class CharacterStatControllerTests
attrPage.AddChild(visibleName);
attrPage.AddChild(visibleHeritage);
attrPage.AddChild(visiblePk);
attrPage.AddChild(visibleLevel);
attrPage.AddChild(visibleTotalXpLabel);
attrPage.AddChild(visibleTotalXp);
attrPage.AddChild(visibleMeter);
hiddenPage.AddChild(hiddenName);
hiddenPage.AddChild(hiddenHeritage);
hiddenPage.AddChild(hiddenPk);
hiddenPage.AddChild(hiddenLevel);
hiddenPage.AddChild(hiddenTotalXpLabel);
hiddenPage.AddChild(hiddenTotalXp);
@ -138,6 +165,7 @@ public class CharacterStatControllerTests
{
[CharacterStatController.NameId] = hiddenName,
[CharacterStatController.HeritageId] = hiddenHeritage,
[CharacterStatController.PkStatusId] = hiddenPk,
[CharacterStatController.LevelId] = hiddenLevel,
[CharacterStatController.TotalXpLabelId] = hiddenTotalXpLabel,
[CharacterStatController.TotalXpId] = hiddenTotalXp,
@ -148,13 +176,19 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Studio Player", visibleName.LinesProvider()[0].Text);
Assert.Equal("Female Aluvian Adventurer", visibleHeritage.LinesProvider()[0].Text);
Assert.Equal("Female Aluvian the Adventurer", visibleHeritage.LinesProvider()[0].Text);
// CT4 fix round item 7: this test predates CT4 and did not cover
// PkStatusId — extended here to prove the visible-page-scoping rule
// (Bind reads/writes the ATTRIBUTES-page copy, not the last-
// registered duplicate in _byId) also holds for the PK line.
Assert.Equal("Non-Player Killer", visiblePk.LinesProvider()[0].Text);
Assert.Equal("126", visibleLevel.LinesProvider()[0].Text);
Assert.Equal("Total Experience (XP):", visibleTotalXpLabel.LinesProvider()[0].Text);
Assert.Equal((1_250_000_000L).ToString("N0"), visibleTotalXp.LinesProvider()[0].Text);
Assert.Equal((42_000_000L).ToString("N0"), visibleXpNext.LinesProvider()[0].Text);
Assert.Empty(hiddenName.LinesProvider());
Assert.Empty(hiddenXpNext.LinesProvider());
Assert.Empty(hiddenPk.LinesProvider());
}
// ── Campaign CT slice CT4: header identity block ───────────────────────
@ -263,26 +297,24 @@ public class CharacterStatControllerTests
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>
/// <summary>CT4 item 5, re-bound at the CT4 fix round (BLOCKER 1): the
/// luminance pair (0x100005C5/0x100005C6) shows/hides its TEXT per
/// retail's exact gate — <c>UpdateExperience</c> (0x004f0a70):
/// "InqInt(0x19) &lt; 200 || MaximumLuminance == 0" empties both
/// elements' <c>LinesProvider</c> (retail's <c>ClearAllText</c>,
/// <c>@0x004f0e31</c>/<c>@0x004f0e3c</c>) rather than toggling
/// <c>Visible</c> — see <c>Bind_LuminancePair_ShowsBoundTextWhenGateIsOpen</c>
/// for the actual bound content.</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)
public void Bind_LuminancePair_TogglesContentPerRetailGate(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 label = new UiText { ElementId = CharacterStatController.LuminanceLabelId };
var value = new UiText { ElementId = CharacterStatController.LuminanceValueId };
var layout = Fake(
(CharacterStatController.LuminanceLabelId, label),
(CharacterStatController.LuminanceValueId, value));
@ -293,8 +325,43 @@ public class CharacterStatControllerTests
MaximumLuminance = maxLuminance,
});
Assert.Equal(expectedVisible, label.Visible);
Assert.Equal(expectedVisible, value.Visible);
Assert.Equal(expectedVisible, label.LinesProvider().Count > 0);
Assert.Equal(expectedVisible, value.LinesProvider().Count > 0);
// ClearAllText leaves layout untouched — Visible is never written by
// this binding at all (retail doesn't touch it either).
Assert.True(label.Visible);
Assert.True(value.Visible);
}
/// <summary>CT4 fix round (BLOCKER 1): caption "Luminance:" (UTF-16
/// PE-recovered @0x007c3dd4) and value "&lt;available&gt; / &lt;maximum&gt;"
/// (narrow "%s / %s" @0x007c3dcc), each number formatted through the
/// same N0/InvariantCulture helper Total XP uses, in the widget's own
/// authored color.</summary>
[Fact]
public void Bind_LuminancePair_ShowsBoundTextWhenGateIsOpen()
{
var authoredColor = new Vector4(0.9f, 0.9f, 0.9f, 1f);
var label = new UiText { ElementId = CharacterStatController.LuminanceLabelId, DefaultColor = authoredColor };
var value = new UiText { ElementId = CharacterStatController.LuminanceValueId, DefaultColor = authoredColor };
var layout = Fake(
(CharacterStatController.LuminanceLabelId, label),
(CharacterStatController.LuminanceValueId, value));
CharacterStatController.Bind(layout, () => new CharacterSheet
{
Level = 200,
AvailableLuminance = 1_500_000L,
MaximumLuminance = 25_000_000L,
});
UiText.Line labelLine = Assert.Single(label.LinesProvider());
Assert.Equal("Luminance:", labelLine.Text);
Assert.Equal(authoredColor, labelLine.Color);
UiText.Line valueLine = Assert.Single(value.LinesProvider());
Assert.Equal("1,500,000 / 25,000,000", valueLine.Text);
Assert.Equal(authoredColor, valueLine.Color);
}
// ── XP meter fill ────────────────────────────────────────────────────────