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:
Erik 2026-08-25 10:20:07 +02:00
parent adfce16bf1
commit 8f8c0c3a07
8 changed files with 901 additions and 24 deletions

View file

@ -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 ("&lt;RankTitle&gt; &lt;Name&gt;", 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
{