feat(ui): Campaign AS AS3 — per-bodypart armor-level rows (G4)
Plumbs Parsed.ArmorLevels into the extras composer and ports the retail armor-level trio + unenchantable legend for the player examination window's extras list (0x10000335), closing gap G4 and the legend half of G8 from docs/research/2026-08-25-campaign-as-ground-truth.md. Decomp evidence (docs/research/named-retail/acclient_2013_pseudo_c.txt): - CharExamineUI::SetAppraiseInfo @0x004B45F0: the armor-level trio (@0x004B4FD1-@0x004B5410) gates on ANY of nine base_armor_* fields > 0, emits one leading spacer, then three rows "Head/Chest/Groin" (Head, Chest, Abdomen), "Bicep/Wrist/Hand" (UpperArm, LowerArm, Hand), "Thigh/Shin/Foot" (UpperLeg, LowerLeg, Foot) formatted "AL: %s/%s/%s" with each part "%d" below 0x270f (9999) or "*%d" with (value-9999) at/ above it (data_794344 vs data_7b110c). The trio precedes the ratings block and has no trailing spacer of its own. - The "* = Unenchantable" legend (@0x004B5D7D-@0x004B5DED) is added UNCONDITIONALLY after the whole `if (InqCreature)` block closes — confirming ruling R3's "unconditional" reading directly from the raw decompile, not just the BN flattening theory. - CreatureExamineUI::SetAppraiseInfo @0x004B3FF0 (monster path): reads the same nine ratings properties with the same gating/spacer logic, but never touches base_armor_* or the unenchantable literal. Confirmed the monster (character:false) path gains neither the trio nor the legend — CreatureAppraisalRows.BuildExtra is character-gated for both. - Ruling R4 (spacer discipline): CharExamineUI's own ratings-block leading- spacer flag (ebx_13) is a known BN-decompiler artifact loss (call- argument mangling instead of a clean `= 1` assignment). Cross-checked against CreatureExamineUI's clean version of the identical algorithm: one leading spacer before the FIRST ratings-family row that fires, one trailing spacer if ANY fired. The existing BuildExtra ratings logic (per-row gates 307|313|314, 308|315|316, 350|351; single leading/ trailing spacer) already matched this exactly — no functional change to the ratings section, only the signature/threading change to make room for the trio and legend around it. Changed: - CreatureAppraisalRows.BuildExtra now takes (properties, armorLevels, character) instead of (properties) alone. Character-gated trio + legend wrap the unchanged ratings logic. - AppraisalUiController.RebuildCreatureStats takes the character flag and threads appraisal.ArmorLevels through; ApplyCreature passes its own `character` parameter. No caching needed for the combat refresh to keep the AL rows: AppraiseInfoParser always parses ArmorLevels into the fresh Parsed value Apply receives, so a re-Apply of the refreshed response renders the same rows for free. - Test signature updates only (no behavior pins changed) plus new coverage: ArmorLevelTrioUsesRetailGroupingLabelsAndFormatPrecedingRatings, ArmorLevelPartRendersUnenchantableSentinelAtOrAbove9999 (theory: 9998/ 9999/10123), ArmorLevelRowMixesStarredAndPlainPartsIndependently, AllNineArmorLevelsZeroOrNegativeEmitsNoTrioAndNoSpacer, ArmorLevelTrioAbsentWhenArmorLevelsIsNull, EachRatingRowGatesIndependently, LegendIsAbsentOnMonsterPathEvenWithRatingsShown, LegendIsAlwaysLastOnCharacterPathEvenWithNoOtherExtras (rows-level); CharacterResponse_ArmorLevelTrioPopulatesExtraListThroughRealBinding, CharacterResponse_CombatRefreshRetainsArmorLevelRows, CreatureResponse_NeverGainsArmorLevelTrioOrLegend (controller-level, through the real LayoutImporter/FixtureLoader binding seam). No existing pin was corrected — the pre-AS3 ratings gating/spacer behavior already matched the decomp; only the call signature changed. Full hermetic suite: AcDream.App.Tests 6208/0 skips; full-solution 15,483/0 skips. Release build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
bde5cae031
commit
1616cd3d39
4 changed files with 490 additions and 38 deletions
|
|
@ -802,6 +802,173 @@ public sealed class AppraisalUiControllerTests
|
|||
Assert.Equal(string.Empty, HeaderText(layout, 0x1000053Au));
|
||||
}
|
||||
|
||||
// ── Campaign AS slice AS3: armor-level rows + extras-list plumbing ────
|
||||
// Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md
|
||||
// §2b rows 3-7 + 15 (gap G4 + partial G8). Real LayoutDesc/template
|
||||
// binding, matching the AS2 controller-level pattern.
|
||||
|
||||
[Fact]
|
||||
public void CharacterResponse_ArmorLevelTrioPopulatesExtraListThroughRealBinding()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = ObjectId,
|
||||
Name = "Dww",
|
||||
Type = ItemType.Creature,
|
||||
});
|
||||
using var interaction = NewInteraction(objects, []);
|
||||
var templates = new CreatureAppraisalRowTemplateFactory(
|
||||
FixtureLoader.LoadExaminationRowTemplateInfos(),
|
||||
NoTexture,
|
||||
defaultFont: null);
|
||||
using AppraisalUiController controller = Bind(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
new CombatState(),
|
||||
[],
|
||||
[],
|
||||
() => { },
|
||||
() => { },
|
||||
templates)!;
|
||||
|
||||
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||
var properties = new PropertyBundle();
|
||||
properties.Strings[5u] = "Template"; // Character-view marker
|
||||
var armorLevels = new AppraiseInfoParser.ArmorLevel(
|
||||
Head: 100, Chest: 110, Abdomen: 120,
|
||||
UpperArm: 130, LowerArm: 140, Hand: 150,
|
||||
UpperLeg: 160, LowerLeg: 170, Foot: 180);
|
||||
|
||||
Assert.True(controller.Apply(Parsed(
|
||||
properties, MinimalCreatureProfile(), armorLevels: armorLevels)));
|
||||
Assert.Equal(AppraisalView.Character, controller.ActiveView);
|
||||
|
||||
UiItemList extra = CreatureExtraList(layout);
|
||||
Assert.Equal(5, extra.GetNumUIItems());
|
||||
Assert.Equal(("", ""), ExtraRow(extra, 0));
|
||||
Assert.Equal(
|
||||
("Head/Chest/Groin", "AL: 100/110/120"), ExtraRow(extra, 1));
|
||||
Assert.Equal(
|
||||
("Bicep/Wrist/Hand", "AL: 130/140/150"), ExtraRow(extra, 2));
|
||||
Assert.Equal(
|
||||
("Thigh/Shin/Foot", "AL: 160/170/180"), ExtraRow(extra, 3));
|
||||
Assert.Equal(
|
||||
("* = Unenchantable", string.Empty), ExtraRow(extra, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharacterResponse_CombatRefreshRetainsArmorLevelRows()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = ObjectId,
|
||||
Name = "Dww",
|
||||
Type = ItemType.Creature,
|
||||
});
|
||||
var sent = new List<uint>();
|
||||
using var interaction = NewInteraction(objects, sent);
|
||||
var combat = new CombatState();
|
||||
var templates = new CreatureAppraisalRowTemplateFactory(
|
||||
FixtureLoader.LoadExaminationRowTemplateInfos(),
|
||||
NoTexture,
|
||||
defaultFont: null);
|
||||
using AppraisalUiController controller = Bind(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
combat,
|
||||
[],
|
||||
[],
|
||||
() => { },
|
||||
() => { },
|
||||
templates)!;
|
||||
|
||||
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||
var properties = new PropertyBundle();
|
||||
properties.Strings[5u] = "Template";
|
||||
var armorLevels = new AppraiseInfoParser.ArmorLevel(
|
||||
Head: 50, Chest: 60, Abdomen: 70,
|
||||
UpperArm: 80, LowerArm: 90, Hand: 100,
|
||||
UpperLeg: 110, LowerLeg: 120, Foot: 130);
|
||||
AppraiseInfoParser.Parsed appraisal = Parsed(
|
||||
properties, MinimalCreatureProfile(), armorLevels: armorLevels);
|
||||
|
||||
Assert.True(controller.Apply(appraisal));
|
||||
controller.OnShown();
|
||||
int sentBeforeRefresh = sent.Count;
|
||||
|
||||
combat.SetCombatMode(CombatMode.Melee);
|
||||
controller.Tick(0.75);
|
||||
// The 0.75 s combat refresh fired exactly one fresh wire request.
|
||||
Assert.Equal(sentBeforeRefresh + 1, sent.Count);
|
||||
|
||||
// The refreshed response is a brand-new Parsed value coming back
|
||||
// through Apply — AppraiseInfoParser always parses ArmorLevels when
|
||||
// the flag is set, so nothing needs to be cached client-side for
|
||||
// the re-applied response to keep rendering the same AL rows.
|
||||
Assert.True(controller.Apply(appraisal));
|
||||
|
||||
UiItemList extra = CreatureExtraList(layout);
|
||||
Assert.Equal(5, extra.GetNumUIItems());
|
||||
Assert.Equal(
|
||||
("Head/Chest/Groin", "AL: 50/60/70"), ExtraRow(extra, 1));
|
||||
Assert.Equal(
|
||||
("Bicep/Wrist/Hand", "AL: 80/90/100"), ExtraRow(extra, 2));
|
||||
Assert.Equal(
|
||||
("Thigh/Shin/Foot", "AL: 110/120/130"), ExtraRow(extra, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreatureResponse_NeverGainsArmorLevelTrioOrLegend()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = ObjectId,
|
||||
Name = "Specter",
|
||||
Type = ItemType.Creature,
|
||||
});
|
||||
using var interaction = NewInteraction(objects, []);
|
||||
var templates = new CreatureAppraisalRowTemplateFactory(
|
||||
FixtureLoader.LoadExaminationRowTemplateInfos(),
|
||||
NoTexture,
|
||||
defaultFont: null);
|
||||
using AppraisalUiController controller = Bind(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
new CombatState(),
|
||||
[],
|
||||
[],
|
||||
() => { },
|
||||
() => { },
|
||||
templates)!;
|
||||
|
||||
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||
// No String 5 / Int 261 marker -> monster path. ArmorLevels present
|
||||
// on the wire (a real ACE response always carries them for a
|
||||
// successful non-player target too) must still be ignored here.
|
||||
var armorLevels = new AppraiseInfoParser.ArmorLevel(
|
||||
Head: 100, Chest: 110, Abdomen: 120,
|
||||
UpperArm: 130, LowerArm: 140, Hand: 150,
|
||||
UpperLeg: 160, LowerLeg: 170, Foot: 180);
|
||||
|
||||
Assert.True(controller.Apply(Parsed(
|
||||
new PropertyBundle(),
|
||||
MinimalCreatureProfile(),
|
||||
armorLevels: armorLevels)));
|
||||
Assert.Equal(AppraisalView.Creature, controller.ActiveView);
|
||||
|
||||
UiItemList extra = CreatureExtraList(layout);
|
||||
Assert.Equal(0, extra.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponseForNeitherPendingNorCurrent_IsIgnored()
|
||||
{
|
||||
|
|
@ -1263,7 +1430,8 @@ public sealed class AppraisalUiControllerTests
|
|||
PropertyBundle properties,
|
||||
AppraiseInfoParser.CreatureProfile? creature = null,
|
||||
uint guid = ObjectId,
|
||||
bool success = true)
|
||||
bool success = true,
|
||||
AppraiseInfoParser.ArmorLevel? armorLevels = null)
|
||||
=> new(
|
||||
Guid: guid,
|
||||
Flags: creature is null
|
||||
|
|
@ -1276,7 +1444,7 @@ public sealed class AppraisalUiControllerTests
|
|||
CreatureProfile: creature,
|
||||
WeaponProfile: null,
|
||||
HookProfile: null,
|
||||
ArmorLevels: null,
|
||||
ArmorLevels: armorLevels,
|
||||
ArmorEnchantments: null,
|
||||
WeaponEnchantments: null,
|
||||
ResistEnchantments: null);
|
||||
|
|
@ -1310,6 +1478,30 @@ public sealed class AppraisalUiControllerTests
|
|||
'\n', text.LinesProvider().Select(line => line.Text));
|
||||
}
|
||||
|
||||
private static UiItemList CreatureExtraList(ImportedLayout layout)
|
||||
{
|
||||
UiElement extraHost = layout.FindElement(
|
||||
AppraisalUiController.CreatureExtraListId)!;
|
||||
UiElement creaturePanel = layout.FindElement(
|
||||
AppraisalUiController.CreaturePanelId)!;
|
||||
return Assert.Single(
|
||||
creaturePanel.Children.OfType<UiItemList>(),
|
||||
candidate => candidate.Top == extraHost.Top);
|
||||
}
|
||||
|
||||
private static (string Label, string Value) ExtraRow(
|
||||
UiItemList extra, int index)
|
||||
{
|
||||
var slot = Assert.IsType<UiTemplateListSlot>(extra.GetItem(index));
|
||||
string label = Assert.Single(((UiText)slot.Content.FindElement(
|
||||
CreatureAppraisalRowTemplateFactory.LabelId)!)
|
||||
.LinesProvider()).Text;
|
||||
string value = Assert.Single(((UiText)slot.Content.FindElement(
|
||||
CreatureAppraisalRowTemplateFactory.ValueId)!)
|
||||
.LinesProvider()).Text;
|
||||
return (label, value);
|
||||
}
|
||||
|
||||
private static void AssertSpellText(
|
||||
ImportedLayout layout,
|
||||
uint elementId,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue