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

@ -413,6 +413,352 @@ public sealed class AppraisalUiControllerTests
Assert.Equal(new[] { ObjectId, otherObjectId }, sent);
}
// ── Campaign AS slice AS2: examination header identity block ──────────
// Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md §2a,
// gap ledger G1/G2/G3/G5, rulings R6/R7. First-ever AppraisalView.Character
// controller coverage (gap G10).
[Fact]
public void CharacterResponse_ComposesRetailHeaderIdentityBlock()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Dww",
Type = ItemType.Creature,
PublicWeenieBitfield = 0x20u, // PWD bit 5 -> IsPK
});
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
[],
[],
() => { },
() => { },
resolveCharacterTitle: titleId => titleId == 13u ? "War Mage" : null)!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
var properties = new PropertyBundle();
properties.Ints[0x71u] = 2; // Gender: Female
properties.Ints[0xBCu] = 1; // HeritageGroup: Aluvian
properties.Ints[0x105u] = 13; // CharacterTitleId (also the Character-view marker)
properties.Ints[30u] = 5; // AllegianceRank >= 1
properties.Strings[47u] = "The Empire"; // AllegianceName
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
Assert.Equal(AppraisalView.Character, controller.ActiveView);
Assert.Equal("Female Aluvian", HeaderText(layout, 0x10000150u));
Assert.Equal("War Mage", HeaderText(layout, 0x10000151u));
Assert.Equal("Player Killer", HeaderText(layout, 0x10000152u));
Assert.Equal("The Empire", HeaderText(layout, 0x1000053Au));
}
[Fact]
public void CharacterResponse_HeritageFallsBackToCreatureTypeWhenGroupIsZero()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Something Odd",
Type = ItemType.Creature,
});
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
[],
[],
() => { },
() => { },
creatureNames: new CreatureDisplayNameResolver(
new Dictionary<uint, string> { [42u] = "Olthoi Guardian" }))!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
var properties = new PropertyBundle();
properties.Ints[2u] = 42; // CreatureType fallback source
properties.Ints[0xBCu] = 0; // HeritageGroup == 0 -> use creature-type fallback
properties.Strings[5u] = "Template"; // Character-view marker
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
Assert.Equal(AppraisalView.Character, controller.ActiveView);
Assert.Equal("Olthoi Guardian", HeaderText(layout, 0x10000150u));
}
[Fact]
public void CharacterResponse_TitleFallsBackToTemplateStringWhenTitleIdAbsent()
{
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] = "Test Template"; // both the fallback text AND the view marker
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
Assert.Equal(AppraisalView.Character, controller.ActiveView);
Assert.Equal("Test Template", HeaderText(layout, 0x10000151u));
}
[Fact]
public void CharacterResponse_TitleClearsWhenIdUnresolvableAndTemplateAbsent()
{
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(),
[],
[],
() => { },
() => { },
// Never resolves any title id.
resolveCharacterTitle: _ => null)!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
var properties = new PropertyBundle();
// Int 261 present (satisfies the Character-view marker) but does not
// resolve, and String 5 Template is absent -> element clears.
properties.Ints[0x105u] = 999;
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
Assert.Equal(AppraisalView.Character, controller.ActiveView);
Assert.Equal(string.Empty, HeaderText(layout, 0x10000151u));
}
[Theory]
[InlineData(0x20u, "Player Killer")]
[InlineData(0x02000000u, "Player Killer Lite")]
[InlineData(0u, "Non-Player Killer")]
public void CharacterResponse_PlayerKillerLineReflectsLocalObjectPwdBits(
uint publicWeenieBitfield,
string expected)
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Dww",
Type = ItemType.Creature,
PublicWeenieBitfield = publicWeenieBitfield,
});
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";
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
Assert.Equal(expected, HeaderText(layout, 0x10000152u));
}
[Fact]
public void MissingAssessedObject_PlayerKillerElementStaysClearedAndApplyReturnsFalse()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
// ObjectId is deliberately never added to the table — retail's
// "the weenie is gone" case (ruling R7).
var objects = new ClientObjectTable();
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";
UiText pk = Assert.IsType<UiText>(layout.FindElement(0x10000152u));
Func<IReadOnlyList<UiText.Line>> providerBeforeApply = pk.LinesProvider;
Assert.False(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
// Apply bailed out before ApplyCreature/SetText ever ran for this
// element — it never had the chance to be anything but cleared.
Assert.Same(providerBeforeApply, pk.LinesProvider);
}
[Fact]
public void AllegianceElement_ClearsUnlessRankIsAtLeastOne()
{
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 noRank = new PropertyBundle();
noRank.Strings[5u] = "Template";
noRank.Strings[47u] = "The Empire";
Assert.True(controller.Apply(Parsed(noRank, MinimalCreatureProfile())));
Assert.Equal(string.Empty, HeaderText(layout, 0x1000053Au));
var zeroRank = new PropertyBundle();
zeroRank.Strings[5u] = "Template";
zeroRank.Strings[47u] = "The Empire";
zeroRank.Ints[30u] = 0;
Assert.True(controller.Apply(Parsed(zeroRank, MinimalCreatureProfile())));
Assert.Equal(string.Empty, HeaderText(layout, 0x1000053Au));
var gatedRank = new PropertyBundle();
gatedRank.Strings[5u] = "Template";
gatedRank.Strings[47u] = "The Empire";
gatedRank.Ints[30u] = 1;
Assert.True(controller.Apply(Parsed(gatedRank, MinimalCreatureProfile())));
Assert.Equal("The Empire", HeaderText(layout, 0x1000053Au));
}
[Fact]
public void FailedAssess_StillRendersHeaderLinesFromPresentTableEntries()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Dww",
Type = ItemType.Creature,
PublicWeenieBitfield = 0x02000000u, // PKLite
});
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
[],
[],
() => { },
() => { },
resolveCharacterTitle: titleId => titleId == 13u ? "War Mage" : null)!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
var properties = new PropertyBundle();
properties.Ints[0x71u] = 1; // Gender: Male
properties.Ints[0xBCu] = 1; // HeritageGroup: Aluvian
properties.Ints[0x105u] = 13; // CharacterTitleId
properties.Ints[30u] = 5; // AllegianceRank
properties.Strings[47u] = "The Empire";
Assert.True(controller.Apply(
Parsed(properties, MinimalCreatureProfile(), success: false)));
Assert.Equal("Male Aluvian", HeaderText(layout, 0x10000150u));
Assert.Equal("War Mage", HeaderText(layout, 0x10000151u));
Assert.Equal("Player Killer Lite", HeaderText(layout, 0x10000152u));
Assert.Equal("The Empire", HeaderText(layout, 0x1000053Au));
}
[Fact]
public void CreatureResponse_HeaderIdentityElementsUnaffectedByPlayerFix()
{
// Regression pin (task constraint): the character:false (monster)
// path must keep its pre-AS2 behavior for the four header elements
// AS2 remapped for players. Pre-fix, 0x10000150/51/52 were never
// touched for monsters (stay at their cleared default) and
// 0x1000053A was set to string.Empty whenever Success was true —
// identical to today's post-fix "gated, nothing gates it open"
// result for a bundle with no allegiance data.
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Specter",
Type = ItemType.Creature,
});
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
[],
[],
() => { },
() => { },
creatureNames: new CreatureDisplayNameResolver(
new Dictionary<uint, string> { [77u] = "Ghost" }))!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
var properties = new PropertyBundle();
properties.Ints[2u] = 77; // CreatureType — no String 5 / Int 261 marker present.
properties.Ints[25u] = 80;
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
Assert.Equal(AppraisalView.Creature, controller.ActiveView);
Assert.Equal("Ghost", HeaderText(layout, AppraisalUiController.CreatureDisplayNameId));
Assert.Equal(string.Empty, HeaderText(layout, 0x10000150u));
Assert.Equal(string.Empty, HeaderText(layout, 0x10000151u));
Assert.Equal(string.Empty, HeaderText(layout, 0x10000152u));
Assert.Equal(string.Empty, HeaderText(layout, 0x1000053Au));
}
[Fact]
public void ResponseForNeitherPendingNorCurrent_IsIgnored()
{
@ -832,7 +1178,8 @@ public sealed class AppraisalUiControllerTests
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)
=> AppraisalUiController.Bind(
layout,
objects,
@ -852,7 +1199,8 @@ public sealed class AppraisalUiControllerTests
resolveComponentIcon,
spellComponents,
magicSkill,
spellComponentTemplates);
spellComponentTemplates,
resolveCharacterTitle);
private static ItemInteractionController NewInteraction(
ClientObjectTable objects,
@ -871,13 +1219,14 @@ public sealed class AppraisalUiControllerTests
private static AppraiseInfoParser.Parsed Parsed(
PropertyBundle properties,
AppraiseInfoParser.CreatureProfile? creature = null,
uint guid = ObjectId)
uint guid = ObjectId,
bool success = true)
=> new(
Guid: guid,
Flags: creature is null
? AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable
: AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile,
Success: true,
Success: success,
Properties: properties,
SpellBook: [],
ArmorProfile: null,
@ -889,6 +1238,35 @@ public sealed class AppraisalUiControllerTests
WeaponEnchantments: null,
ResistEnchantments: null);
/// <summary>Minimal <c>CreatureProfile</c> — just enough for
/// <see cref="AppraisalUiController.SelectView"/>'s CreatureProfile-not-null
/// gate; every attribute is intentionally absent since these tests pin
/// the header identity block, not the attribute rows.</summary>
private static AppraiseInfoParser.CreatureProfile MinimalCreatureProfile()
=> new(
Flags: 0,
Health: 1u,
HealthMax: 1u,
Strength: null,
Endurance: null,
Quickness: null,
Coordination: null,
Focus: null,
Self: null,
Stamina: null,
Mana: null,
StaminaMax: null,
ManaMax: null,
AttributeHighlights: null,
AttributeColors: null);
private static string HeaderText(ImportedLayout layout, uint elementId)
{
UiText text = Assert.IsType<UiText>(layout.FindElement(elementId));
return string.Join(
'\n', text.LinesProvider().Select(line => line.Text));
}
private static void AssertSpellText(
ImportedLayout layout,
uint elementId,