diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs
index a522af1a..bac95ade 100644
--- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs
+++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs
@@ -718,7 +718,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
_creatureNames.Resolve(GetInt(p, 2u)));
}
- RebuildCreatureStats(appraisal);
+ RebuildCreatureStats(appraisal, character);
if (newlySelected)
ResetCreatureScroll();
@@ -810,7 +810,22 @@ public sealed class AppraisalUiController : IRetainedPanelController
private static string BuildAllegianceDisplay(PropertyBundle p)
=> GetInt(p, 30u) >= 1 ? GetString(p, 47u) : string.Empty;
- private void RebuildCreatureStats(AppraiseInfoParser.Parsed appraisal)
+ ///
+ /// Rebuilds both authored appraisal lists from a freshly parsed response.
+ /// selects between the CharExamineUI
+ /// and CreatureExamineUI extras composition (armor-level trio +
+ /// unenchantable legend are CHAR-ONLY; see
+ /// ). appraisal
+ /// already carries ArmorLevels straight from the freshly parsed
+ /// wire response (AppraiseInfoParser parses it unconditionally
+ /// when the flag is set) — the 0.75 s combat refresh
+ /// ( → RefreshCurrentAppraisal) round-trips a
+ /// brand-new response through , so no separate cache
+ /// is needed for a refresh to keep rendering the same armor-level rows.
+ ///
+ private void RebuildCreatureStats(
+ AppraiseInfoParser.Parsed appraisal,
+ bool character)
{
if (_creatureStats is null || _creatureRowTemplates is null)
return;
@@ -820,7 +835,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
? CreatureAppraisalRows.Build(profile, appraisal.Success)
: Array.Empty());
_creatureExtra?.Rebuild(
- CreatureAppraisalRows.BuildExtra(appraisal.Properties));
+ CreatureAppraisalRows.BuildExtra(
+ appraisal.Properties, appraisal.ArmorLevels, character));
}
private void ConfigureScrollableText(
diff --git a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs
index c150ee3d..db38926a 100644
--- a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs
+++ b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs
@@ -88,17 +88,71 @@ public static class CreatureAppraisalRows
}
///
- /// Port of CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0's
- /// separate miscellaneous list. Retail reads all nine rating properties,
- /// emits a leading and trailing blank row when any display group exists,
- /// and uses Crit/CritResist only to decide whether their paired row exists.
- /// HealingBoost is read but not displayed by this retail build.
+ /// Armor-level unenchantable sentinel. Retail formats each per-part value
+ /// as "%d" (data_794344) below this threshold and
+ /// "*%d" (data_7b110c) with value - 9999 at/above it
+ /// — see e.g. CharExamineUI::SetAppraiseInfo
+ /// @0x004B5086-@0x004B50A5 (the head part) and every
+ /// subsequent part in the trio.
///
+ private const int UnenchantableArmorLevel = 9999;
+
+ ///
+ /// Port of the armor-level trio + rating rows from
+ /// CharExamineUI::SetAppraiseInfo @ 0x004B45F0 (player path) and
+ /// CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0 (monster path,
+ /// ratings only — no armor-level trio and no unenchantable legend; the
+ /// monster function ends immediately after its own trailing ratings
+ /// spacer and never touches base_armor_* or the
+ /// u"* = Unenchantable" literal). Ground truth:
+ /// docs/research/2026-08-25-campaign-as-ground-truth.md §2b rows
+ /// 3-7 + 15, ruling R3 (legend unconditional) and R4 (spacer discipline).
+ ///
+ /// Rating gating/spacer logic: retail's CharExamineUI decompile
+ /// mangles its leading-spacer flag (ebx_13) into unreadable
+ /// call-argument artifacts — a known BN-decompiler loss (see
+ /// feedback_bn_decomp_field_names.md). Ruling R4 directs using
+ /// CreatureExamineUI's clean version of the identical algorithm
+ /// instead, which is what this method implements: one leading blank row
+ /// before the FIRST ratings-family row that fires, one trailing blank
+ /// row if ANY ratings-family row fired. Each of the three rating rows is
+ /// gated independently (307|313|314, 308|315|316, 350|351); 313/315 only
+ /// gate — their values (Crit/CritResist) are never displayed.
+ /// HealingBoost (Int 323) is read by retail and never rendered (ruling
+ /// R2) — discarded here too.
+ ///
+ ///
+ /// The response's parsed property tables.
+ ///
+ /// Parsed ArmorLevels blob (Success-only per ACE), or
+ /// when the response didn't carry one.
+ ///
+ ///
+ /// for the CharExamineUI (player) path,
+ /// which alone emits the armor-level trio and the trailing
+ /// "* = Unenchantable" legend; for the
+ /// CreatureExamineUI (monster) path, which never emits either.
+ ///
public static IReadOnlyList BuildExtra(
- PropertyBundle properties)
+ PropertyBundle properties,
+ AppraiseInfoParser.ArmorLevel? armorLevels,
+ bool character)
{
ArgumentNullException.ThrowIfNull(properties);
+ var rows = new List();
+
+ if (character && armorLevels is { } levels && HasAnyArmorLevel(levels))
+ {
+ rows.Add(Blank());
+ rows.Add(ArmorLevelRow(
+ "Head/Chest/Groin", levels.Head, levels.Chest, levels.Abdomen));
+ rows.Add(ArmorLevelRow(
+ "Bicep/Wrist/Hand", levels.UpperArm, levels.LowerArm, levels.Hand));
+ rows.Add(ArmorLevelRow(
+ "Thigh/Shin/Foot", levels.UpperLeg, levels.LowerLeg, levels.Foot));
+ }
+
int damage = Get(properties, DamageRating);
int damageResist = Get(properties, DamageResistRating);
int crit = Get(properties, CritRating);
@@ -113,38 +167,61 @@ public static class CreatureAppraisalRows
bool showResist =
damageResist > 0 || critResist > 0 || critDamageResist > 0;
bool showDotLife = dotResist > 0 || lifeResist > 0;
- if (!showRating && !showResist && !showDotLife)
- return Array.Empty();
+ if (showRating || showResist || showDotLife)
+ {
+ rows.Add(Blank());
+ if (showRating)
+ {
+ rows.Add(new CreatureAppraisalRow(
+ "Dmg/CritDmg",
+ $"Rating: {Number(damage)}/{Number(critDamage)}",
+ CreatureAppraisalValueStyle.Normal));
+ }
+ if (showResist)
+ {
+ rows.Add(new CreatureAppraisalRow(
+ "Dmg/CritDmg",
+ $"Resist: {Number(damageResist)}/{Number(critDamageResist)}",
+ CreatureAppraisalValueStyle.Normal));
+ }
+ if (showDotLife)
+ {
+ rows.Add(new CreatureAppraisalRow(
+ "DoT/Life:",
+ $"Resist: {Number(dotResist)}/{Number(lifeResist)}",
+ CreatureAppraisalValueStyle.Normal));
+ }
+ rows.Add(Blank());
+ }
- var rows = new List(5)
- {
- Blank(),
- };
- if (showRating)
+ if (character)
{
rows.Add(new CreatureAppraisalRow(
- "Dmg/CritDmg",
- $"Rating: {Number(damage)}/{Number(critDamage)}",
+ "* = Unenchantable",
+ string.Empty,
CreatureAppraisalValueStyle.Normal));
}
- if (showResist)
- {
- rows.Add(new CreatureAppraisalRow(
- "Dmg/CritDmg",
- $"Resist: {Number(damageResist)}/{Number(critDamageResist)}",
- CreatureAppraisalValueStyle.Normal));
- }
- if (showDotLife)
- {
- rows.Add(new CreatureAppraisalRow(
- "DoT/Life:",
- $"Resist: {Number(dotResist)}/{Number(lifeResist)}",
- CreatureAppraisalValueStyle.Normal));
- }
- rows.Add(Blank());
+
return rows;
}
+ private static bool HasAnyArmorLevel(AppraiseInfoParser.ArmorLevel levels)
+ => levels.Head > 0 || levels.Chest > 0 || levels.Abdomen > 0
+ || levels.UpperArm > 0 || levels.LowerArm > 0 || levels.Hand > 0
+ || levels.UpperLeg > 0 || levels.LowerLeg > 0 || levels.Foot > 0;
+
+ private static CreatureAppraisalRow ArmorLevelRow(
+ string label, int a, int b, int c)
+ => new(
+ label,
+ $"AL: {ArmorLevelPart(a)}/{ArmorLevelPart(b)}/{ArmorLevelPart(c)}",
+ CreatureAppraisalValueStyle.Normal);
+
+ private static string ArmorLevelPart(int value)
+ => value >= UnenchantableArmorLevel
+ ? $"*{Number(value - UnenchantableArmorLevel)}"
+ : Number(value);
+
private static CreatureAppraisalRow Blank() =>
new(string.Empty, string.Empty, CreatureAppraisalValueStyle.Normal);
diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs
index 94b523bd..73366bb6 100644
--- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs
@@ -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();
+ 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(),
+ candidate => candidate.Top == extraHost.Top);
+ }
+
+ private static (string Label, string Value) ExtraRow(
+ UiItemList extra, int index)
+ {
+ var slot = Assert.IsType(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,
diff --git a/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs b/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs
index 926573b7..cca7ea3a 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs
@@ -61,7 +61,7 @@ public sealed class CreatureAppraisalRowsTests
properties.Ints[0x15Fu] = 6;
IReadOnlyList rows =
- CreatureAppraisalRows.BuildExtra(properties);
+ CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: false);
Assert.Equal(5, rows.Count);
Assert.Equal(("", ""), (rows[0].Label, rows[0].Value));
@@ -84,14 +84,181 @@ public sealed class CreatureAppraisalRowsTests
critOnly.Ints[0x139u] = 8;
IReadOnlyList rows =
- CreatureAppraisalRows.BuildExtra(critOnly);
+ CreatureAppraisalRows.BuildExtra(critOnly, armorLevels: null, character: false);
Assert.Equal(3, rows.Count);
Assert.Equal("Rating: 0/0", rows[1].Value);
var healingOnly = new PropertyBundle();
healingOnly.Ints[0x143u] = 20;
- Assert.Empty(CreatureAppraisalRows.BuildExtra(healingOnly));
+ Assert.Empty(
+ CreatureAppraisalRows.BuildExtra(
+ healingOnly, armorLevels: null, character: false));
+ }
+
+ // ── Campaign AS slice AS3: armor-level trio + extras-list ordering ────
+ // Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md
+ // §2b rows 3-7 + 15, rulings R3 (legend unconditional) and R4 (spacer
+ // discipline). Decomp anchors: CharExamineUI::SetAppraiseInfo
+ // @0x004B45F0 (armor-level trio @0x004B4FD1-@0x004B5410, legend
+ // @0x004B5D7D-@0x004B5DED) and CreatureExamineUI::SetAppraiseInfo
+ // @0x004B3FF0 (ratings-only; no trio, no legend).
+
+ [Fact]
+ public void ArmorLevelTrioUsesRetailGroupingLabelsAndFormatPrecedingRatings()
+ {
+ var levels = new AppraiseInfoParser.ArmorLevel(
+ Head: 100, Chest: 110, Abdomen: 120,
+ UpperArm: 130, LowerArm: 140, Hand: 150,
+ UpperLeg: 160, LowerLeg: 170, Foot: 180);
+ var properties = new PropertyBundle();
+ properties.Ints[0x133u] = 35; // DamageRating -> also triggers ratings block
+
+ IReadOnlyList rows =
+ CreatureAppraisalRows.BuildExtra(properties, levels, character: true);
+
+ // [0] spacer, [1..3] AL trio, [4] spacer, [5] rating, [6] spacer,
+ // [7] legend.
+ Assert.Equal(8, rows.Count);
+ Assert.Equal(("", ""), (rows[0].Label, rows[0].Value));
+ Assert.Equal(
+ ("Head/Chest/Groin", "AL: 100/110/120"),
+ (rows[1].Label, rows[1].Value));
+ Assert.Equal(
+ ("Bicep/Wrist/Hand", "AL: 130/140/150"),
+ (rows[2].Label, rows[2].Value));
+ Assert.Equal(
+ ("Thigh/Shin/Foot", "AL: 160/170/180"),
+ (rows[3].Label, rows[3].Value));
+ Assert.Equal(("", ""), (rows[4].Label, rows[4].Value));
+ Assert.Equal(
+ ("Dmg/CritDmg", "Rating: 35/0"),
+ (rows[5].Label, rows[5].Value));
+ Assert.Equal(("", ""), (rows[6].Label, rows[6].Value));
+ Assert.Equal(
+ ("* = Unenchantable", string.Empty),
+ (rows[7].Label, rows[7].Value));
+ }
+
+ [Theory]
+ [InlineData(9998, "9998")]
+ [InlineData(9999, "*0")]
+ [InlineData(10123, "*124")]
+ public void ArmorLevelPartRendersUnenchantableSentinelAtOrAbove9999(
+ int value,
+ string expected)
+ {
+ var levels = new AppraiseInfoParser.ArmorLevel(
+ Head: value, Chest: 0, Abdomen: 0,
+ UpperArm: 0, LowerArm: 0, Hand: 0,
+ UpperLeg: 0, LowerLeg: 0, Foot: 0);
+
+ IReadOnlyList rows = CreatureAppraisalRows.BuildExtra(
+ new PropertyBundle(), levels, character: true);
+
+ CreatureAppraisalRow row = Assert.Single(
+ rows, r => r.Label == "Head/Chest/Groin");
+ Assert.Equal($"AL: {expected}/0/0", row.Value);
+ }
+
+ [Fact]
+ public void ArmorLevelRowMixesStarredAndPlainPartsIndependently()
+ {
+ var levels = new AppraiseInfoParser.ArmorLevel(
+ Head: 50, Chest: 9999, Abdomen: 20000,
+ UpperArm: 0, LowerArm: 0, Hand: 0,
+ UpperLeg: 0, LowerLeg: 0, Foot: 0);
+
+ IReadOnlyList rows = CreatureAppraisalRows.BuildExtra(
+ new PropertyBundle(), levels, character: true);
+
+ CreatureAppraisalRow row = Assert.Single(
+ rows, r => r.Label == "Head/Chest/Groin");
+ Assert.Equal("AL: 50/*0/*10001", row.Value);
+ }
+
+ [Fact]
+ public void AllNineArmorLevelsZeroOrNegativeEmitsNoTrioAndNoSpacer()
+ {
+ var levels = new AppraiseInfoParser.ArmorLevel(
+ Head: 0, Chest: 0, Abdomen: -5,
+ UpperArm: 0, LowerArm: 0, Hand: 0,
+ UpperLeg: 0, LowerLeg: 0, Foot: 0);
+
+ IReadOnlyList rows = CreatureAppraisalRows.BuildExtra(
+ new PropertyBundle(), levels, character: true);
+
+ Assert.DoesNotContain(rows, r => r.Label.Contains("Groin"));
+ Assert.DoesNotContain(rows, r => r.Label.Contains("Hand"));
+ Assert.DoesNotContain(rows, r => r.Label.Contains("Foot"));
+ // Legend is still unconditional on the char path.
+ Assert.Equal("* = Unenchantable", rows[^1].Label);
+ }
+
+ [Fact]
+ public void ArmorLevelTrioAbsentWhenArmorLevelsIsNull()
+ {
+ var properties = new PropertyBundle();
+ properties.Ints[0x133u] = 35;
+
+ IReadOnlyList rows =
+ CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: true);
+
+ Assert.DoesNotContain(rows, r => r.Value.StartsWith("AL:", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void EachRatingRowGatesIndependently()
+ {
+ // Crit (313) alone gates the Rating row but never displays.
+ var critOnly = new PropertyBundle();
+ critOnly.Ints[0x139u] = 1;
+ IReadOnlyList critRows =
+ CreatureAppraisalRows.BuildExtra(critOnly, armorLevels: null, character: false);
+ Assert.Equal(3, critRows.Count);
+ Assert.Equal(("Dmg/CritDmg", "Rating: 0/0"), (critRows[1].Label, critRows[1].Value));
+
+ // CritResist (315) alone gates the Resist row but never displays.
+ var critResistOnly = new PropertyBundle();
+ critResistOnly.Ints[0x13Bu] = 1;
+ IReadOnlyList critResistRows =
+ CreatureAppraisalRows.BuildExtra(critResistOnly, armorLevels: null, character: false);
+ Assert.Equal(3, critResistRows.Count);
+ Assert.Equal(
+ ("Dmg/CritDmg", "Resist: 0/0"),
+ (critResistRows[1].Label, critResistRows[1].Value));
+
+ // 350/351 (DoT/Life) alone, independent of the other two families.
+ var dotLifeOnly = new PropertyBundle();
+ dotLifeOnly.Ints[0x15Eu] = 4;
+ IReadOnlyList dotLifeRows =
+ CreatureAppraisalRows.BuildExtra(dotLifeOnly, armorLevels: null, character: false);
+ Assert.Equal(3, dotLifeRows.Count);
+ Assert.Equal(
+ ("DoT/Life:", "Resist: 4/0"),
+ (dotLifeRows[1].Label, dotLifeRows[1].Value));
+ }
+
+ [Fact]
+ public void LegendIsAbsentOnMonsterPathEvenWithRatingsShown()
+ {
+ var properties = new PropertyBundle();
+ properties.Ints[0x133u] = 35;
+
+ IReadOnlyList rows =
+ CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: false);
+
+ Assert.DoesNotContain(rows, r => r.Label == "* = Unenchantable");
+ }
+
+ [Fact]
+ public void LegendIsAlwaysLastOnCharacterPathEvenWithNoOtherExtras()
+ {
+ IReadOnlyList rows = CreatureAppraisalRows.BuildExtra(
+ new PropertyBundle(), armorLevels: null, character: true);
+
+ CreatureAppraisalRow only = Assert.Single(rows);
+ Assert.Equal(("* = Unenchantable", string.Empty), (only.Label, only.Value));
}
[Fact]