acdream/src/AcDream.App/UI/Layout/CharacterIdentityText.cs
Erik 9f3e326374 fix(ui): Campaign AS AS5 fix round — "retires AP-109" corrected to "narrows" at 6 sites
The AS5 review verified the port exhaustively (170/170 strings, 20
dispatch arms, 17 bounds tests, both call sites) and found one docs-class
defect: five code comments plus the plan slice text claimed AP-109 was
RETIRED while the register correctly keeps the row ACTIVE-narrowed (CT4
FormatXp GetNumberFormatA approximation sliver survives). Comment-only
edits; compile-checked; no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:30:53 +02:00

143 lines
6.8 KiB
C#

namespace AcDream.App.UI.Layout;
/// <summary>
/// Retail character identity display helpers for gmStatManagementUI.
/// Sources: gmStatManagementUI::UpdateCharacterInfo (0x004f0770) calls
/// AppraisalSystem::InqGenderHeritageDisplay(gender 0x71, heritage 0xBC, 0),
/// then — when CharacterTitleTable::GetCharacterTitleFromID(m_titleID)
/// resolves — AppendText(separator @data_794358) + AppendText(titleString).
/// Campaign CT slice CT4 (2026-08-24) PE-read RECOVERED the separator as a
/// single space " " (both here and for the allegiance-rank prefix below);
/// <see cref="Join"/>'s existing <c>string.Join(" ", ...)</c> already
/// matched it.
/// </summary>
/// <remarks>
/// <b>Name-line ruling (CT4, 2026-08-24; CLOSED at Campaign AS slice AS5,
/// 2026-08-25 — narrows AP-109; its FormatXp sliver survives).</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)
/// @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"/>
/// 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
{
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)
{
if (string.IsNullOrWhiteSpace(sheet.Gender))
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",
2 => "Female",
_ => 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",
2 => "Gharu'ndim",
3 => "Sho",
4 => "Viamontian",
5 => "Umbraen",
6 => "Gearknight",
7 => "Tumerok",
8 => "Lugian",
9 => "Empyrean",
10 => "Penumbraen",
11 => "Undead",
12 => "Olthoi",
13 => "Olthoi",
_ => null,
};
/// <summary>
/// Campaign AS slice AS2 (2026-08-25): examination window element
/// <c>0x10000150</c> (Heritage), <c>CharExamineUI::SetAppraiseInfo
/// @0x004B45F0</c> via <c>AppraisalSystem::InqGenderHeritageDisplay
/// @0x005B5AE0</c>. Unlike <see cref="StatHeaderLine"/> (the
/// stat-management panel's header, which never appends a title and has
/// no creature-type fallback), this composes ONLY
/// "&lt;Gender&gt; &lt;Heritage&gt;" — falling back to
/// <paramref name="creatureTypeFallback"/> (the assessed object's
/// creature-type display name, Int 2, resolved through the SAME
/// EnumMapper family <see cref="CreatureDisplayNameResolver"/> already
/// serves the monster view's own <c>0x1000014E</c> line) ONLY when
/// <paramref name="heritageGroup"/> is exactly 0, matching retail's own
/// integer comparison rather than "the lookup failed".
/// </summary>
public static string GenderHeritageDisplay(
int gender,
int heritageGroup,
string? creatureTypeFallback)
{
string? heritage = heritageGroup == 0
? creatureTypeFallback
: HeritageGroupDisplayName(heritageGroup);
return Join(GenderDisplayName(gender), heritage);
}
private static string Join(params string?[] parts)
{
return string.Join(" ", parts
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p!.Trim()));
}
}