feat(ui): complete retail item appraisal reports

Restore the authored examination geometry and top-origin item list, then port retail's ordered weapon, armor, magic, requirement, capacity, cooldown, use, and description branches into a dedicated formatter with DAT spell prose. Keep the remaining specialized display-name and preview gaps explicit in AP-110.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 17:03:26 +02:00
parent d96ea2de98
commit bc47bc4917
9 changed files with 1587 additions and 297 deletions

View file

@ -1,6 +1,4 @@
using System.Globalization;
using System.Numerics;
using System.Text;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
@ -128,6 +126,11 @@ public sealed class AppraisalUiController : IRetainedPanelController
_title.LinesProvider = () =>
[new UiText.Line(_titleValue, _title.DefaultColor)];
// ItemExamineUI::AddItemInfo @ 0x004AC050 appends the freshly
// rebuilt report from the beginning of the authored text surface.
// LayoutDesc 0x2100006B carries Bottom here, which is appropriate for
// static text but not for the generated retail item list.
_itemText.VerticalJustify = VJustify.Top;
ConfigureScrollableText(_itemText, ItemScrollbarId, () => _itemReport);
if (_inscriptionText is not null)
ConfigureScrollableText(
@ -337,10 +340,10 @@ public sealed class AppraisalUiController : IRetainedPanelController
AppraiseInfoParser.Parsed appraisal,
bool newlySelected)
{
_itemReport = AppraisalTextFormatter.BuildItem(
_itemReport = ItemAppraisalTextFormatter.Build(
obj,
appraisal,
ResolveSpellName);
ResolveSpell);
SetInscription(obj, appraisal);
if (newlySelected)
{
@ -611,9 +614,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
characterInfo.Visible = view == AppraisalView.Character;
}
private string? ResolveSpellName(uint spellId)
private SpellMetadata? ResolveSpell(uint spellId)
=> _spellbook.TryGetMetadata(spellId & 0x7FFF_FFFFu, out SpellMetadata metadata)
? metadata.Name
? metadata
: null;
private static AppraisalView SelectView(AppraiseInfoParser.Parsed appraisal)
@ -713,288 +716,3 @@ public enum AppraisalView
Creature,
Character,
}
/// <summary>Pure retail-ordered text projection for the authored examination fields.</summary>
public static class AppraisalTextFormatter
{
private const uint Level = 25u;
private const uint ArmorLevel = 28u;
private const uint Damage = 44u;
private const uint DamageType = 45u;
private const uint WeaponTime = 49u;
private const uint Structure = 92u;
private const uint MaxStructure = 91u;
private const uint Workmanship = 105u;
private const uint CurrentMana = 107u;
private const uint MaximumMana = 108u;
private const uint Difficulty = 109u;
private const uint WieldRequirement = 158u;
private const uint WieldSkill = 159u;
private const uint WieldDifficulty = 160u;
private const uint RareId = 17u;
public static string BuildItem(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
Func<uint, string?> resolveSpellName)
{
ArgumentNullException.ThrowIfNull(obj);
ArgumentNullException.ThrowIfNull(resolveSpellName);
PropertyBundle p = appraisal.Properties;
var text = new StringBuilder();
Append(text, "Value", obj.Value != 0 ? obj.Value : GetInt(p, 19u), "N0");
Append(text, "Burden", obj.Burden != 0 ? obj.Burden : GetInt(p, 5u), "N0");
int workmanship = GetInt(p, Workmanship);
if (workmanship != 0 || obj.Workmanship != 0)
AppendLine(text, "Workmanship",
(workmanship != 0 ? workmanship : obj.Workmanship)
.ToString("0.##", CultureInfo.CurrentCulture));
if (appraisal.WeaponProfile is { } weapon)
{
AppendLine(text, "Damage",
$"{weapon.Damage.ToString(CultureInfo.CurrentCulture)} {DamageTypeName(weapon.DamageType)}");
AppendLine(text, "Speed", weapon.WeaponTime.ToString(CultureInfo.CurrentCulture));
AppendPercent(text, "Damage Variance", weapon.DamageVariance);
AppendPercent(text, "Attack Bonus", weapon.WeaponOffense - 1d);
}
else
{
Append(text, "Damage", GetInt(p, Damage));
int damageType = GetInt(p, DamageType);
if (damageType != 0)
AppendLine(text, "Damage Type", DamageTypeName((uint)damageType));
Append(text, "Speed", GetInt(p, WeaponTime));
}
if (appraisal.ArmorProfile is { } armor)
{
Append(text, "Armor Level", GetInt(p, ArmorLevel));
AppendProtection(text, "Slashing", armor.SlashingProtection);
AppendProtection(text, "Piercing", armor.PiercingProtection);
AppendProtection(text, "Bludgeoning", armor.BludgeoningProtection);
AppendProtection(text, "Fire", armor.FireProtection);
AppendProtection(text, "Cold", armor.ColdProtection);
AppendProtection(text, "Acid", armor.AcidProtection);
AppendProtection(text, "Electric", armor.LightningProtection);
AppendProtection(text, "Nether", armor.NetherProtection);
}
Append(text, "Level", GetInt(p, Level));
Append(text, "Difficulty", GetInt(p, Difficulty));
int currentMana = GetInt(p, CurrentMana);
int maxMana = GetInt(p, MaximumMana);
if (currentMana != 0 || maxMana != 0)
AppendLine(text, "Mana",
$"{currentMana.ToString("N0", CultureInfo.CurrentCulture)} / {maxMana.ToString("N0", CultureInfo.CurrentCulture)}");
int currentUses = obj.Structure != 0 ? obj.Structure : GetInt(p, Structure);
int maxUses = obj.MaxStructure != 0 ? obj.MaxStructure : GetInt(p, MaxStructure);
if (currentUses != 0 || maxUses != 0)
AppendLine(text, "Uses",
maxUses > 0
? $"{currentUses.ToString(CultureInfo.CurrentCulture)} / {maxUses.ToString(CultureInfo.CurrentCulture)}"
: currentUses.ToString(CultureInfo.CurrentCulture));
if (GetInt(p, WieldRequirement) != 0)
AppendLine(
text,
"Wield Requirement",
$"{SkillName(GetInt(p, WieldSkill))} {GetInt(p, WieldDifficulty).ToString(CultureInfo.CurrentCulture)}");
if (appraisal.HookProfile is { } hook)
{
if ((hook.Flags & 0x1u) != 0)
AppendFlag(text, "Inscribable");
if ((hook.Flags & 0x2u) != 0)
AppendFlag(text, "Healer");
if ((hook.Flags & 0x4u) != 0)
AppendFlag(text, "Food");
if ((hook.Flags & 0x8u) != 0)
AppendFlag(text, "Lockpick");
}
foreach (uint rawSpellId in appraisal.SpellBook)
{
uint spellId = rawSpellId & 0x7FFF_FFFFu;
AppendFlag(text, resolveSpellName(spellId) ?? $"Spell {spellId}");
}
if (GetInt(p, RareId) != 0)
AppendFlag(text, "Rare Item");
string use = GetString(p, 14u);
if (!string.IsNullOrWhiteSpace(use))
AppendParagraph(text, use);
string shortDescription = GetString(p, 15u);
if (!string.IsNullOrWhiteSpace(shortDescription))
AppendParagraph(text, shortDescription);
string longDescription = GetString(p, 16u);
if (!string.IsNullOrWhiteSpace(longDescription)
&& !string.Equals(longDescription, shortDescription, StringComparison.Ordinal))
AppendParagraph(text, longDescription);
if (!appraisal.Success && text.Length == 0)
text.Append("Assessment incomplete");
return text.ToString().TrimEnd();
}
public static string BuildCreature(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
bool character)
{
ArgumentNullException.ThrowIfNull(obj);
PropertyBundle p = appraisal.Properties;
var text = new StringBuilder();
Append(text, "Level", GetInt(p, Level));
if (appraisal.CreatureProfile is { } creature)
{
AppendLine(text, "Health",
$"{creature.Health.ToString("N0", CultureInfo.CurrentCulture)} / {creature.HealthMax.ToString("N0", CultureInfo.CurrentCulture)}");
AppendOptional(text, "Stamina", creature.Stamina, creature.StaminaMax);
AppendOptional(text, "Mana", creature.Mana, creature.ManaMax);
AppendOptional(text, "Strength", creature.Strength);
AppendOptional(text, "Endurance", creature.Endurance);
AppendOptional(text, "Coordination", creature.Coordination);
AppendOptional(text, "Quickness", creature.Quickness);
AppendOptional(text, "Focus", creature.Focus);
AppendOptional(text, "Self", creature.Self);
}
string title = GetString(p, 2u);
if (character && !string.IsNullOrWhiteSpace(title))
AppendLine(text, "Title", title);
string description = GetString(p, 16u);
if (!string.IsNullOrWhiteSpace(description))
AppendParagraph(text, description);
if (!appraisal.Success && text.Length == 0)
text.Append("Assessment incomplete");
return text.ToString().TrimEnd();
}
public static string CreatureTypeName(int creatureType)
=> creatureType == 0
? string.Empty
: $"Creature type {creatureType.ToString(CultureInfo.CurrentCulture)}";
private static void AppendOptional(
StringBuilder text,
string label,
uint? current,
uint? maximum = null)
{
if (current is null)
return;
AppendLine(
text,
label,
maximum is { } max
? $"{current.Value.ToString("N0", CultureInfo.CurrentCulture)} / {max.ToString("N0", CultureInfo.CurrentCulture)}"
: current.Value.ToString("N0", CultureInfo.CurrentCulture));
}
private static void Append(
StringBuilder text,
string label,
int value,
string? format = null)
{
if (value == 0)
return;
AppendLine(
text,
label,
value.ToString(format ?? "0", CultureInfo.CurrentCulture));
}
private static void AppendPercent(StringBuilder text, string label, double value)
{
if (Math.Abs(value) < 0.000001)
return;
AppendLine(text, label, value.ToString("+0%;-0%;0%", CultureInfo.CurrentCulture));
}
private static void AppendProtection(StringBuilder text, string label, float value)
{
if (Math.Abs(value - 1f) < 0.0001f)
return;
AppendLine(text, label, value.ToString("0.00x", CultureInfo.CurrentCulture));
}
private static void AppendFlag(StringBuilder text, string value)
=> AppendParagraph(text, value);
private static void AppendLine(StringBuilder text, string label, string value)
{
if (string.IsNullOrWhiteSpace(value))
return;
if (text.Length != 0)
text.Append('\n');
text.Append(label).Append(": ").Append(value);
}
private static void AppendParagraph(StringBuilder text, string value)
{
if (text.Length != 0)
text.Append("\n\n");
text.Append(value);
}
private static int GetInt(PropertyBundle p, uint id)
=> p.Ints.TryGetValue(id, out int value) ? value : 0;
private static string GetString(PropertyBundle p, uint id)
=> p.Strings.TryGetValue(id, out string? value) ? value : string.Empty;
private static string DamageTypeName(uint type) => type switch
{
1u => "Slashing",
2u => "Piercing",
4u => "Bludgeoning",
8u => "Cold",
16u => "Fire",
32u => "Acid",
64u => "Electric",
1024u => "Nether",
_ => $"Type {type.ToString(CultureInfo.CurrentCulture)}",
};
private static string SkillName(int skill) => skill switch
{
1 => "Axe",
3 => "Dagger",
6 => "Mace",
9 => "Spear",
10 => "Staff",
12 => "Sword",
13 => "Thrown Weapons",
14 => "Unarmed Combat",
15 => "Arcane Lore",
18 => "Magic Defense",
19 => "Mana Conversion",
21 => "Item Tinkering",
22 => "Assess Person",
23 => "Deception",
24 => "Healing",
27 => "Lockpick",
30 => "Creature Enchantment",
31 => "Item Enchantment",
32 => "Life Magic",
34 => "War Magic",
40 => "Light Weapons",
41 => "Heavy Weapons",
42 => "Finesse Weapons",
43 => "Missile Weapons",
44 => "Shield",
45 => "Dual Wield",
46 => "Recklessness",
47 => "Sneak Attack",
48 => "Dirty Fighting",
54 => "Void Magic",
55 => "Summoning",
_ => $"Skill {skill.ToString(CultureInfo.CurrentCulture)}",
};
}

View file

@ -370,7 +370,7 @@ public sealed class CreatureAppraisalRowTemplateFactory
/// </summary>
public sealed class CreatureAppraisalLayeredList
{
public const float TextInset = 4f;
public const float TextInset = 8f;
private readonly CreatureAppraisalRowTemplateFactory _templates;
@ -403,7 +403,10 @@ public sealed class CreatureAppraisalLayeredList
backgroundHost.ZOrder = backgroundZOrder;
var scroll = new UiScrollable();
var background = NewList(
left: TextInset,
// The authored row chrome keeps its LayoutDesc origin. Only the
// foreground text is inset; moving both layers clips the left
// edge of the retail separator/background media.
left: 0f,
top: 0f,
width: templates.Width,
height: backgroundHost.Height,

File diff suppressed because it is too large Load diff