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
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue