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:
Erik 2026-08-25 09:16:17 +02:00
parent bde5cae031
commit 1616cd3d39
4 changed files with 490 additions and 38 deletions

View file

@ -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)
/// <summary>
/// Rebuilds both authored appraisal lists from a freshly parsed response.
/// <paramref name="character"/> selects between the <c>CharExamineUI</c>
/// and <c>CreatureExamineUI</c> extras composition (armor-level trio +
/// unenchantable legend are CHAR-ONLY; see
/// <see cref="CreatureAppraisalRows.BuildExtra"/>). <c>appraisal</c>
/// already carries <c>ArmorLevels</c> straight from the freshly parsed
/// wire response (<c>AppraiseInfoParser</c> parses it unconditionally
/// when the flag is set) — the 0.75 s combat refresh
/// (<see cref="Tick"/> → <c>RefreshCurrentAppraisal</c>) round-trips a
/// brand-new response through <see cref="Apply"/>, so no separate cache
/// is needed for a refresh to keep rendering the same armor-level rows.
/// </summary>
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<CreatureAppraisalRow>());
_creatureExtra?.Rebuild(
CreatureAppraisalRows.BuildExtra(appraisal.Properties));
CreatureAppraisalRows.BuildExtra(
appraisal.Properties, appraisal.ArmorLevels, character));
}
private void ConfigureScrollableText(

View file

@ -88,17 +88,71 @@ public static class CreatureAppraisalRows
}
/// <summary>
/// Port of <c>CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0</c>'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 <c>"%d"</c> (<c>data_794344</c>) below this threshold and
/// <c>"*%d"</c> (<c>data_7b110c</c>) with <c>value - 9999</c> at/above it
/// — see e.g. <c>CharExamineUI::SetAppraiseInfo</c>
/// <c>@0x004B5086</c>-<c>@0x004B50A5</c> (the head part) and every
/// subsequent part in the trio.
/// </summary>
private const int UnenchantableArmorLevel = 9999;
/// <summary>
/// Port of the armor-level trio + rating rows from
/// <c>CharExamineUI::SetAppraiseInfo @ 0x004B45F0</c> (player path) and
/// <c>CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0</c> (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 <c>base_armor_*</c> or the
/// <c>u"* = Unenchantable"</c> literal). Ground truth:
/// <c>docs/research/2026-08-25-campaign-as-ground-truth.md</c> §2b rows
/// 3-7 + 15, ruling R3 (legend unconditional) and R4 (spacer discipline).
/// <para>
/// Rating gating/spacer logic: retail's <c>CharExamineUI</c> decompile
/// mangles its leading-spacer flag (<c>ebx_13</c>) into unreadable
/// call-argument artifacts — a known BN-decompiler loss (see
/// <c>feedback_bn_decomp_field_names.md</c>). Ruling R4 directs using
/// <c>CreatureExamineUI</c>'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.
/// </para>
/// </summary>
/// <param name="properties">The response's parsed property tables.</param>
/// <param name="armorLevels">
/// Parsed <c>ArmorLevels</c> blob (Success-only per ACE), or
/// <see langword="null"/> when the response didn't carry one.
/// </param>
/// <param name="character">
/// <see langword="true"/> for the <c>CharExamineUI</c> (player) path,
/// which alone emits the armor-level trio and the trailing
/// "* = Unenchantable" legend; <see langword="false"/> for the
/// <c>CreatureExamineUI</c> (monster) path, which never emits either.
/// </param>
public static IReadOnlyList<CreatureAppraisalRow> BuildExtra(
PropertyBundle properties)
PropertyBundle properties,
AppraiseInfoParser.ArmorLevel? armorLevels,
bool character)
{
ArgumentNullException.ThrowIfNull(properties);
var rows = new List<CreatureAppraisalRow>();
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<CreatureAppraisalRow>();
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<CreatureAppraisalRow>(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);