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>
353 lines
13 KiB
C#
353 lines
13 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net.Messages;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
public sealed class CreatureAppraisalRowsTests
|
|
{
|
|
[Fact]
|
|
public void FailedAssessmentShowsOnlyHealthPercentAndUnknownOtherValues()
|
|
{
|
|
var profile = Profile(
|
|
health: 25u,
|
|
healthMax: 100u,
|
|
highlights: (ushort)0,
|
|
colors: (ushort)0);
|
|
|
|
IReadOnlyList<CreatureAppraisalRow> rows =
|
|
CreatureAppraisalRows.Build(profile, success: false);
|
|
|
|
Assert.All(
|
|
rows,
|
|
row => Assert.Equal(
|
|
CreatureAppraisalValueStyle.Incomplete,
|
|
row.Style));
|
|
Assert.Equal("25 %", rows[6].Value);
|
|
Assert.Equal("???", rows[0].Value);
|
|
Assert.Equal("???", rows[7].Value);
|
|
Assert.Equal("???", rows[8].Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnchantmentBitsSelectRetailPositiveAndNegativeStyles()
|
|
{
|
|
var profile = Profile(
|
|
health: 100u,
|
|
healthMax: 100u,
|
|
highlights: (ushort)((1 << 0) | (1 << 6)),
|
|
colors: (ushort)(1 << 0));
|
|
|
|
IReadOnlyList<CreatureAppraisalRow> rows =
|
|
CreatureAppraisalRows.Build(profile, success: true);
|
|
|
|
Assert.Equal(CreatureAppraisalValueStyle.Positive, rows[0].Style);
|
|
Assert.Equal(CreatureAppraisalValueStyle.Negative, rows[6].Style);
|
|
Assert.Equal(CreatureAppraisalValueStyle.Normal, rows[1].Style);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtraRatingsFollowRetailGroupingFormattingAndSeparators()
|
|
{
|
|
var properties = new PropertyBundle();
|
|
properties.Ints[0x133u] = 35;
|
|
properties.Ints[0x139u] = 7;
|
|
properties.Ints[0x13Au] = 4;
|
|
properties.Ints[0x134u] = 12;
|
|
properties.Ints[0x13Bu] = 3;
|
|
properties.Ints[0x13Cu] = 2;
|
|
properties.Ints[0x15Eu] = 9;
|
|
properties.Ints[0x15Fu] = 6;
|
|
|
|
IReadOnlyList<CreatureAppraisalRow> rows =
|
|
CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: false);
|
|
|
|
Assert.Equal(5, rows.Count);
|
|
Assert.Equal(("", ""), (rows[0].Label, rows[0].Value));
|
|
Assert.Equal(
|
|
("Dmg/CritDmg", "Rating: 35/4"),
|
|
(rows[1].Label, rows[1].Value));
|
|
Assert.Equal(
|
|
("Dmg/CritDmg", "Resist: 12/2"),
|
|
(rows[2].Label, rows[2].Value));
|
|
Assert.Equal(
|
|
("DoT/Life:", "Resist: 9/6"),
|
|
(rows[3].Label, rows[3].Value));
|
|
Assert.Equal(("", ""), (rows[4].Label, rows[4].Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void CritOnlyTriggersRatingRowWhileHealingBoostAloneDoesNot()
|
|
{
|
|
var critOnly = new PropertyBundle();
|
|
critOnly.Ints[0x139u] = 8;
|
|
|
|
IReadOnlyList<CreatureAppraisalRow> rows =
|
|
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, 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> rows =
|
|
CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: false);
|
|
|
|
Assert.DoesNotContain(rows, r => r.Label == "* = Unenchantable");
|
|
}
|
|
|
|
[Fact]
|
|
public void LegendIsAlwaysLastOnCharacterPathEvenWithNoOtherExtras()
|
|
{
|
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
|
new PropertyBundle(), armorLevels: null, character: true);
|
|
|
|
CreatureAppraisalRow only = Assert.Single(rows);
|
|
Assert.Equal(("* = Unenchantable", string.Empty), (only.Label, only.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthoredRowTemplateCarriesRetailOverlappingLabelValueGeometry()
|
|
{
|
|
ElementInfo template =
|
|
FixtureLoader.LoadExaminationRowTemplateInfos();
|
|
|
|
Assert.Equal(CreatureAppraisalRowTemplateFactory.TemplateId, template.Id);
|
|
Assert.Equal((292f, 20f), (template.Width, template.Height));
|
|
ElementInfo label = Assert.Single(
|
|
template.Children,
|
|
child => child.Id == CreatureAppraisalRowTemplateFactory.LabelId);
|
|
ElementInfo value = Assert.Single(
|
|
template.Children,
|
|
child => child.Id == CreatureAppraisalRowTemplateFactory.ValueId);
|
|
Assert.Equal((0f, 128f), (label.X, label.Width));
|
|
Assert.Equal((27f, 256f), (value.X, value.Width));
|
|
Assert.Equal(0x40000001u, label.FontDid);
|
|
Assert.Equal(0x40000001u, value.FontDid);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreatureNameResolverUsesLoadedRetailMappingAndSafeFallback()
|
|
{
|
|
var resolver = new CreatureDisplayNameResolver(
|
|
new Dictionary<uint, string> { [77u] = "Ghost" });
|
|
|
|
Assert.Equal("Ghost", resolver.Resolve(77));
|
|
Assert.Equal(string.Empty, resolver.Resolve(0));
|
|
Assert.Equal(string.Empty, resolver.Resolve(999));
|
|
}
|
|
|
|
[Fact]
|
|
public void LayeredTemplateSeparatesChromeFromForegroundText()
|
|
{
|
|
var templates = new CreatureAppraisalRowTemplateFactory(
|
|
FixtureLoader.LoadExaminationRowTemplateInfos(),
|
|
_ => (0u, 0, 0),
|
|
defaultFont: null);
|
|
var row = new CreatureAppraisalRow(
|
|
"Strength",
|
|
"120",
|
|
CreatureAppraisalValueStyle.Normal);
|
|
|
|
UiTemplateListSlot background = templates.Create(
|
|
row,
|
|
CreatureAppraisalRowLayer.Background);
|
|
UiTemplateListSlot foreground = templates.Create(
|
|
row,
|
|
CreatureAppraisalRowLayer.Foreground);
|
|
|
|
Assert.Empty(((UiText)background.Content.FindElement(
|
|
CreatureAppraisalRowTemplateFactory.LabelId)!).LinesProvider());
|
|
Assert.Empty(((UiText)background.Content.FindElement(
|
|
CreatureAppraisalRowTemplateFactory.ValueId)!).LinesProvider());
|
|
Assert.False(
|
|
Assert.IsType<UiDatElement>(foreground.Content.Root).MediaVisible);
|
|
Assert.Equal(
|
|
"Strength",
|
|
Assert.Single(((UiText)foreground.Content.FindElement(
|
|
CreatureAppraisalRowTemplateFactory.LabelId)!)
|
|
.LinesProvider()).Text);
|
|
Assert.Equal(
|
|
"120",
|
|
Assert.Single(((UiText)foreground.Content.FindElement(
|
|
CreatureAppraisalRowTemplateFactory.ValueId)!)
|
|
.LinesProvider()).Text);
|
|
}
|
|
|
|
private static AppraiseInfoParser.CreatureProfile Profile(
|
|
uint health,
|
|
uint healthMax,
|
|
ushort highlights,
|
|
ushort colors)
|
|
=> new(
|
|
Flags: 0x09u,
|
|
Health: health,
|
|
HealthMax: healthMax,
|
|
Strength: null,
|
|
Endurance: null,
|
|
Quickness: null,
|
|
Coordination: null,
|
|
Focus: null,
|
|
Self: null,
|
|
Stamina: 50u,
|
|
Mana: 75u,
|
|
StaminaMax: 100u,
|
|
ManaMax: 100u,
|
|
AttributeHighlights: highlights,
|
|
AttributeColors: colors);
|
|
}
|