feat(ui): Campaign AS AS2 — examination header identity block (G1/G2/G3/G5/G10)

Retail's CharExamineUI::SetAppraiseInfo (@0x004B45F0, player-target
examine subview) binds four fixed header lines that acdream was feeding
from the wrong appraisal properties:

- 0x10000150 (Heritage) got raw string prop 4 verbatim. Retail composes
  "<Gender> <Heritage>" via InqGenderHeritageDisplay (@0x005B5AE0) from
  Int 113 Gender + Int 188 HeritageGroup, falling back to the assessed
  object's creature-type name only when HeritageGroup == 0. Reuses the
  Campaign CT gender/heritage tables in CharacterIdentityText (new
  GenderHeritageDisplay helper) and the controller's existing
  CreatureDisplayNameResolver for the creature-type fallback arm.
- 0x10000151 (Profession/title) got AllegianceName (string 47) — the
  title line was never shown at all. Now resolves the CURRENT display
  title from Int 261 CharacterTitleId through CharacterTitleResolver
  (CharacterTitleTable::GetCharacterTitleFromID @0x005C6ED0), falling
  back to String 5 Template verbatim when the id is absent or
  unresolvable. RetailUiRuntime.MountAppraisal now wires the SAME
  CharacterTitleResolver instance the D.2b Character panel already
  owns (_bindings.Character.TitleResolver), resolved per call under
  DatLock — never captured once at mount time, per the secure-trade
  deferred-Func lesson.
- 0x10000152 (PlayerKiller) got MonarchsName (string 11) — never shown.
  Ruling R7: retail reads the LOCAL weenie's PWD bits
  (ACCWeenieObject::IsPK/IsPKLite @0x0058C8B0/@0x0058C8A0), never the
  appraisal payload. Now reads the assessed ClientObject's
  PublicWeenieBitfield directly (bit 0x20 -> "Player Killer", bit
  0x02000000 -> "Player Killer Lite", else "Non-Player Killer").
  Apply()'s existing bail-out when the object has left the local table
  already matches retail's "weenie is gone, leave the line cleared".
- 0x1000053A (AllegianceName) invented a literal "Assessment
  incomplete" on failed assess — zero retail provenance, deleted
  outright. Retail clears the element first (ClearCreatureText already
  does this every ApplyCreature call) then sets String 47
  AllegianceName only inside the Int 30 AllegianceRank >= 1 gate.
  Scoped to the character branch since retail's CreatureExamineUI
  (monsters) never binds this element at all.

None of the four lines are success-gated — ACE sends the int/string
tables even on a failed assess, matching retail's own composition.

First-ever AppraisalView.Character controller test coverage (gap G10):
header composition, title/allegiance fallback and gating, all three PK
variants plus the missing-object clear case, failed-assess rendering
with a repo grep confirming the invented literal is gone, and a
regression pin proving the monster (character: false) path is
untouched.

Register: files AD-114 for the examination preview's animated clone
(mirrors the assessed target's live current pose via
CreatureAppraisalFramePresenter) versus retail's independently
animated private CreatureMode clone (BasicCreatureExamineUI::Init
@0x004AB9C0) — owner-ruled intentional deviation, 2026-08-25
("we animate it, and I like it").

Full hermetic suite green: 15,469 tests passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-25 08:36:11 +02:00
parent 0332466fdc
commit f8a2258979
5 changed files with 533 additions and 15 deletions

View file

@ -43,6 +43,12 @@ public sealed class AppraisalUiController : IRetainedPanelController
public const uint SpellFormulaListId = 0x1000032Du;
private const uint TemplateStringProperty = 5u;
/// <summary>
/// PropertyInt 0x105 (261 decimal) — CharacterTitleId. Doubles as the
/// Character-subview dispatch marker (<see cref="SelectView"/>) and, as
/// of AS2, the source id for <see cref="BuildCharacterTitleDisplay"/>'s
/// current-display-title line (element 0x10000151).
/// </summary>
private const uint CharacterMarkerIntProperty = 0x105u;
private const uint DisplayedNameStringProperty = 0x34u;
private const double CreatureRefreshSeconds = 0.75;
@ -77,6 +83,15 @@ public sealed class AppraisalUiController : IRetainedPanelController
private readonly Func<uint, uint> _resolveComponentIcon;
private readonly Func<uint, IReadOnlyList<SpellExamineComponent>> _spellComponents;
private readonly Func<MagicSchool, uint> _magicSkill;
/// <summary>
/// AS2: element <c>0x10000151</c>'s current-display-title chain
/// (<c>CharacterTitleResolver.Resolve</c> under the composition layer's
/// DatLock — resolved PER CALL, never captured once at mount time, per
/// the secure-trade deferred-Func lesson). Defaults to "never resolves"
/// so a caller that supplies nothing simply falls back to String 5
/// Template every time, matching retail's own fallback branch.
/// </summary>
private readonly Func<uint, string?> _resolveCharacterTitle;
private readonly SpellExamineComponentTemplateFactory? _spellComponentTemplates;
private readonly UiText _spellSchool;
private readonly UiText _spellMana;
@ -132,7 +147,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
Func<uint, uint>? resolveComponentIcon,
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents,
Func<MagicSchool, uint>? magicSkill,
SpellExamineComponentTemplateFactory? spellComponentTemplates)
SpellExamineComponentTemplateFactory? spellComponentTemplates,
Func<uint, string?>? resolveCharacterTitle)
{
_layout = layout;
_objects = objects;
@ -159,6 +175,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
_resolveComponentIcon = resolveComponentIcon ?? (_ => 0u);
_spellComponents = spellComponents ?? (_ => []);
_magicSkill = magicSkill ?? (_ => 0u);
_resolveCharacterTitle = resolveCharacterTitle ?? (_ => null);
_spellComponentTemplates = spellComponentTemplates;
_spellSchool = (UiText)layout.FindElement(SpellSchoolTextId)!;
_spellMana = (UiText)layout.FindElement(SpellManaTextId)!;
@ -287,7 +304,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
Func<uint, uint>? resolveComponentIcon = null,
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
Func<MagicSchool, uint>? magicSkill = null,
SpellExamineComponentTemplateFactory? spellComponentTemplates = null)
SpellExamineComponentTemplateFactory? spellComponentTemplates = null,
Func<uint, string?>? resolveCharacterTitle = null)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(objects);
@ -339,7 +357,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
resolveComponentIcon,
spellComponents,
magicSkill,
spellComponentTemplates);
spellComponentTemplates,
resolveCharacterTitle);
}
/// <summary>
@ -687,9 +706,10 @@ public sealed class AppraisalUiController : IRetainedPanelController
if (character)
{
SetText(0x10000150u, GetString(p, 4u));
SetText(0x10000151u, GetString(p, 47u));
SetText(0x10000152u, GetString(p, 11u));
SetText(0x10000150u, BuildCharacterHeritageDisplay(p));
SetText(0x10000151u, BuildCharacterTitleDisplay(p));
SetText(0x10000152u, BuildPlayerKillerDisplay(obj));
SetText(0x1000053Au, BuildAllegianceDisplay(p));
}
else
{
@ -700,13 +720,91 @@ public sealed class AppraisalUiController : IRetainedPanelController
RebuildCreatureStats(appraisal);
SetText(0x1000053Au, appraisal.Success
? string.Empty
: "Assessment incomplete");
if (newlySelected)
ResetCreatureScroll();
}
/// <summary>
/// Composes examination element <c>0x10000150</c> (Heritage) for a
/// player target. Ground truth: <c>docs/research/2026-08-25-campaign-as-
/// ground-truth.md</c> §2a. Gap G1.
/// </summary>
private string BuildCharacterHeritageDisplay(PropertyBundle p)
{
int gender = GetInt(p, CharacterIdentityText.GenderPropertyId);
int heritageGroup = GetInt(p, CharacterIdentityText.HeritageGroupPropertyId);
string creatureFallback = heritageGroup == 0
? _creatureNames.Resolve(GetInt(p, 2u))
: string.Empty;
return CharacterIdentityText.GenderHeritageDisplay(
gender, heritageGroup, creatureFallback);
}
/// <summary>
/// Composes examination element <c>0x10000151</c> (Profession/title) for
/// a player target: the current display title from Int 261
/// CharacterTitleId (<see cref="_resolveCharacterTitle"/>, resolved
/// through <c>CharacterTitleResolver</c>'s <c>CharacterTitleTable::
/// GetCharacterTitleFromID @0x005C6ED0</c> chain), falling back to
/// String 5 Template verbatim when the id is absent or unresolvable.
/// Ground truth §2a, gap G2. <see cref="CharacterMarkerIntProperty"/>
/// (0x105 == 261 decimal) is the SAME property <see cref="SelectView"/>
/// already keys the Character-subview dispatch on.
/// </summary>
private string BuildCharacterTitleDisplay(PropertyBundle p)
{
if (p.Ints.TryGetValue(CharacterMarkerIntProperty, out int titleId)
&& titleId != 0
&& _resolveCharacterTitle(unchecked((uint)titleId)) is { Length: > 0 } resolved)
{
return resolved;
}
return GetString(p, TemplateStringProperty);
}
/// <summary>PWD bit 5 — <c>ACCWeenieObject::IsPK @0x0058C8B0</c>.</summary>
private const uint PlayerKillerPwdBit = 0x20u;
/// <summary>PWD bit 0x19 (25) — <c>ACCWeenieObject::IsPKLite @0x0058C8A0</c>.</summary>
private const uint PlayerKillerLitePwdBit = 0x02000000u;
/// <summary>
/// Composes examination element <c>0x10000152</c> (PlayerKiller) for a
/// player target. Ruling R7: retail reads the LOCAL weenie's PWD bits —
/// never the appraisal payload's PropertyInt 134 (AP-109's CT4 lesson).
/// <paramref name="obj"/> is the SAME <see cref="ClientObject"/>
/// <see cref="BuildTitle"/> already resolves names from — the assessed
/// object <see cref="Apply"/> located before <c>ApplyCreature</c> ever
/// runs. When that lookup fails, <see cref="Apply"/> already bails out
/// before reaching this method, matching retail's "weenie is gone, leave
/// the line cleared" behavior. Ground truth §2a, gap G3.
/// </summary>
private static string BuildPlayerKillerDisplay(ClientObject obj)
{
uint bitfield = obj.PublicWeenieBitfield ?? 0u;
if ((bitfield & PlayerKillerPwdBit) != 0u)
return "Player Killer";
if ((bitfield & PlayerKillerLitePwdBit) != 0u)
return "Player Killer Lite";
return "Non-Player Killer";
}
/// <summary>
/// Composes examination element <c>0x1000053A</c> (AllegianceName).
/// Retail clears the element first (<see cref="ClearCreatureText"/>
/// already does this for every <c>ApplyCreature</c> call), then sets it
/// to String 47 AllegianceName ONLY inside the Int 30 AllegianceRank
/// &gt;= 1 gate — no gate, no allegiance line. Deletes the former
/// invented "Assessment incomplete" literal (zero retail provenance;
/// was the only occurrence in the repo). Ground truth §2a, gap G5.
/// Retail's <c>CreatureExamineUI</c> (monsters) never binds this
/// element at all — only <c>CharExamineUI</c> (players) does — so this
/// composer is scoped to the <c>character</c> branch, matching that
/// per-class split.
/// </summary>
private static string BuildAllegianceDisplay(PropertyBundle p)
=> GetInt(p, 30u) >= 1 ? GetString(p, 47u) : string.Empty;
private void RebuildCreatureStats(AppraiseInfoParser.Parsed appraisal)
{
if (_creatureStats is null || _creatureRowTemplates is null)

View file

@ -116,6 +116,32 @@ internal static class CharacterIdentityText
_ => 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

View file

@ -2113,6 +2113,20 @@ public sealed class RetailUiRuntime : IDisposable
return;
}
// AS2 (Campaign AS, gap G2): the examination header's title line
// (element 0x10000151) reuses the SAME CharacterTitleResolver
// instance the D.2b Character panel already owns
// (_bindings.Character.TitleResolver, constructed once per
// composition in InteractionRetainedUiComposition — see
// MountCharacter's identical TitleResolver local function). Resolved
// PER CALL under DatLock, never captured once, per the secure-trade
// deferred-Func lesson.
string? ResolveCharacterTitle(uint titleId)
{
lock (_bindings.Assets.DatLock)
return _bindings.Character.TitleResolver.Resolve(titleId);
}
AppraisalUiController? controller = AppraisalUiController.Bind(
layout,
_bindings.Inventory.Objects,
@ -2132,7 +2146,8 @@ public sealed class RetailUiRuntime : IDisposable
resolveComponentIcon: _bindings.Magic.ResolveComponentIcon,
spellComponents: _bindings.Magic.SpellComponents,
magicSkill: _bindings.Magic.MagicSkill,
spellComponentTemplates: spellComponentTemplates);
spellComponentTemplates: spellComponentTemplates,
resolveCharacterTitle: ResolveCharacterTitle);
if (controller is null)
{
Console.WriteLine(