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:
parent
ed652ed8ad
commit
e7e32409c2
9 changed files with 472 additions and 117 deletions
File diff suppressed because one or more lines are too long
|
|
@ -265,7 +265,9 @@ row-template resolver already takes (`RetailUiRuntime.MountCharacter`),
|
|||
and set the list box's authored 24px row height so wheel/line scroll
|
||||
lands row-aligned.
|
||||
|
||||
**CT4 — Header identity block. CODE-COMPLETE 2026-08-24.** Retail composition: name; "<Gender>
|
||||
**CT4 — Header identity block. REVIEW-CLOSED 2026-08-25: landed `ed652ed8`,
|
||||
Opus dual-lens review (2 blockers + 5 should-fix, all applied), fix round
|
||||
applied 2026-08-25.** Retail composition: name; "<Gender>
|
||||
<Heritage> <DisplayTitle>"; PK status line — authored fonts/colors
|
||||
(pure white per probe), live refresh on display-title change and PK
|
||||
status, identical on Attributes AND Skills pages. Level color from the
|
||||
|
|
@ -275,7 +277,10 @@ authored element. Retires the rest of AP-109's UI half.
|
|||
already covers both Attributes/Skills page copies — `CharacterStatController`
|
||||
binds the SAME physically-visible container (contentPage = the Attributes
|
||||
page chain) for both tabs; the Skills-page duplicate header subtree is never
|
||||
shown (a test now pins this: `Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated`).
|
||||
shown (pinned by `Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated`,
|
||||
a test that PREDATES CT4 — corrected at the CT4 fix round below, since the
|
||||
original wording here implied CT4 wrote it fresh; the pre-existing test did
|
||||
not cover `PkStatusId` until the fix round extended it).
|
||||
All four header identity elements (Name/Heritage/PkStatus/Level) switched
|
||||
from hand-picked `Body`/`Gold` runtime colors to the widget's own authored
|
||||
`DefaultColor` (`LabelAuthoredColor`), matching CT1's live-DAT pin exactly —
|
||||
|
|
@ -319,6 +324,60 @@ key-by-status resolution including a combined-flag case, no-resolver ⇒ null,
|
|||
Level null-vs-present, title resolution + live refresh on both title
|
||||
events + unsubscribe-on-dispose, luminance Int64 read-through).
|
||||
|
||||
**CT4 fix round (Opus dual-lens review, 2026-08-25).** 2 BLOCKERS: (1) the
|
||||
luminance caption/value strings were RECOVERED by PE-byte-decoding the raw
|
||||
retail binary (caption UTF-16 `"Luminance:"` at `@0x007c3dd4`, value narrow
|
||||
`"%s / %s"` at `@0x007c3dcc`, both immediately following
|
||||
`gmStatManagementUI::UpdatePKStatus`'s own vftable slots — the CT4 landing's
|
||||
"could not be recovered" claim is FALSIFIED), so the pair now binds real
|
||||
text (each number formatted through a new shared `FormatXp` helper —
|
||||
`.ToString("N0", InvariantCulture)`, also now used by Total XP / XP-to-next-
|
||||
level, replacing their un-invariant `.ToString("N0")`), and the hide path
|
||||
switched from `Visible = false` to retail's own `UIElement_Text::ClearAllText`
|
||||
mechanism (`@0x004f0e31`/`@0x004f0e3c` — empty the LinesProvider, leave
|
||||
layout); (2) `CharacterIdentityText.StripLeadingArticle` is deleted — retail
|
||||
`AppendText`s the resolved title VERBATIM (`@0x004f0990`), and 26 real ACE
|
||||
`CharacterTitle` entries begin with "The", so every one of them was being
|
||||
mangled; the dead `CharacterSheet.Race` fallback (no retail producer — the
|
||||
`InqGenderHeritageDisplay` creature-type argument is a hardcoded literal `0`
|
||||
at `@0x004f08db`) is deleted alongside it. 5 SHOULD-FIX: (3) the PK line now
|
||||
classifies off the live `ClientObject.PublicWeenieBitfield` PWD bits
|
||||
(`0x20`/`0x02000000`, `ACCWeenieObject::IsPK`/`IsPKLite`
|
||||
`@0x0058c8b0`/`@0x0058c8a0`) instead of a bitwise test against raw
|
||||
PropertyInt 134 — PropertyInt 134 carries ACE's own `PlayerKillerStatus`
|
||||
enum bit layout, not the PWD layout, so the deleted `0x4 | 0x8` combined-flag
|
||||
test case asserted a non-retail answer (PropertyInt 134 already drives the
|
||||
correct PWD bits via `PlayerKillerStatusBitfield.Apply`, so this is a
|
||||
re-source, not new wiring); (4) the register's 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 item's 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 the
|
||||
originally-estimated 22 functions/~200 strings to the ACTUAL 17
|
||||
functions/~170 strings (`AllegianceSystem::GetTitle`'s dispatch switch read
|
||||
directly: Gearknight/Tumerok author only a male function reused both ways,
|
||||
Lugian only a female one, and Olthoi/OlthoiAcid have none), and downgrades
|
||||
the row's evidence claim to "synthetic-layout binding tests plus a small
|
||||
number of InstalledDat string/DID pins" rather than implying a
|
||||
connected/live gate; (5) `CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors`
|
||||
gains the luminance pair's own occurrence-count + font/color pins, matching
|
||||
the pattern every other header id already uses. Also landed this round: 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 (10
|
||||
"Penumbraen", 12 "Olthoi") the review had flagged as unverified guesses —
|
||||
both are correct; the mechanism divergence (hardcoded table vs. live DAT
|
||||
read) is filed as AP-235, pointing CT5 at the ALREADY-EXISTING generic
|
||||
`RetailDataIdResolver.Resolve` helper (not a new "GetDIDByEnum helper" to
|
||||
write) as the unification seam; `RetailAppraisalNameResolver.ResolveHeritage`'s
|
||||
independent re-implementation of the same three overrides is noted there
|
||||
too, for CT5. `CharacterSheetProvider.BuildSheet`'s level read switched from
|
||||
a `GetInt` + `Ints.ContainsKey` double dictionary lookup to one
|
||||
`TryGetValue`.
|
||||
|
||||
**CT5 — Row alignment + value gutter.** Reconcile our hand-built
|
||||
attribute/skill rows with the authored row templates from CT1: icon
|
||||
placement, name/value columns, the authored right margin that reserves
|
||||
|
|
|
|||
|
|
@ -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 ("<RankTitle> <Name>", 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 >> 5) & 1</c>.</summary>
|
||||
private const uint PkPwdBit = 0x20u;
|
||||
|
||||
/// <summary>PWD bit 0x19 (25) — <c>ACCWeenieObject::IsPKLite @0x0058c8a0</c>:
|
||||
/// <c>(bitfield >> 0x19) & 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
// 0x10000242–0x10000245 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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<char>*) @0x0041ac40</c> overload, which itself calls
|
||||
/// <c>DBObj::GetDIDByEnum(&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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) < 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) < 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 "<available> / <maximum>"
|
||||
/// (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 ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue