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:
parent
aa8106d57a
commit
ed652ed8ad
10 changed files with 646 additions and 32 deletions
|
|
@ -387,4 +387,33 @@ public sealed class CharacterPanelLiveDatTests
|
|||
string? resolved = resolver.Resolve(0x2300000Eu, hash);
|
||||
Assert.Equal("War Mage", resolved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CT slice CT4: <c>gmStatManagementUI::UpdatePKStatus</c>
|
||||
/// (0x004f00a0) resolves its three-way PK status text through
|
||||
/// StringTable <c>0x23000001</c> by key (the same compute_str_hash
|
||||
/// mechanism <c>ChatWindowController</c>'s chat labels already use — no
|
||||
/// EnumMapper indirection needed here, unlike the title chain above).
|
||||
/// Pins the exact authored strings <c>CharacterSheetProvider.PkStatusText</c>
|
||||
/// resolves against, discovered by a live probe against the installed
|
||||
/// DAT set (not guessed): "Player Killer" / "Player Killer Lite" /
|
||||
/// "Non-Player Killer".
|
||||
/// </summary>
|
||||
[InstalledDatFact]
|
||||
public void PkStatusKeys_ResolveExpectedAuthoredStrings()
|
||||
{
|
||||
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
|
||||
var resolver = new DatStringResolver(dats);
|
||||
|
||||
string? pk = resolver.Resolve(0x23000001u,
|
||||
DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_PK"));
|
||||
string? pkLite = resolver.Resolve(0x23000001u,
|
||||
DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_PKL"));
|
||||
string? npk = resolver.Resolve(0x23000001u,
|
||||
DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_NPK"));
|
||||
|
||||
Assert.Equal("Player Killer", pk);
|
||||
Assert.Equal("Player Killer Lite", pkLite);
|
||||
Assert.Equal("Non-Player Killer", npk);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using AcDream.Core.Items;
|
|||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
|
@ -420,4 +421,167 @@ public sealed class CharacterSheetProviderTests
|
|||
|
||||
Assert.Equal(0, changed);
|
||||
}
|
||||
|
||||
// ── Campaign CT slice CT4: PK status / display title / luminance ───────
|
||||
|
||||
/// <summary>
|
||||
/// <c>gmStatManagementUI::UpdatePKStatus</c> (0x004f00a0): IsPK() tested
|
||||
/// first, then IsPKLite(), else "neither" resolves NPK. The resolver
|
||||
/// stub below echoes the KEY it was handed, so the assertion proves
|
||||
/// 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".
|
||||
/// </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();
|
||||
var player = new LocalPlayerState();
|
||||
string? capturedKey = null;
|
||||
var provider = new CharacterSheetProvider(
|
||||
objects, player,
|
||||
playerGuid: () => PlayerGuid,
|
||||
resolveUiString: key =>
|
||||
{
|
||||
capturedKey = key;
|
||||
return key; // echo — the test asserts on the KEY, not invented English
|
||||
});
|
||||
|
||||
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
|
||||
obj.Properties.Ints[134u] = rawStatus;
|
||||
objects.AddOrUpdate(obj);
|
||||
|
||||
CharacterSheet sheet = provider.BuildSheet();
|
||||
|
||||
Assert.Equal(expectedKey, capturedKey);
|
||||
Assert.Equal(expectedKey, sheet.PkStatus);
|
||||
}
|
||||
|
||||
/// <summary>CT4 contract: "no invented English; if a key fails to
|
||||
/// resolve, show nothing" — a null resolver (no live DAT session, e.g.
|
||||
/// the Studio path) must not synthesize any PK text.</summary>
|
||||
[Fact]
|
||||
public void BuildSheet_PkStatus_NoResolver_LeavesPkStatusNull()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
var player = new LocalPlayerState();
|
||||
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);
|
||||
|
||||
Assert.Null(provider.BuildSheet().PkStatus);
|
||||
}
|
||||
|
||||
/// <summary>CT4 contract item 4: Level is null (not 0) when retail
|
||||
/// InqInt(0x19) is absent, distinguishing "no property yet" from a
|
||||
/// genuinely-present value.</summary>
|
||||
[Fact]
|
||||
public void BuildSheet_Level_NullWhenPropertyAbsent_PresentOtherwise()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
var player = new LocalPlayerState();
|
||||
var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid);
|
||||
|
||||
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
|
||||
obj.Properties.Ints[0x18u] = 1; // some OTHER property present so HasLiveData() is true
|
||||
objects.AddOrUpdate(obj);
|
||||
Assert.Null(provider.BuildSheet().Level);
|
||||
|
||||
obj.Properties.Ints[0x19u] = 42;
|
||||
objects.AddOrUpdate(obj);
|
||||
Assert.Equal(42, provider.BuildSheet().Level);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CT4 item 2: the heritage line's appended title comes from CT2's
|
||||
/// <see cref="RuntimeCharacterTitleState.DisplayTitleId"/> resolved
|
||||
/// through the DAT id->string chain — <see cref="CharacterSheet.Title"/>
|
||||
/// tracks whatever the resolver returns for the CURRENT display id.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildSheet_Title_ResolvesDisplayTitleIdThroughResolver()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
var player = new LocalPlayerState();
|
||||
var titles = new RuntimeCharacterTitleState();
|
||||
var provider = new CharacterSheetProvider(
|
||||
objects, player,
|
||||
playerGuid: () => PlayerGuid,
|
||||
titles: titles,
|
||||
resolveDisplayTitle: id => id == 13u ? "War Mage" : null);
|
||||
|
||||
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
|
||||
obj.Properties.Ints[0x19u] = 1; // some property present so HasLiveData() is true
|
||||
objects.AddOrUpdate(obj);
|
||||
|
||||
Assert.Null(provider.BuildSheet().Title); // no display title seeded yet
|
||||
|
||||
titles.ReplaceTable(13u, new uint[] { 13u });
|
||||
|
||||
Assert.Equal("War Mage", provider.BuildSheet().Title);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CT4 contract: the heritage line MUST refresh live on both
|
||||
/// <see cref="RuntimeCharacterTitleState.TableReplaced"/> (0x0029) and
|
||||
/// <see cref="RuntimeCharacterTitleState.DisplayTitleChanged"/> (the
|
||||
/// display half of 0x002B) — both must fire the sheet-changed
|
||||
/// notification <see cref="CharacterSheetProvider.SubscribeChanged"/>
|
||||
/// exposes, and both must stop firing after disposal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SubscribeChanged_FiresOnTitlesTableReplacedAndDisplayTitleChanged_AndUnsubscribesOnDispose()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
var player = new LocalPlayerState();
|
||||
var titles = new RuntimeCharacterTitleState();
|
||||
var provider = new CharacterSheetProvider(
|
||||
objects, player, playerGuid: () => PlayerGuid, titles: titles);
|
||||
int changed = 0;
|
||||
IDisposable subscription = provider.SubscribeChanged(() => changed++);
|
||||
|
||||
titles.ReplaceTable(1u, new uint[] { 1u }); // TableReplaced (+ DisplayTitleChanged, id 0->1)
|
||||
Assert.True(changed >= 1);
|
||||
|
||||
int afterFirst = changed;
|
||||
titles.ApplyUpdateTitle(2u, setAsDisplay: true); // UpdateTitle → DisplayTitleChanged (1->2)
|
||||
Assert.True(changed > afterFirst);
|
||||
|
||||
subscription.Dispose();
|
||||
int afterDispose = changed;
|
||||
titles.ReplaceTable(3u, new uint[] { 3u });
|
||||
Assert.Equal(afterDispose, changed);
|
||||
}
|
||||
|
||||
/// <summary>CT4 item 5: retail PropertyInt64 6 (AvailableLuminance) / 7
|
||||
/// (MaximumLuminance) flow into the sheet exactly like TotalXp/
|
||||
/// UnassignedXp — the same generic, non-whitelisted Int64 property
|
||||
/// path.</summary>
|
||||
[Fact]
|
||||
public void BuildSheet_Luminance_ReadsInt64Properties6And7()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
var player = new LocalPlayerState();
|
||||
var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid);
|
||||
|
||||
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
|
||||
obj.Properties.Int64s[6u] = 1_500_000L;
|
||||
obj.Properties.Int64s[7u] = 25_000_000L;
|
||||
objects.AddOrUpdate(obj);
|
||||
|
||||
var sheet = provider.BuildSheet();
|
||||
|
||||
Assert.Equal(1_500_000L, sheet.AvailableLuminance);
|
||||
Assert.Equal(25_000_000L, sheet.MaximumLuminance);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) < 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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue