feat(ui): Campaign AS AS5 — allegiance rank-title table, exam title bar + character panel name line (G9, narrows AP-109)
Ports the 17-function heritage×gender AllegianceSystem::GetTitle
@0x005B8DD0 dispatch and AllegianceData::GetFullName @0x005B6950 as
AllegianceRankTitleTable (src/AcDream.App/UI/Layout/), then wires both
retail call sites: the examination window's title bar
(AppraisalUiController.BuildCharacterTitleBarName, from ApplyCreature's
character branch) and the character panel's name line
(CharacterSheetProvider.BuildSheet).
Census confirmed directly against the decomp (matches AP-109's
2026-08-25 correction exactly): 11 heritages -> 17 functions. Gearknight
and Tumerok author only a MALE Get*Title function, reused for both
gender dispatch branches; Lugian authors only a FEMALE one, reused for
both. Heritage id 0xA (Penumbraen) aliases to the Shadowbound (5)
functions on both branches. Olthoi/OlthoiAcid (12/13) are excluded by
GetTitle's own unsigned range check (heritage-1) <= 0xa. Every one of
the 17 functions shares an identical unsigned rank bounds test
(rank-1) > 9 -> no title (valid range 1..10; there is no "clamp to
rank-10 title" behavior for an out-of-range rank). All 170 title
strings transcribed verbatim from the decomp, including several
PE-byte-recovered data-literal indirections in the Sho/Gearknight/
Tumerok tables ("Kou", "Ou", "Dux", "Ona", "Rea", "Tah") that match
published AC lore exactly.
GetFullName: title = GetTitle(rank, heritage, gender); when GetTitle
resolves nothing, the output is the plain name; when it resolves, the
output is "title" + a single ASCII space (PE-byte-recovered at
data_794098) + name.
Two call sites independently re-verified against the decomp for the
rank/heritage/gender property ids: CharExamineUI::SetAppraiseInfo's
local AllegianceData struct (proven by its ctor/dtor pair) never shows
an explicit field write for _rank/_hg/_gender in the decompile — a
Binary Ninja struct-flattening artifact, not a missing read — while
gmStatManagementUI::UpdateCharacterInfo shows the same three
CBaseQualities::InqInt(0x71/0xbc/0x1e) calls as plain, unambiguous
locals, confirming Gender=0x71/HeritageGroup=0xBC/AllegianceRank=0x1E
as the three inputs at both sites (ruling R8: read live off the
appraisal/qualities bundle, never RuntimeAllegianceState).
Register: AP-109 is NARROWED, not retired. Its stated risk (a ranked
character's Name line showing plain-name-only) is closed, but the same
CT4 narrowing also flagged FormatXp's non-byte-identical
GetNumberFormatA approximation as "this row's own residual sliver if
one is ever found" — that caveat is untouched by this slice and is now
the row's only surviving open item.
Tests: AllegianceRankTitleTableTests (per-function golden values,
gender-reuse rules, Penumbraen alias, Olthoi exclusion, rank/heritage/
gender bounds, GetFullName composition); AppraisalUiControllerTests
(title-bar prefix, plain-name fallback, monster-path regression pin);
CharacterSheetProviderTests (name-line prefix + plain-name fallback).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
adfce16bf1
commit
8f8c0c3a07
8 changed files with 901 additions and 24 deletions
File diff suppressed because one or more lines are too long
454
src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs
Normal file
454
src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign AS slice AS5 (2026-08-25, retires register row AP-109): the
|
||||
/// retail allegiance rank-title table and its name-composition wrapper.
|
||||
///
|
||||
/// <para><b>Dispatch — <c>AllegianceSystem::GetTitle @0x005B8DD0</c>.</b>
|
||||
/// Retail tests gender first (<c>arg3</c>: 1 = male, 2 = female — anything
|
||||
/// else falls through to "no title"), then an UNSIGNED heritage-group range
|
||||
/// check <c>(arg2 - 1) <= 0xa</c> (heritage ids 1..11; this is what
|
||||
/// excludes Olthoi/OlthoiAcid, ids 12/13, from ever getting a title), then
|
||||
/// switches on heritage to one of 17 <c>Get*Title</c> functions. The dispatch
|
||||
/// was READ DIRECTLY from the decomp (not estimated) for AP-109's
|
||||
/// 2026-08-25 correction: Gearknight (heritage 6) and Tumerok (heritage 7)
|
||||
/// author only a MALE function, reused verbatim for the FEMALE dispatch
|
||||
/// branch too; Lugian (heritage 8) authors only a FEMALE function, reused
|
||||
/// for the MALE branch; heritage id 0xA (Penumbraen) aliases to the
|
||||
/// Shadowbound (heritage 5) functions on BOTH gender branches — 11
|
||||
/// heritages, 17 functions, not 22.</para>
|
||||
///
|
||||
/// <para><b>Per-function bounds — every one of the 17 functions.</b> Each
|
||||
/// does its OWN unsigned bounds check, identical across all 17:
|
||||
/// <c>(arg1 - 1) > 9</c> → return 0 (no title). So a valid rank is
|
||||
/// exactly 1..10 inclusive; rank 0 (unsigned wraps to a huge value) and any
|
||||
/// rank > 10 both resolve to "no title" — there is no separate
|
||||
/// "too high" clamp to the rank-10 title, contrary to what a naive port
|
||||
/// might assume.</para>
|
||||
///
|
||||
/// <para><b>Composition — <c>AllegianceData::GetFullName @0x005B6950</c>.</b>
|
||||
/// <c>title = GetTitle(this->_rank, this->_hg, this->_gender)</c>;
|
||||
/// when <c>GetTitle</c> returns 0 (no title resolved), the output is the
|
||||
/// plain name, unmodified. When it resolves, the output is
|
||||
/// <c>title + " " + name</c> — a single ASCII space, PE-byte-recovered at
|
||||
/// <c>data_794098</c> (bytes <c>20 00 00 00</c>), the SAME separator
|
||||
/// <see cref="CharacterIdentityText"/>'s class remarks already cite for this
|
||||
/// exact literal. <b>Two call sites verified independently against the
|
||||
/// decomp, both confirming <see cref="AllegianceRankPropertyId"/> (0x1E) /
|
||||
/// <see cref="CharacterIdentityText.HeritageGroupPropertyId"/> (0xBC) /
|
||||
/// <see cref="CharacterIdentityText.GenderPropertyId"/> (0x71) as the three
|
||||
/// inputs</b>:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>CharExamineUI::SetAppraiseInfo @0x004B45F0</c> (examination
|
||||
/// title bar, `@0x004b4c8c`-`@0x004b4cec`): reads
|
||||
/// <c>AppraisalProfile::InqInt(arg2, 0x1e, ...)</c> for rank immediately
|
||||
/// before the <c>GetFullName</c> call; heritage/gender were read earlier in
|
||||
/// the SAME function (<c>InqInt(arg2, 0x71, ...)</c> /
|
||||
/// <c>InqInt(arg2, 0xbc, ...)</c>) for the heritage-line composition. The
|
||||
/// local <c>AllegianceData</c> object (BN name <c>var_a8</c>, constructed via
|
||||
/// <c>CAllegianceData::CAllegianceData(&var_a8)</c> and destructed via
|
||||
/// <c>AllegianceData::~AllegianceData(&var_a8)</c> — the ctor/dtor pair
|
||||
/// is the only proof BN's flattened locals belong to one struct) never shows
|
||||
/// an explicit field WRITE for <c>_rank</c>/<c>_hg</c>/<c>_gender</c> in the
|
||||
/// decompile — a known Binary Ninja struct-flattening artifact
|
||||
/// (<c>feedback_bn_decomp_field_names.md</c>): the <c>InqInt</c> OUTPUT
|
||||
/// pointers target those stack fields directly, and BN labels the pointed-to
|
||||
/// slot with a synthetic local name instead of recognizing it as a struct
|
||||
/// member. The name half of the struct IS visible as a normal assignment:
|
||||
/// <c>var_a8 = ACCWeenieObject::GetObjectName(cur_weenobj, NAME_APPROPRIATE,
|
||||
/// 0)</c> — the assessed object's own live "appropriate name", same
|
||||
/// resolver <see cref="RetailAppraisalNameResolver.ResolveAppropriateName"/>
|
||||
/// already serves <c>AppraisalUiController.BuildTitle</c>'s fallback. The
|
||||
/// result overwrites the window's <c>m_displayedNameText</c>
|
||||
/// UNCONDITIONALLY (only a null-widget guard, no rank gate at the call
|
||||
/// site — the "plain name when rankless" behavior lives entirely inside
|
||||
/// <c>GetFullName</c>/<c>GetTitle</c>).</item>
|
||||
/// <item><c>gmStatManagementUI::UpdateCharacterInfo @0x004F0770</c>
|
||||
/// (character panel name line, `@0x004f0807`-`@0x004f0895`): this one shows
|
||||
/// the three <c>CBaseQualities::InqInt</c> reads (0x71, 0xbc, 0x1e) as
|
||||
/// plain, unambiguous locals with no flattening ambiguity — direct
|
||||
/// confirmation of the same three property ids, this time straight off the
|
||||
/// <c>CACQualities</c> qualities bundle rather than an
|
||||
/// <c>AppraisalProfile</c>. The name appended is the LOCAL PLAYER's own
|
||||
/// singular name (<c>ACCWeenieObject::GetObjectName(SmartBox::player_id,
|
||||
/// NAME_SINGULAR, 0)</c>) — this window always shows your own sheet, never
|
||||
/// another player's.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class AllegianceRankTitleTable
|
||||
{
|
||||
/// <summary>PropertyInt 0x1E = AllegianceRank — the value
|
||||
/// <c>GetTitle</c>'s <c>arg1</c> reads, per both verified call sites
|
||||
/// above. Ruling R8 (AS1 ground truth): read this LIVE off the
|
||||
/// appraisal/qualities bundle, never <c>RuntimeAllegianceState</c> (that
|
||||
/// state exists for a DIFFERENT UI, the local player's own allegiance
|
||||
/// page, and has no bearing on another player's rank).</summary>
|
||||
public const uint AllegianceRankPropertyId = 0x1Eu;
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>AllegianceData::GetFullName @0x005B6950</c>: prefixes
|
||||
/// <paramref name="name"/> with the resolved rank title + a single space
|
||||
/// when <see cref="GetTitle"/> resolves one; returns
|
||||
/// <paramref name="name"/> unmodified otherwise (retail's own
|
||||
/// <c>if (GetTitle(...) == 0) { *out = name; return 0; }</c> branch).
|
||||
/// </summary>
|
||||
public static string ComposeFullName(int rank, int heritageGroup, int gender, string name)
|
||||
{
|
||||
string? title = GetTitle(rank, heritageGroup, gender);
|
||||
return string.IsNullOrEmpty(title) ? name : $"{title} {name}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>AllegianceSystem::GetTitle @0x005B8DD0</c>'s dispatch.
|
||||
/// Returns null where retail returns 0 (no title: unrecognized gender,
|
||||
/// heritage outside 1..11, or the resolved <c>Get*Title</c> function
|
||||
/// itself rejects the rank).
|
||||
/// </summary>
|
||||
public static string? GetTitle(int rank, int heritageGroup, int gender)
|
||||
{
|
||||
if (gender == 1)
|
||||
{
|
||||
if (!IsHeritageInRange(heritageGroup)) return null;
|
||||
return heritageGroup switch
|
||||
{
|
||||
1 => GetAluvianMaleTitle(rank),
|
||||
2 => GetGharundimMaleTitle(rank),
|
||||
3 => GetShoMaleTitle(rank),
|
||||
4 => GetViamontianMaleTitle(rank),
|
||||
5 or 0xA => GetShadowboundMaleTitle(rank), // 0xA = Penumbraen alias
|
||||
6 => GetGearknightMaleTitle(rank),
|
||||
7 => GetTumerokMaleTitle(rank),
|
||||
8 => GetLugianFemaleTitle(rank), // Lugian authors FEMALE only; reused here
|
||||
9 => GetEmpyreanMaleTitle(rank),
|
||||
0xB => GetUndeadMaleTitle(rank),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
if (gender == 2)
|
||||
{
|
||||
if (!IsHeritageInRange(heritageGroup)) return null;
|
||||
return heritageGroup switch
|
||||
{
|
||||
1 => GetAluvianFemaleTitle(rank),
|
||||
2 => GetGharundimFemaleTitle(rank),
|
||||
3 => GetShoFemaleTitle(rank),
|
||||
4 => GetViamontianFemaleTitle(rank),
|
||||
5 or 0xA => GetShadowboundFemaleTitle(rank), // 0xA = Penumbraen alias
|
||||
6 => GetGearknightMaleTitle(rank), // Gearknight authors MALE only; reused here
|
||||
7 => GetTumerokMaleTitle(rank), // Tumerok authors MALE only; reused here
|
||||
8 => GetLugianFemaleTitle(rank),
|
||||
9 => GetEmpyreanFemaleTitle(rank),
|
||||
0xB => GetUndeadFemaleTitle(rank),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Retail's unsigned <c>(heritage - 1) <= 0xa</c> range test
|
||||
/// — heritage ids 1..11 (0xA = Penumbraen, 0xB = Undead); excludes 0,
|
||||
/// negative, and Olthoi/OlthoiAcid (12/13).</summary>
|
||||
private static bool IsHeritageInRange(int heritageGroup)
|
||||
=> unchecked((uint)(heritageGroup - 1)) <= 0xAu;
|
||||
|
||||
// ── The 17 Get*Title functions, verbatim from the decomp. ──────────────
|
||||
// Every function shares the identical bounds test `(rank - 1) > 9` ->
|
||||
// return 0 (ported here as the switch's default arm returning null,
|
||||
// since rank values outside 1..10 have no case in any of the 17 retail
|
||||
// switches either).
|
||||
|
||||
/// <c>AllegianceSystem::GetAluvianMaleTitle @0x005B7BC0</c></summary>
|
||||
private static string? GetAluvianMaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Yeoman",
|
||||
2 => "Baronet",
|
||||
3 => "Baron",
|
||||
4 => "Reeve",
|
||||
5 => "Thane",
|
||||
6 => "Ealdor",
|
||||
7 => "Duke",
|
||||
8 => "Aetheling",
|
||||
9 => "King",
|
||||
10 => "High King",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetAluvianFemaleTitle @0x005B7CD0</c></summary>
|
||||
private static string? GetAluvianFemaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Yeoman",
|
||||
2 => "Baronet",
|
||||
3 => "Baroness",
|
||||
4 => "Reeve",
|
||||
5 => "Thane",
|
||||
6 => "Ealdor",
|
||||
7 => "Duchess",
|
||||
8 => "Aetheling",
|
||||
9 => "Queen",
|
||||
10 => "High Queen",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetGharundimMaleTitle @0x005B7DE0</c></summary>
|
||||
private static string? GetGharundimMaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Sayyid",
|
||||
2 => "Shayk",
|
||||
3 => "Maulan",
|
||||
4 => "Mu'allim",
|
||||
5 => "Naquib",
|
||||
6 => "Qadi",
|
||||
7 => "Mushir",
|
||||
8 => "Amir",
|
||||
9 => "Malik",
|
||||
10 => "Sultan",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetGharundimFemaleTitle @0x005B7EF0</c></summary>
|
||||
private static string? GetGharundimFemaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Sayyida",
|
||||
2 => "Shayka",
|
||||
3 => "Maulana",
|
||||
4 => "Mu'allima",
|
||||
5 => "Naquiba",
|
||||
6 => "Qadiya",
|
||||
7 => "Mushira",
|
||||
8 => "Amira",
|
||||
9 => "Malika",
|
||||
10 => "Sultana",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetShoMaleTitle @0x005B8000</c>. Ranks 7 and 9
|
||||
/// resolve through data-literal indirections (<c>&data_7e6ef8</c> /
|
||||
/// <c>&data_7e6eec</c>) rather than inline C string literals — PE
|
||||
/// byte-read as "Kou" (<c>4b 6f 75 00</c>) and "Ou" (<c>4f 75 00 00</c>)
|
||||
/// respectively, matching published AC Sho lore exactly.</summary>
|
||||
private static string? GetShoMaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Jinin",
|
||||
2 => "Jo-chueh",
|
||||
3 => "Nan-chueh",
|
||||
4 => "Shi-chueh",
|
||||
5 => "Ta-chueh",
|
||||
6 => "Kun-chueh",
|
||||
7 => "Kou",
|
||||
8 => "Taikou",
|
||||
9 => "Ou",
|
||||
10 => "Koutei",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetShoFemaleTitle @0x005B8110</c>. Rank 7 shares
|
||||
/// the same <c>&data_7e6ef8</c> ("Kou") indirection as the male
|
||||
/// table; rank 9 is a plain inline literal here ("Jo-ou"), unlike the
|
||||
/// male table's rank 9.</summary>
|
||||
private static string? GetShoFemaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Jinin",
|
||||
2 => "Jo-chueh",
|
||||
3 => "Nan-chueh",
|
||||
4 => "Shi-chueh",
|
||||
5 => "Ta-chueh",
|
||||
6 => "Kun-chueh",
|
||||
7 => "Kou",
|
||||
8 => "Taikou",
|
||||
9 => "Jo-ou",
|
||||
10 => "Koutei",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetViamontianMaleTitle @0x005B8220</c></summary>
|
||||
private static string? GetViamontianMaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Squire",
|
||||
2 => "Banner",
|
||||
3 => "Baron",
|
||||
4 => "Viscount",
|
||||
5 => "Count",
|
||||
6 => "Marquis",
|
||||
7 => "Duke",
|
||||
8 => "Grand Duke",
|
||||
9 => "King",
|
||||
10 => "High King",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetViamontianFemaleTitle @0x005B8330</c></summary>
|
||||
private static string? GetViamontianFemaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Dame",
|
||||
2 => "Banner",
|
||||
3 => "Baroness",
|
||||
4 => "Viscountess",
|
||||
5 => "Countess",
|
||||
6 => "Marquise",
|
||||
7 => "Duchess",
|
||||
8 => "Grand Duchess",
|
||||
9 => "Queen",
|
||||
10 => "High Queen",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetShadowboundMaleTitle @0x005B8440</c>. Serves
|
||||
/// heritage ids 5 (Shadowbound) AND 0xA (Penumbraen alias).</summary>
|
||||
private static string? GetShadowboundMaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Tenebrous",
|
||||
2 => "Shade",
|
||||
3 => "Squire",
|
||||
4 => "Knight",
|
||||
5 => "Void Knight",
|
||||
6 => "Void Lord",
|
||||
7 => "Duke",
|
||||
8 => "Archduke",
|
||||
9 => "Highborn",
|
||||
10 => "King",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetShadowboundFemaleTitle @0x005B8550</c>. Serves
|
||||
/// heritage ids 5 (Shadowbound) AND 0xA (Penumbraen alias).</summary>
|
||||
private static string? GetShadowboundFemaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Tenebrous",
|
||||
2 => "Shade",
|
||||
3 => "Squire",
|
||||
4 => "Knight",
|
||||
5 => "Void Knight",
|
||||
6 => "Void Lady",
|
||||
7 => "Duchess",
|
||||
8 => "Archduchess",
|
||||
9 => "Highborn",
|
||||
10 => "Queen",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetGearknightMaleTitle @0x005B8660</c>. Gearknight
|
||||
/// authors ONLY this function — <see cref="GetTitle"/> dispatches BOTH
|
||||
/// gender branches for heritage 6 here. Rank 8 resolves through a
|
||||
/// data-literal indirection (<c>&data_7e7034</c>), PE byte-read as
|
||||
/// "Dux" (<c>44 75 78 00</c>).</summary>
|
||||
private static string? GetGearknightMaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Tribunus",
|
||||
2 => "Praefectus",
|
||||
3 => "Optio",
|
||||
4 => "Centurion",
|
||||
5 => "Principes",
|
||||
6 => "Legatus",
|
||||
7 => "Consul",
|
||||
8 => "Dux",
|
||||
9 => "Secondus",
|
||||
10 => "Primus",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetTumerokMaleTitle @0x005B8770</c>. Tumerok
|
||||
/// authors ONLY this function — <see cref="GetTitle"/> dispatches BOTH
|
||||
/// gender branches for heritage 7 here. Ranks 3/6/10 resolve through
|
||||
/// data-literal indirections, PE byte-read as "Ona" (<c>4f 6e 61 00</c>),
|
||||
/// "Rea" (<c>52 65 61 00</c>), and "Tah" (<c>54 61 68 00</c>)
|
||||
/// respectively.</summary>
|
||||
private static string? GetTumerokMaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Xutua",
|
||||
2 => "Tuona",
|
||||
3 => "Ona",
|
||||
4 => "Nuona",
|
||||
5 => "Turea",
|
||||
6 => "Rea",
|
||||
7 => "Nurea",
|
||||
8 => "Kauh",
|
||||
9 => "Sutah",
|
||||
10 => "Tah",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetLugianFemaleTitle @0x005B8880</c>. Lugian
|
||||
/// authors ONLY this function — <see cref="GetTitle"/> dispatches BOTH
|
||||
/// gender branches for heritage 8 here.</summary>
|
||||
private static string? GetLugianFemaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Laigus",
|
||||
2 => "Raigus",
|
||||
3 => "Amploth",
|
||||
4 => "Arintoth",
|
||||
5 => "Obeloth",
|
||||
6 => "Lithos",
|
||||
7 => "Kantos",
|
||||
8 => "Gigas",
|
||||
9 => "Extas",
|
||||
10 => "Tiatus",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetEmpyreanMaleTitle @0x005B8990</c></summary>
|
||||
private static string? GetEmpyreanMaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Ensign",
|
||||
2 => "Corporal",
|
||||
3 => "Lieutenant",
|
||||
4 => "Commander",
|
||||
5 => "Captain",
|
||||
6 => "Commodore",
|
||||
7 => "Admiral",
|
||||
8 => "Warlord",
|
||||
9 => "Ipharsin",
|
||||
10 => "Aulin",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetEmpyreanFemaleTitle @0x005B8AA0</c></summary>
|
||||
private static string? GetEmpyreanFemaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Ensign",
|
||||
2 => "Corporal",
|
||||
3 => "Lieutenant",
|
||||
4 => "Commander",
|
||||
5 => "Captain",
|
||||
6 => "Commodore",
|
||||
7 => "Admiral",
|
||||
8 => "Warlord",
|
||||
9 => "Ipharsia",
|
||||
10 => "Aulia",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetUndeadMaleTitle @0x005B8BB0</c></summary>
|
||||
private static string? GetUndeadMaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Neophyte",
|
||||
2 => "Acolyte",
|
||||
3 => "Adept",
|
||||
4 => "Esquire",
|
||||
5 => "Squire",
|
||||
6 => "Knight",
|
||||
7 => "Count",
|
||||
8 => "Viscount",
|
||||
9 => "Highness",
|
||||
10 => "Annointed",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <c>AllegianceSystem::GetUndeadFemaleTitle @0x005B8CC0</c></summary>
|
||||
private static string? GetUndeadFemaleTitle(int rank) => rank switch
|
||||
{
|
||||
1 => "Neophyte",
|
||||
2 => "Acolyte",
|
||||
3 => "Adept",
|
||||
4 => "Esquire",
|
||||
5 => "Squire",
|
||||
6 => "Knight",
|
||||
7 => "Countess",
|
||||
8 => "Viscountess",
|
||||
9 => "Highness",
|
||||
10 => "Annointed",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
|
@ -725,6 +725,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
SetText(0x10000151u, BuildCharacterTitleDisplay(p));
|
||||
SetText(0x10000152u, BuildPlayerKillerDisplay(obj));
|
||||
SetText(0x1000053Au, BuildAllegianceDisplay(p));
|
||||
_titleValue = BuildCharacterTitleBarName(obj, p);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -825,6 +826,36 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
private static string BuildAllegianceDisplay(PropertyBundle p)
|
||||
=> GetInt(p, 30u) >= 1 ? GetString(p, 47u) : string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign AS slice AS5 (retires AP-109's title-bar residual): the char
|
||||
/// path's title-bar OVERWRITE, <c>AllegianceData::GetFullName
|
||||
/// @0x005B6950</c> called from <c>CharExamineUI::SetAppraiseInfo</c>
|
||||
/// (`@0x004b4c8c`-`@0x004b4cec`, AFTER <see cref="BuildTitle"/>'s String
|
||||
/// 52/stack-count composition already ran in <see cref="Apply"/>).
|
||||
/// Unconditional at the call site — retail's null check there guards only
|
||||
/// the destination WIDGET, not the rank; the "plain name when rankless"
|
||||
/// behavior lives inside <c>GetFullName</c>/<c>GetTitle</c> themselves.
|
||||
/// Rank/heritage/gender read straight off the APPRAISAL bundle (ruling
|
||||
/// R8: PropertyInt <see cref="AllegianceRankTitleTable.AllegianceRankPropertyId"/>
|
||||
/// (0x1E), never <c>RuntimeAllegianceState</c>, which carries a DIFFERENT
|
||||
/// UI's own rank). The appended name reuses
|
||||
/// <see cref="RetailAppraisalNameResolver.ResolveAppropriateName"/> — the
|
||||
/// SAME "appropriate name" resolver retail's
|
||||
/// <c>ACCWeenieObject::GetObjectName(cur_weenobj, NAME_APPROPRIATE, 0)</c>
|
||||
/// call resolves at that exact call site; String 52 (ruling R1, absent
|
||||
/// for players) is deliberately bypassed here exactly as retail bypasses
|
||||
/// it — the overwrite recomputes the name independently of
|
||||
/// <see cref="BuildTitle"/>'s own String-52-then-fallback chain.
|
||||
/// </summary>
|
||||
private string BuildCharacterTitleBarName(ClientObject obj, PropertyBundle p)
|
||||
{
|
||||
int rank = GetInt(p, AllegianceRankTitleTable.AllegianceRankPropertyId);
|
||||
int heritageGroup = GetInt(p, CharacterIdentityText.HeritageGroupPropertyId);
|
||||
int gender = GetInt(p, CharacterIdentityText.GenderPropertyId);
|
||||
string name = _itemNames.ResolveAppropriateName(obj);
|
||||
return AllegianceRankTitleTable.ComposeFullName(rank, heritageGroup, gender, name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds both authored appraisal lists from a freshly parsed response.
|
||||
/// <paramref name="character"/> selects between the <c>CharExamineUI</c>
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ namespace AcDream.App.UI.Layout;
|
|||
/// matched it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Name-line ruling (CT4, 2026-08-24; corrected at the CT4 fix round,
|
||||
/// 2026-08-25 — AP-109).</b> Retail's NAME line
|
||||
/// <b>Name-line ruling (CT4, 2026-08-24; CLOSED at Campaign AS slice AS5,
|
||||
/// 2026-08-25 — retires 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)
|
||||
|
|
@ -24,26 +24,18 @@ namespace AcDream.App.UI.Layout;
|
|||
/// 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.
|
||||
/// reads every other header property straight off
|
||||
/// <c>props.GetInt(...)</c>, and now the rank-prefix too. The 17-function
|
||||
/// <c>Get*Title</c> table (Gearknight/Tumerok author only a MALE function,
|
||||
/// reused for both genders' dispatch branches; Lugian only a FEMALE one,
|
||||
/// likewise reused both ways; Olthoi/OlthoiAcid excluded by the dispatch's
|
||||
/// own unsigned range check) is ported verbatim at
|
||||
/// <see cref="AllegianceRankTitleTable"/>, whose class remarks carry the
|
||||
/// full per-function address citations and the two independently-verified
|
||||
/// decomp call sites. <see cref="CharacterStatController.Bind"/>'s Name
|
||||
/// label now composes through
|
||||
/// <see cref="AllegianceRankTitleTable.ComposeFullName"/> via
|
||||
/// <see cref="CharacterSheetProvider.BuildSheet"/>.
|
||||
/// </remarks>
|
||||
internal static class CharacterIdentityText
|
||||
{
|
||||
|
|
|
|||
|
|
@ -164,7 +164,20 @@ public sealed class CharacterSheetProvider
|
|||
|
||||
return new CharacterSheet
|
||||
{
|
||||
Name = CharacterName(),
|
||||
// Campaign AS slice AS5 (retires AP-109): retail's NAME line
|
||||
// (gmStatManagementUI::UpdateCharacterInfo @0x004F0770,
|
||||
// @0x004f0807-@0x004f0895) prefixes AllegianceData::GetFullName
|
||||
// @0x005B6950's rank title exactly like the examination window's
|
||||
// title bar (AppraisalUiController.BuildCharacterTitleBarName) —
|
||||
// same GetFullName port, same "plain name when rankless"
|
||||
// fallback. Rank/heritage/gender come straight off THIS bundle
|
||||
// (props.GetInt), per AP-109's own correction — never
|
||||
// RuntimeAllegianceState.
|
||||
Name = AllegianceRankTitleTable.ComposeFullName(
|
||||
props.GetInt(AllegianceRankTitleTable.AllegianceRankPropertyId),
|
||||
props.GetInt(CharacterIdentityText.HeritageGroupPropertyId),
|
||||
props.GetInt(CharacterIdentityText.GenderPropertyId),
|
||||
CharacterName()),
|
||||
Level = displayLevel,
|
||||
Gender = CharacterIdentityText.GenderDisplayName(
|
||||
props.GetInt(CharacterIdentityText.GenderPropertyId)),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,248 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign AS slice AS5 conformance tests for <see cref="AllegianceRankTitleTable"/>
|
||||
/// — golden values transcribed directly from <c>AllegianceSystem::GetTitle
|
||||
/// @0x005B8DD0</c>'s dispatch and its 17 <c>Get*Title</c> functions (see the
|
||||
/// class remarks there for full address citations). Gender: 1 = male,
|
||||
/// 2 = female. Heritage ids per <see cref="CharacterIdentityText.HeritageGroupDisplayName"/>:
|
||||
/// 1 Aluvian, 2 Gharu'ndim, 3 Sho, 4 Viamontian, 5 Shadowbound/Umbraen,
|
||||
/// 6 Gearknight, 7 Tumerok, 8 Lugian, 9 Empyrean, 10 (0xA) Penumbraen
|
||||
/// (Shadowbound alias), 11 (0xB) Undead, 12/13 Olthoi/OlthoiAcid (excluded).
|
||||
/// </summary>
|
||||
public sealed class AllegianceRankTitleTableTests
|
||||
{
|
||||
// ── Per-function spot checks: first / a middle / last rank each. ───────
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Yeoman")]
|
||||
[InlineData(5, "Thane")]
|
||||
[InlineData(10, "High King")]
|
||||
public void AluvianMale_MatchesDecomp(int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 1, gender: 1));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Yeoman")]
|
||||
[InlineData(3, "Baroness")] // diverges from male's "Baron" at rank 3
|
||||
[InlineData(10, "High Queen")]
|
||||
public void AluvianFemale_MatchesDecomp(int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 1, gender: 2));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Sayyid")]
|
||||
[InlineData(5, "Naquib")]
|
||||
[InlineData(10, "Sultan")]
|
||||
public void GharundimMale_MatchesDecomp(int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 2, gender: 1));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Sayyida")]
|
||||
[InlineData(5, "Naquiba")]
|
||||
[InlineData(10, "Sultana")]
|
||||
public void GharundimFemale_MatchesDecomp(int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 2, gender: 2));
|
||||
|
||||
/// <summary>Ranks 7 and 9 resolve through PE-byte-recovered data-literal
|
||||
/// indirections in the decomp ("Kou", "Ou") — spot-checked explicitly,
|
||||
/// not just the first/last ranks.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, "Jinin")]
|
||||
[InlineData(5, "Ta-chueh")]
|
||||
[InlineData(7, "Kou")]
|
||||
[InlineData(9, "Ou")]
|
||||
[InlineData(10, "Koutei")]
|
||||
public void ShoMale_MatchesDecomp(int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 3, gender: 1));
|
||||
|
||||
/// <summary>Rank 9 diverges from the male table ("Jo-ou" vs "Ou"); rank 7
|
||||
/// shares the male table's "Kou" indirection.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, "Jinin")]
|
||||
[InlineData(7, "Kou")]
|
||||
[InlineData(9, "Jo-ou")]
|
||||
[InlineData(10, "Koutei")]
|
||||
public void ShoFemale_MatchesDecomp(int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 3, gender: 2));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Squire")]
|
||||
[InlineData(5, "Count")]
|
||||
[InlineData(10, "High King")]
|
||||
public void ViamontianMale_MatchesDecomp(int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 4, gender: 1));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Dame")]
|
||||
[InlineData(5, "Countess")]
|
||||
[InlineData(10, "High Queen")]
|
||||
public void ViamontianFemale_MatchesDecomp(int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 4, gender: 2));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Tenebrous")]
|
||||
[InlineData(5, "Void Knight")]
|
||||
[InlineData(10, "King")]
|
||||
public void ShadowboundMale_MatchesDecomp(int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 5, gender: 1));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Tenebrous")]
|
||||
[InlineData(6, "Void Lady")] // diverges from male's "Void Lord" at rank 6
|
||||
[InlineData(10, "Queen")]
|
||||
public void ShadowboundFemale_MatchesDecomp(int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 5, gender: 2));
|
||||
|
||||
/// <summary>Heritage id 0xA (Penumbraen) ALIASES to the Shadowbound
|
||||
/// functions on both gender dispatch branches — identical results to
|
||||
/// heritage 5.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, 1, "Tenebrous")]
|
||||
[InlineData(2, 1, "Tenebrous")]
|
||||
[InlineData(1, 6, "Void Lord")]
|
||||
[InlineData(2, 6, "Void Lady")]
|
||||
public void Penumbraen_AliasesShadowbound(int gender, int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 0xA, gender: gender));
|
||||
|
||||
[Fact]
|
||||
public void Penumbraen_MatchesShadowboundExactlyAcrossAllRanksAndGenders()
|
||||
{
|
||||
for (int gender = 1; gender <= 2; gender++)
|
||||
for (int rank = 1; rank <= 10; rank++)
|
||||
{
|
||||
Assert.Equal(
|
||||
AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 5, gender),
|
||||
AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 0xA, gender));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gearknight authors only a MALE <c>Get*Title</c> function;
|
||||
/// <see cref="AllegianceRankTitleTable.GetTitle"/> dispatches BOTH gender
|
||||
/// branches to it for heritage 6.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, "Tribunus")]
|
||||
[InlineData(5, "Principes")]
|
||||
[InlineData(8, "Dux")] // PE-byte-recovered data-literal indirection
|
||||
[InlineData(10, "Primus")]
|
||||
public void Gearknight_MaleFunctionReusedForBothGenders(int rank, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 6, gender: 1));
|
||||
Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 6, gender: 2));
|
||||
}
|
||||
|
||||
/// <summary>Tumerok authors only a MALE <c>Get*Title</c> function;
|
||||
/// dispatched for both genders on heritage 7.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, "Xutua")]
|
||||
[InlineData(3, "Ona")] // PE-byte-recovered data-literal indirection
|
||||
[InlineData(6, "Rea")] // PE-byte-recovered data-literal indirection
|
||||
[InlineData(10, "Tah")] // PE-byte-recovered data-literal indirection
|
||||
public void Tumerok_MaleFunctionReusedForBothGenders(int rank, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 7, gender: 1));
|
||||
Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 7, gender: 2));
|
||||
}
|
||||
|
||||
/// <summary>Lugian authors only a FEMALE <c>Get*Title</c> function;
|
||||
/// dispatched for both genders on heritage 8.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, "Laigus")]
|
||||
[InlineData(5, "Obeloth")]
|
||||
[InlineData(10, "Tiatus")]
|
||||
public void Lugian_FemaleFunctionReusedForBothGenders(int rank, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 8, gender: 1));
|
||||
Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 8, gender: 2));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Ensign")]
|
||||
[InlineData(5, "Captain")]
|
||||
[InlineData(10, "Aulin")]
|
||||
public void EmpyreanMale_MatchesDecomp(int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 9, gender: 1));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Ensign")]
|
||||
[InlineData(9, "Ipharsia")] // diverges from male's "Ipharsin"
|
||||
[InlineData(10, "Aulia")]
|
||||
public void EmpyreanFemale_MatchesDecomp(int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 9, gender: 2));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Neophyte")]
|
||||
[InlineData(7, "Count")]
|
||||
[InlineData(8, "Viscount")]
|
||||
[InlineData(10, "Annointed")]
|
||||
public void UndeadMale_MatchesDecomp(int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 0xB, gender: 1));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Neophyte")]
|
||||
[InlineData(7, "Countess")] // diverges from male's "Count"
|
||||
[InlineData(8, "Viscountess")] // diverges from male's "Viscount"
|
||||
[InlineData(10, "Annointed")]
|
||||
public void UndeadFemale_MatchesDecomp(int rank, string expected)
|
||||
=> Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 0xB, gender: 2));
|
||||
|
||||
// ── Dispatch bounds and exclusions. ─────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(12)] // Olthoi
|
||||
[InlineData(13)] // OlthoiAcid
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void Heritage_OutsideRange_ReturnsNullForBothGenders(int heritageGroup)
|
||||
{
|
||||
Assert.Null(AllegianceRankTitleTable.GetTitle(rank: 1, heritageGroup, gender: 1));
|
||||
Assert.Null(AllegianceRankTitleTable.GetTitle(rank: 1, heritageGroup, gender: 2));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(3)]
|
||||
[InlineData(-1)]
|
||||
public void Gender_Unrecognized_ReturnsNull(int gender)
|
||||
=> Assert.Null(AllegianceRankTitleTable.GetTitle(rank: 1, heritageGroup: 1, gender));
|
||||
|
||||
/// <summary>Every one of the 17 functions shares retail's identical
|
||||
/// unsigned bounds test <c>(rank - 1) > 9</c> — rank 0 (wraps huge) and
|
||||
/// any rank > 10 both resolve to "no title", uniformly.</summary>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(11)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(int.MaxValue)]
|
||||
public void Rank_OutsideOneToTen_ReturnsNull(int rank)
|
||||
=> Assert.Null(AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 1, gender: 1));
|
||||
|
||||
// ── AllegianceData::GetFullName composition. ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ComposeFullName_ValidRank_PrefixesTitleWithSingleSpace()
|
||||
{
|
||||
string result = AllegianceRankTitleTable.ComposeFullName(
|
||||
rank: 3, heritageGroup: 1, gender: 2, name: "Aluvia");
|
||||
Assert.Equal("Baroness Aluvia", result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)] // rank absent / zero
|
||||
[InlineData(11)] // rank out of range
|
||||
public void ComposeFullName_NoTitleResolved_ReturnsPlainNameUnmodified(int rank)
|
||||
{
|
||||
string result = AllegianceRankTitleTable.ComposeFullName(
|
||||
rank, heritageGroup: 1, gender: 1, name: "Somebody");
|
||||
Assert.Equal("Somebody", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeFullName_UnrecognizedHeritage_ReturnsPlainName()
|
||||
{
|
||||
string result = AllegianceRankTitleTable.ComposeFullName(
|
||||
rank: 5, heritageGroup: 12 /* Olthoi */, gender: 1, name: "Xarabydun");
|
||||
Assert.Equal("Xarabydun", result);
|
||||
}
|
||||
}
|
||||
|
|
@ -459,6 +459,115 @@ public sealed class AppraisalUiControllerTests
|
|||
Assert.Equal("The Empire", HeaderText(layout, 0x1000053Au));
|
||||
}
|
||||
|
||||
// ── Campaign AS slice AS5: title-bar allegiance rank-title prefix ─────
|
||||
// Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md
|
||||
// §2a's title-bar row, gap G9, ruling R8. Retires register row AP-109.
|
||||
|
||||
[Fact]
|
||||
public void CharacterResponse_TitleBarPrefixesAllegianceRankTitle()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = ObjectId,
|
||||
Name = "Dww",
|
||||
Type = ItemType.Creature,
|
||||
});
|
||||
using var interaction = NewInteraction(objects, []);
|
||||
using AppraisalUiController controller = Bind(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
new CombatState(),
|
||||
[],
|
||||
[],
|
||||
() => { },
|
||||
() => { })!;
|
||||
|
||||
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||
var properties = new PropertyBundle();
|
||||
properties.Strings[5u] = "Template"; // Character-view marker
|
||||
properties.Ints[0x1Eu] = 3; // AllegianceRank
|
||||
properties.Ints[0xBCu] = 1; // HeritageGroup: Aluvian
|
||||
properties.Ints[0x71u] = 2; // Gender: Female
|
||||
|
||||
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
||||
Assert.Equal(AppraisalView.Character, controller.ActiveView);
|
||||
// Aluvian female rank 3 = "Baroness" (AllegianceRankTitleTableTests
|
||||
// pins the table itself); GetFullName's single-space separator.
|
||||
Assert.Equal("Baroness Dww", HeaderText(layout, AppraisalUiController.TitleId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharacterResponse_TitleBarPlainNameWhenRankAbsent()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = ObjectId,
|
||||
Name = "Dww",
|
||||
Type = ItemType.Creature,
|
||||
});
|
||||
using var interaction = NewInteraction(objects, []);
|
||||
using AppraisalUiController controller = Bind(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
new CombatState(),
|
||||
[],
|
||||
[],
|
||||
() => { },
|
||||
() => { })!;
|
||||
|
||||
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||
var properties = new PropertyBundle();
|
||||
properties.Strings[5u] = "Template"; // Character-view marker, no AllegianceRank.
|
||||
|
||||
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
||||
Assert.Equal(AppraisalView.Character, controller.ActiveView);
|
||||
Assert.Equal("Dww", HeaderText(layout, AppraisalUiController.TitleId));
|
||||
}
|
||||
|
||||
/// <summary>Regression pin: <c>CreatureExamineUI</c> (monsters) never
|
||||
/// calls <c>AllegianceData::GetFullName</c> — the overwrite is scoped to
|
||||
/// the <c>character</c> branch of <c>ApplyCreature</c> only, even when
|
||||
/// rank/heritage/gender-shaped properties happen to be present.</summary>
|
||||
[Fact]
|
||||
public void CreatureResponse_TitleBarNeverGetsAllegianceRankPrefix()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = ObjectId,
|
||||
Name = "Drudge",
|
||||
Type = ItemType.Creature,
|
||||
});
|
||||
using var interaction = NewInteraction(objects, []);
|
||||
using AppraisalUiController controller = Bind(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
new CombatState(),
|
||||
[],
|
||||
[],
|
||||
() => { },
|
||||
() => { })!;
|
||||
|
||||
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||
var properties = new PropertyBundle();
|
||||
// No String 5 / Int 0x105 -> monster path, NOT the character view.
|
||||
properties.Ints[0x1Eu] = 3;
|
||||
properties.Ints[0xBCu] = 1;
|
||||
properties.Ints[0x71u] = 2;
|
||||
|
||||
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
||||
Assert.Equal(AppraisalView.Creature, controller.ActiveView);
|
||||
Assert.Equal("Drudge", HeaderText(layout, AppraisalUiController.TitleId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharacterResponse_HeritageFallsBackToCreatureTypeWhenGroupIsZero()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -104,6 +104,36 @@ public sealed class CharacterSheetProviderTests
|
|||
Assert.Equal(90L, sheet.AttributeRaise10Costs[0]);
|
||||
}
|
||||
|
||||
// ── Campaign AS slice AS5: name-line allegiance rank-title prefix ──────
|
||||
// Retires register row AP-109. Same GetFullName port + rank-property id
|
||||
// (props.GetInt(0x1E)) as the examination window's title bar
|
||||
// (AppraisalUiControllerTests.CharacterResponse_TitleBarPrefixes*).
|
||||
|
||||
[Fact]
|
||||
public void BuildSheet_WithAllegianceRank_PrefixesNameWithRankTitle()
|
||||
{
|
||||
var h = new Harness();
|
||||
var player = h.AddPlayerObject();
|
||||
player.Properties.Ints[0x1Eu] = 3; // AllegianceRank
|
||||
player.Properties.Ints[0xBCu] = 1; // HeritageGroup: Aluvian
|
||||
player.Properties.Ints[0x71u] = 2; // Gender: Female
|
||||
|
||||
var sheet = h.Provider.BuildSheet();
|
||||
|
||||
Assert.Equal("Baroness Testy", sheet.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSheet_WithoutAllegianceRank_NameStaysPlain()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddPlayerObject();
|
||||
|
||||
var sheet = h.Provider.BuildSheet();
|
||||
|
||||
Assert.Equal("Testy", sheet.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSheet_AfterLiveInt64Updates_RefreshesBothXpWindowsAndMeter()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue