fix(ui): match retail item appraisal semantics

Preserve PublicWeenieDesc hook identity from CreateObject through the item model so hook appraisals suppress sentinel capacities exactly. Use appraisal-only Value and Burden presence, retain AddItemInfo paragraph and authored font-color selection, and port retail lock, page, enchantment, and spell-block formatting.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 18:04:19 +02:00
parent bc47bc4917
commit d3c5e06fdd
21 changed files with 982 additions and 145 deletions

View file

@ -68,7 +68,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
private uint _creatureObjectId;
private uint _characterObjectId;
private string _titleValue = string.Empty;
private string _itemReport = string.Empty;
private ItemAppraisalReport _itemReport = ItemAppraisalReport.Empty;
private string _inscriptionValue = string.Empty;
private string _signatureValue = string.Empty;
private string _scribeName = string.Empty;
@ -131,7 +131,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
// 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);
ConfigureScrollableItemText(_itemText, ItemScrollbarId);
if (_inscriptionText is not null)
ConfigureScrollableText(
_inscriptionText,
@ -311,7 +311,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
public void ResetSession()
{
_titleValue = string.Empty;
_itemReport = string.Empty;
_itemReport = ItemAppraisalReport.Empty;
_inscriptionValue = string.Empty;
_inscriptionField?.SetText(string.Empty);
if (_inscriptionField is not null)
@ -340,7 +340,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
AppraiseInfoParser.Parsed appraisal,
bool newlySelected)
{
_itemReport = ItemAppraisalTextFormatter.Build(
_itemReport = ItemAppraisalTextFormatter.BuildReport(
obj,
appraisal,
ResolveSpell);
@ -370,8 +370,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
(PublicWeenieFlags)(obj.PublicWeenieBitfield ?? 0u);
bool publicInscribable =
(publicFlags & PublicWeenieFlags.Inscribable) != 0;
_presentationInscribable = appraisal.HookProfile is { } hook
? (hook.Flags & 0x1u) != 0
_presentationInscribable = obj.IsHook
? appraisal.HookProfile is { } hook
&& (hook.Flags & 0x1u) != 0
: publicInscribable;
if (!_presentationInscribable)
@ -559,6 +560,19 @@ public sealed class AppraisalUiController : IRetainedPanelController
scrollbar.Model = text.Scroll;
}
private void ConfigureScrollableItemText(
UiText text,
uint scrollbarId)
{
text.PreserveEndOnLayout = false;
text.WheelScrollEnabled = true;
text.ClickThrough = false;
text.LinesProvider = () =>
ItemAppraisalTextLayout.Shape(text, _itemReport);
if (_layout.FindElement(scrollbarId) is UiScrollbar scrollbar)
scrollbar.Model = text.Scroll;
}
private void SetText(uint elementId, string value, bool scrollable = false)
{
if (_layout.FindElement(elementId) is not UiText text)

View file

@ -572,6 +572,9 @@ public static class DatWidgetFactory
// it is the shared global fallback. Controllers that call FindElement and explicitly
// set DatFont afterward STILL override this (backward-compat guarantee).
DatFont = elementFont,
FontColorPalette = ElementReader.ReadEffectiveColorPalette(
info,
0x1Bu),
};
t.ConfigureDatState(info);

View file

@ -385,4 +385,38 @@ public static class ElementReader
}
}
}
/// <summary>
/// Resolves an authored color or color-array property after the retail
/// DirectState/default-state inheritance rules have been applied.
/// </summary>
internal static Vector4[] ReadEffectiveColorPalette(
ElementInfo info,
uint propertyId)
{
ArgumentNullException.ThrowIfNull(info);
if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value))
return [];
IEnumerable<UiPropertyValue> entries = value.Kind switch
{
UiPropertyKind.Color => [value],
UiPropertyKind.Array => value.ArrayValue,
_ => [],
};
return entries
.Where(entry => entry.Kind == UiPropertyKind.Color)
.Select(entry =>
{
UiColorValue color = entry.ColorValue;
float alpha = color.Alpha == 0 ? 1f : color.Alpha / 255f;
return new Vector4(
color.Red / 255f,
color.Green / 255f,
color.Blue / 255f,
alpha);
})
.ToArray();
}
}

View file

@ -0,0 +1,177 @@
using System.Numerics;
using System.Text;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Font-color indices selected by retail <c>ItemExamineUI</c>. The concrete
/// colors come from the item report's LayoutDesc property <c>0x1B</c>.
/// </summary>
public enum ItemAppraisalFontStyle
{
Normal = 0,
Beneficial = 1,
Detrimental = 2,
}
/// <summary>
/// Separator inserted before an appraisal fragment. This is the retained
/// equivalent of <c>ItemExamineUI::AddItemInfo</c>'s final argument.
/// </summary>
public enum ItemAppraisalSeparator
{
None,
Line,
Paragraph,
}
/// <summary>
/// One retail appraisal append operation: prose, separator, and authored
/// font-color index. Keeping these separate until shaping preserves style
/// when a long fragment wraps.
/// </summary>
public readonly record struct ItemAppraisalFragment(
string Text,
ItemAppraisalSeparator Separator,
ItemAppraisalFontStyle Style);
/// <summary>
/// Immutable item appraisal report in retail append order.
/// </summary>
public sealed class ItemAppraisalReport
{
public static ItemAppraisalReport Empty { get; } = new([]);
public ItemAppraisalReport(IReadOnlyList<ItemAppraisalFragment> fragments)
{
ArgumentNullException.ThrowIfNull(fragments);
Fragments = fragments;
}
public IReadOnlyList<ItemAppraisalFragment> Fragments { get; }
public bool IsEmpty => Fragments.Count == 0;
public override string ToString()
{
var text = new StringBuilder();
foreach (ItemAppraisalFragment fragment in Fragments)
{
if (text.Length != 0)
{
text.Append(fragment.Separator == ItemAppraisalSeparator.Paragraph
? "\n\n"
: "\n");
}
text.Append(fragment.Text);
}
return text.ToString();
}
}
/// <summary>
/// Port of <c>ItemExamineUI::AddItemInfo @ 0x004AC050/0x004ADCA0</c>.
/// Retail appends one newline when <paramref name="sameParagraph"/> is true,
/// two otherwise, then applies font DID index zero and the supplied color index.
/// </summary>
internal sealed class ItemAppraisalReportBuilder
{
private readonly List<ItemAppraisalFragment> _fragments = [];
public void Line(
string value,
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
=> Add(value, sameParagraph: true, style);
public void Paragraph(
string value,
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
=> Add(value, sameParagraph: false, style);
public ItemAppraisalReport Build()
=> _fragments.Count == 0
? ItemAppraisalReport.Empty
: new ItemAppraisalReport(_fragments.ToArray());
private void Add(
string value,
bool sameParagraph,
ItemAppraisalFontStyle style)
{
if (string.IsNullOrWhiteSpace(value))
return;
_fragments.Add(new ItemAppraisalFragment(
value,
_fragments.Count == 0
? ItemAppraisalSeparator.None
: sameParagraph
? ItemAppraisalSeparator.Line
: ItemAppraisalSeparator.Paragraph,
style));
}
}
/// <summary>
/// Width-aware projection of retail appraisal fragments into the retained
/// <see cref="UiText"/> line model.
/// </summary>
internal static class ItemAppraisalTextLayout
{
public static IReadOnlyList<UiText.Line> Shape(
UiText target,
ItemAppraisalReport report)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(report);
float maxWidth = Math.Max(1f, target.Width - (2f * target.Padding));
float Measure(string value)
=> target.DatFont?.MeasureWidth(value)
?? target.Font?.MeasureWidth(value)
?? value.Length * 8f;
var lines = new List<UiText.Line>();
foreach (ItemAppraisalFragment fragment in report.Fragments)
{
if (lines.Count != 0
&& fragment.Separator == ItemAppraisalSeparator.Paragraph)
{
lines.Add(new UiText.Line(string.Empty, ResolveColor(target, fragment.Style)));
}
Vector4 color = ResolveColor(target, fragment.Style);
string normalized = fragment.Text.Replace(
"\\n",
"\n",
StringComparison.Ordinal);
foreach (string logicalLine in normalized.Split('\n'))
{
if (logicalLine.Length == 0)
{
lines.Add(new UiText.Line(string.Empty, color));
continue;
}
foreach (string wrapped in ChatWindowController.WrapText(
logicalLine,
maxWidth,
Measure))
{
lines.Add(new UiText.Line(wrapped, color));
}
}
}
return lines;
}
private static Vector4 ResolveColor(
UiText target,
ItemAppraisalFontStyle style)
{
int index = (int)style;
return index >= 0 && index < target.FontColorPalette.Count
? target.FontColorPalette[index]
: target.DefaultColor;
}
}

View file

@ -27,6 +27,12 @@ public static class ItemAppraisalTextFormatter
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
Func<uint, SpellMetadata?> resolveSpell)
=> BuildReport(obj, appraisal, resolveSpell).ToString();
public static ItemAppraisalReport BuildReport(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
Func<uint, SpellMetadata?> resolveSpell)
{
ArgumentNullException.ThrowIfNull(obj);
ArgumentNullException.ThrowIfNull(resolveSpell);
@ -35,11 +41,11 @@ public static class ItemAppraisalTextFormatter
var report = new RetailReportBuilder();
// ItemExamineUI::SetAppraiseInfo @ 0x004B72B0.
ShowValueAndBurden(report, obj, properties);
ShowValueAndBurden(report, properties);
ShowTinkering(report, obj, properties);
ShowSetAndRatings(report, properties);
ShowWeaponAndArmor(report, obj, appraisal);
ShowDefenseModifiers(report, properties);
ShowDefenseModifiers(report, appraisal);
ShowShortMagicInfo(report, appraisal, resolveSpell);
ShowSpecialProperties(report, obj, properties);
ShowUsage(report, properties);
@ -48,9 +54,9 @@ public static class ItemAppraisalTextFormatter
ShowUsageLimits(report, properties);
ShowItemLevel(report, properties);
ShowActivationRequirements(report, properties);
ShowCasterData(report, properties);
ShowCasterData(report, appraisal);
ShowBoostAndHealing(report, properties);
ShowCapacityAndLock(report, obj, properties);
ShowCapacityAndLock(report, obj, appraisal);
ShowManaStone(report, appraisal);
ShowRemainingUses(report, obj, appraisal);
ShowCraftsman(report, properties);
@ -58,22 +64,23 @@ public static class ItemAppraisalTextFormatter
ShowMagicInfo(report, appraisal, resolveSpell);
ShowDescription(report, properties);
if (!appraisal.Success && report.IsEmpty)
report.Paragraph("Assessment incomplete");
return report.ToString();
return report.Build();
}
private static void ShowValueAndBurden(
RetailReportBuilder report,
ClientObject obj,
PropertyBundle properties)
{
int value = obj.Value != 0 ? obj.Value : properties.GetInt(19u);
int burden = obj.Burden != 0 ? obj.Burden : properties.GetInt(5u);
if (value != 0)
report.Line($"Value: {value.ToString("N0", CultureInfo.CurrentCulture)}");
if (burden != 0)
report.Line($"Burden: {burden.ToString("N0", CultureInfo.CurrentCulture)}");
// Appraisal_ShowValueInfo @ 0x004ADEA0 and
// Appraisal_ShowBurdenInfo @ 0x004AE180 query only the appraisal
// profile. PublicWeenieDesc values must not leak through a failed or
// deliberately incomplete assessment.
report.Line(properties.Ints.TryGetValue(19u, out int value)
? $"Value: {value.ToString("N0", CultureInfo.InvariantCulture)}"
: "Value: ???");
report.Line(properties.Ints.TryGetValue(5u, out int burden)
? $"Burden: {burden.ToString("N0", CultureInfo.InvariantCulture)}"
: "Burden: Unknown");
}
/// <summary>
@ -161,15 +168,18 @@ public static class ItemAppraisalTextFormatter
{
PropertyBundle properties = appraisal.Properties;
uint validLocations = (uint)obj.ValidLocations;
uint ammoType = appraisal.HookProfile?.AmmoType
?? obj.AmmoType
?? 0u;
uint ammoType = obj.IsHook && appraisal.HookProfile is { } hook
? hook.AmmoType
: obj.AmmoType ?? 0u;
if ((validLocations & (uint)EquipMask.Shield) != 0)
{
if (properties.Ints.TryGetValue(28u, out int shieldLevel))
report.Line(
$"Base Shield Level: {shieldLevel.ToString(CultureInfo.CurrentCulture)}");
$"Base Shield Level: {shieldLevel.ToString(CultureInfo.CurrentCulture)}",
EnchantmentStyle(
appraisal.ArmorEnchantments,
0x0001u));
else if (!appraisal.Success)
report.Line("Shield Level: Unknown");
}
@ -191,7 +201,16 @@ public static class ItemAppraisalTextFormatter
: weapon.Damage.ToString(CultureInfo.CurrentCulture);
if (!launcher)
damage += $", {DamageTypeName(weapon.DamageType)}";
report.Line($"{damageLabel}: {damage}");
ItemAppraisalFontStyle damageStyle = EnchantmentStyle(
appraisal.WeaponEnchantments,
0x0008u);
if (damageStyle == ItemAppraisalFontStyle.Normal)
{
damageStyle = EnchantmentStyle(
appraisal.WeaponEnchantments,
0x0010u);
}
report.Line($"{damageLabel}: {damage}", damageStyle);
int elementalBonus = properties.GetInt(204u);
if (elementalBonus > 0)
@ -202,7 +221,10 @@ public static class ItemAppraisalTextFormatter
if (launcher)
report.Line(
$"Damage Modifier: {FormatModifier(weapon.DamageMod)}.");
$"Damage Modifier: {FormatModifier(weapon.DamageMod)}.",
EnchantmentStyle(
appraisal.WeaponEnchantments,
0x0020u));
const uint timedWeaponLocations =
(uint)(EquipMask.MeleeWeapon
@ -211,7 +233,10 @@ public static class ItemAppraisalTextFormatter
if ((validLocations & timedWeaponLocations) != 0)
report.Line(
$"Speed: {WeaponTimeName((int)weapon.WeaponTime)} "
+ $"({weapon.WeaponTime.ToString(CultureInfo.CurrentCulture)})");
+ $"({weapon.WeaponTime.ToString(CultureInfo.CurrentCulture)})",
EnchantmentStyle(
appraisal.WeaponEnchantments,
0x0004u));
if (launcher && weapon.MaxVelocity > 0d)
{
@ -235,7 +260,10 @@ public static class ItemAppraisalTextFormatter
if (!launcher && Math.Abs(weapon.WeaponOffense - 1d) > 0.000001d)
report.Line(
$"Bonus to Attack Skill: "
+ $"{FormatSignedPercent(weapon.WeaponOffense - 1d)}.");
+ $"{FormatSignedPercent(weapon.WeaponOffense - 1d)}.",
EnchantmentStyle(
appraisal.WeaponEnchantments,
0x0001u));
}
ShowAmmunitionDescription(report, validLocations, ammoType);
@ -247,15 +275,32 @@ public static class ItemAppraisalTextFormatter
report.Line(
appraisal.Success
? $"Armor Level: {armorLevel.ToString(CultureInfo.CurrentCulture)}"
: "Armor Level: Unknown");
ShowProtection(report, "Slashing", armorLevel, armor.SlashingProtection);
ShowProtection(report, "Piercing", armorLevel, armor.PiercingProtection);
ShowProtection(report, "Bludgeoning", armorLevel, armor.BludgeoningProtection);
ShowProtection(report, "Fire", armorLevel, armor.FireProtection);
ShowProtection(report, "Cold", armorLevel, armor.ColdProtection);
ShowProtection(report, "Acid", armorLevel, armor.AcidProtection);
ShowProtection(report, "Electric", armorLevel, armor.LightningProtection);
ShowProtection(report, "Nether", armorLevel, armor.NetherProtection);
: "Armor Level: Unknown",
EnchantmentStyle(appraisal.ArmorEnchantments, 0x0001u));
ShowProtection(
report, "Slashing", armorLevel, armor.SlashingProtection,
EnchantmentStyle(appraisal.ArmorEnchantments, 0x0002u));
ShowProtection(
report, "Piercing", armorLevel, armor.PiercingProtection,
EnchantmentStyle(appraisal.ArmorEnchantments, 0x0004u));
ShowProtection(
report, "Bludgeoning", armorLevel, armor.BludgeoningProtection,
EnchantmentStyle(appraisal.ArmorEnchantments, 0x0008u));
ShowProtection(
report, "Fire", armorLevel, armor.FireProtection,
EnchantmentStyle(appraisal.ArmorEnchantments, 0x0020u));
ShowProtection(
report, "Cold", armorLevel, armor.ColdProtection,
EnchantmentStyle(appraisal.ArmorEnchantments, 0x0010u));
ShowProtection(
report, "Acid", armorLevel, armor.AcidProtection,
EnchantmentStyle(appraisal.ArmorEnchantments, 0x0040u));
ShowProtection(
report, "Electric", armorLevel, armor.LightningProtection,
EnchantmentStyle(appraisal.ArmorEnchantments, 0x0080u));
ShowProtection(
report, "Nether", armorLevel, armor.NetherProtection,
EnchantmentStyle(appraisal.ArmorEnchantments, 0x0100u));
}
private static void ShowAmmunitionDescription(
@ -292,7 +337,8 @@ public static class ItemAppraisalTextFormatter
RetailReportBuilder report,
string damageType,
int armorLevel,
float modifier)
float modifier,
ItemAppraisalFontStyle style)
{
// AppraisalSystem::DamageResistanceToString @ 0x005B5490.
string quality = modifier switch
@ -308,15 +354,22 @@ public static class ItemAppraisalTextFormatter
double effective = armorLevel * modifier;
report.Line(
$"{damageType}: {quality} "
+ $"({effective.ToString("0", CultureInfo.CurrentCulture)})");
+ $"({effective.ToString("0", CultureInfo.CurrentCulture)})",
style);
}
/// <summary><c>Appraisal_ShowDefenseModData @ 0x004B1DF0</c>.</summary>
private static void ShowDefenseModifiers(
RetailReportBuilder report,
PropertyBundle properties)
AppraiseInfoParser.Parsed appraisal)
{
ShowModifier(report, properties, 29u, "Bonus to Melee Defense");
PropertyBundle properties = appraisal.Properties;
ShowModifier(
report,
properties,
29u,
"Bonus to Melee Defense",
EnchantmentStyle(appraisal.WeaponEnchantments, 0x0002u));
ShowModifier(report, properties, 149u, "Bonus to Missile Defense");
ShowModifier(report, properties, 150u, "Bonus to Magic Defense");
}
@ -325,12 +378,15 @@ public static class ItemAppraisalTextFormatter
RetailReportBuilder report,
PropertyBundle properties,
uint property,
string label)
string label,
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
{
if (!properties.Floats.TryGetValue(property, out double modifier)
|| Math.Abs(modifier - 1d) <= 0.000001d)
return;
report.Line($"{label}: {FormatSignedPercent(modifier - 1d)}.");
report.Line(
$"{label}: {FormatSignedPercent(modifier - 1d)}.",
style);
}
/// <summary><c>Appraisal_ShowShortMagicInfo @ 0x004B2C90</c>.</summary>
@ -343,7 +399,7 @@ public static class ItemAppraisalTextFormatter
return;
if (!appraisal.Success)
{
report.Line("Spells: unknown.");
report.Paragraph("Spells: unknown.");
return;
}
@ -355,7 +411,7 @@ public static class ItemAppraisalTextFormatter
return resolveSpell(id)?.Name
?? $"Spell {id.ToString(CultureInfo.CurrentCulture)}";
}));
report.Line($"Spells: {names}");
report.Paragraph($"Spells: {names}");
}
/// <summary><c>Appraisal_ShowSpecialProperties @ 0x004B0140</c>.</summary>
@ -491,22 +547,22 @@ public static class ItemAppraisalTextFormatter
int minimum = properties.GetInt(86u);
int maximum = properties.GetInt(87u);
if (minimum > 0 && maximum > 0)
report.Line(
report.Paragraph(
$"Restricted to characters of level "
+ $"{minimum.ToString(CultureInfo.CurrentCulture)} to "
+ $"{maximum.ToString(CultureInfo.CurrentCulture)}.");
else if (minimum > 0)
report.Line(
report.Paragraph(
$"Restricted to characters of level "
+ $"{minimum.ToString(CultureInfo.CurrentCulture)} or greater.");
else if (maximum > 0)
report.Line(
report.Paragraph(
$"Restricted to characters of level "
+ $"{maximum.ToString(CultureInfo.CurrentCulture)} or lower.");
string destination = properties.GetString(38u);
if (!string.IsNullOrWhiteSpace(destination))
report.Line($"Destination: {destination}");
report.Paragraph($"Destination: {destination}");
}
/// <summary><c>Appraisal_ShowWieldRequirements @ 0x004AF9A0</c>.</summary>
@ -685,18 +741,25 @@ public static class ItemAppraisalTextFormatter
/// <summary><c>Appraisal_ShowCasterData @ 0x004B1B10</c>.</summary>
private static void ShowCasterData(
RetailReportBuilder report,
PropertyBundle properties)
AppraiseInfoParser.Parsed appraisal)
{
PropertyBundle properties = appraisal.Properties;
if (properties.Floats.TryGetValue(144u, out double manaConversion))
report.Line(
report.Paragraph(
$"Bonus to Mana Conversion: "
+ $"{FormatSignedPercent(manaConversion)}.");
+ $"{FormatSignedPercent(manaConversion)}.",
EnchantmentStyle(
appraisal.ResistEnchantments,
0x1000u));
if (properties.Floats.TryGetValue(152u, out double elemental)
&& properties.Ints.TryGetValue(45u, out int damageType))
{
report.Line(
$"Damage bonus for {DamageTypeName((uint)damageType)} spells:");
report.Paragraph(
$"Damage bonus for {DamageTypeName((uint)damageType)} spells:",
EnchantmentStyle(
appraisal.ResistEnchantments,
0x2000u));
report.Line($" vs. Monsters: {FormatSignedPercent(elemental - 1d)}.");
double playerModifier = 1d + (elemental - 1d) * 0.25d;
report.Line(
@ -721,13 +784,13 @@ public static class ItemAppraisalTextFormatter
_ => null,
};
if (boost != 0 && vital is not null)
report.Line(
report.Paragraph(
$"{(boost > 0 ? "Restores" : "Depletes")} "
+ $"{Math.Abs(boost).ToString(CultureInfo.CurrentCulture)} "
+ $"{vital} when used.");
if (boost != 0 && properties.Floats.ContainsKey(100u))
report.Line(
report.Paragraph(
$"Bonus to Healing Skill: {boost.ToString(CultureInfo.CurrentCulture)}");
if (properties.Floats.TryGetValue(100u, out double healKitModifier))
report.Line(
@ -742,36 +805,73 @@ public static class ItemAppraisalTextFormatter
private static void ShowCapacityAndLock(
RetailReportBuilder report,
ClientObject obj,
PropertyBundle properties)
AppraiseInfoParser.Parsed appraisal)
{
if (obj.ItemsCapacity > 0 && obj.ContainersCapacity > 0)
report.Line(
$"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.CurrentCulture)} "
+ $"items and {obj.ContainersCapacity.ToString(CultureInfo.CurrentCulture)} containers.");
else if (obj.ItemsCapacity > 0)
report.Line(
$"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.CurrentCulture)} items.");
else if (obj.ContainersCapacity > 0)
report.Line(
$"Can hold up to {obj.ContainersCapacity.ToString(CultureInfo.CurrentCulture)} containers.");
PropertyBundle properties = appraisal.Properties;
// Appraisal_ShowCapacity @ 0x004B2680 suppresses the hook object's
// own container capacity while a HookProfile describes the item
// mounted on it. This is the Black Phyntos Hive 255/255 case.
if (!obj.IsHook || appraisal.HookProfile is null)
{
if (obj.ItemsCapacity > 0 && obj.ContainersCapacity > 0)
report.Paragraph(
$"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.CurrentCulture)} "
+ $"items and {obj.ContainersCapacity.ToString(CultureInfo.CurrentCulture)} containers.");
else if (obj.ItemsCapacity > 0)
report.Paragraph(
$"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.CurrentCulture)} items.");
else if (obj.ContainersCapacity > 0)
report.Paragraph(
$"Can hold up to {obj.ContainersCapacity.ToString(CultureInfo.CurrentCulture)} containers.");
int pages = properties.GetInt(174u);
int pagesUsed = properties.GetInt(175u);
if (pages > 0)
report.Line(
$"Pages: {pagesUsed.ToString(CultureInfo.CurrentCulture)} / "
+ $"{pages.ToString(CultureInfo.CurrentCulture)}");
int pages = properties.GetInt(175u);
int pagesUsed = properties.GetInt(174u);
if (pages > 0)
report.Paragraph(
$"{pagesUsed.ToString(CultureInfo.CurrentCulture)} of "
+ $"{pages.ToString(CultureInfo.CurrentCulture)} pages full.");
}
if (properties.GetBool(3u))
report.Line("This item is locked.");
int resistance = properties.GetInt(38u);
if (resistance > 0)
report.Line(
$"Resistance to Lockpick: {resistance.ToString(CultureInfo.CurrentCulture)}");
int chance = properties.GetInt(173u);
if (chance > 0)
report.Line(
$"Chance of success: {chance.ToString(CultureInfo.CurrentCulture)}%");
// Appraisal_ShowLockAppraiseInfo @ 0x004B2790 does not present lock
// data for hook objects.
if (obj.IsHook)
return;
bool hasLocked = properties.Bools.TryGetValue(3u, out bool locked);
bool hasResistance = properties.Ints.TryGetValue(
38u,
out int resistance);
if (!hasLocked)
{
if (hasResistance && resistance != 0)
{
report.Paragraph(
$"Bonus to Lockpick Skill: "
+ $"{resistance.ToString("+0;-0;0", CultureInfo.CurrentCulture)}");
}
return;
}
if (!locked)
{
report.Paragraph("Unlocked");
return;
}
report.Paragraph("Locked");
if (!hasResistance)
{
report.Paragraph("You can't tell how hard the lock is to pick.");
return;
}
if (properties.Ints.TryGetValue(173u, out int chance)
&& LockpickDifficulty(chance) is { } difficulty)
{
report.Paragraph(
$"The lock looks {difficulty} to pick "
+ $"(Resistance {resistance.ToString(CultureInfo.CurrentCulture)}).");
}
}
/// <summary><c>Appraisal_ShowManaStoneInfo @ 0x004B3900</c>.</summary>
@ -808,7 +908,7 @@ public static class ItemAppraisalTextFormatter
if (properties.GetBool(63u))
{
report.Line("Number of uses remaining: Unlimited");
report.Line("Number of uses remaining: Unlimited");
return;
}
@ -820,7 +920,7 @@ public static class ItemAppraisalTextFormatter
&& (obj.MaxStructure != 0
|| appraisal.HookProfile is { Flags: var flags }
&& (flags & 0xAu) != 0))
report.Line("Number of uses remaining: Unknown");
report.Paragraph("Number of uses remaining: Unknown");
}
private static void ShowCraftsman(
@ -842,7 +942,7 @@ public static class ItemAppraisalTextFormatter
report.Line("This item cannot be sold.");
int rare = properties.GetInt(17u);
if (rare > 0)
report.Line($"Rare #{rare.ToString(CultureInfo.CurrentCulture)}");
report.Paragraph($"Rare #{rare.ToString(CultureInfo.CurrentCulture)}");
}
/// <summary><c>Appraisal_ShowMagicInfo @ 0x004B2E10</c>.</summary>
@ -888,24 +988,46 @@ public static class ItemAppraisalTextFormatter
}
else if (properties.Ints.TryGetValue(117u, out int manaCost))
{
report.Line($"Mana Cost: {manaCost.ToString(CultureInfo.CurrentCulture)}.");
string manaCostText =
$"Mana Cost: {manaCost.ToString(CultureInfo.CurrentCulture)}.";
if (manaCost > 0)
report.Line("(Can be reduced by the Mana Conversion skill).");
{
manaCostText +=
"\n(Can be reduced by the Mana Conversion skill).";
}
report.Paragraph(manaCostText);
}
report.Paragraph("Spell Descriptions:");
foreach ((uint id, SpellMetadata? metadata) in ordinary)
report.Spell(metadata?.Name, metadata?.Description, id);
report.Paragraph(BuildSpellDescriptionBlock(
"Spell Descriptions:",
ordinary));
}
if (enchantments.Count != 0)
{
report.Paragraph("Enchantments:");
foreach ((uint id, SpellMetadata? metadata) in enchantments)
report.Spell(metadata?.Name, metadata?.Description, id);
report.Paragraph(BuildSpellDescriptionBlock(
"Enchantments:",
enchantments));
}
}
private static string BuildSpellDescriptionBlock(
string heading,
IReadOnlyList<(uint Id, SpellMetadata? Metadata)> spells)
{
var text = new StringBuilder(heading);
foreach ((uint id, SpellMetadata? metadata) in spells)
{
string name = !string.IsNullOrWhiteSpace(metadata?.Name)
? metadata.Name
: $"Spell {id.ToString(CultureInfo.CurrentCulture)}";
text.Append('\n').Append(name);
if (!string.IsNullOrWhiteSpace(metadata?.Description))
text.Append('\n').Append(metadata.Description);
}
return text.ToString();
}
private static void ShowDescription(
RetailReportBuilder report,
PropertyBundle properties)
@ -979,6 +1101,47 @@ public static class ItemAppraisalTextFormatter
private static string FormatSignedPercent(double modifier)
=> modifier.ToString("+0%;-0%;0%", CultureInfo.CurrentCulture);
/// <summary>
/// Port of <c>AppraisalSystem::LockpickSuccessPercentToString
/// @ 0x005B4600</c>.
/// </summary>
private static string? LockpickDifficulty(int successPercent)
=> successPercent switch
{
< 0 => null,
0 => "impossible",
< 5 => "ridiculously difficult",
< 15 => "extremely difficult",
< 35 => "quite difficult",
< 50 => "difficult",
< 70 => "challenging",
< 85 => "mildly challenging",
< 95 => "easy",
_ => "trivial",
};
/// <summary>
/// Port of <c>AppraisalProfile::InqIntEnchantmentMod @ 0x005B2C10</c>
/// and <c>InqFloatEnchantmentMod @ 0x005B2C70</c>. The wire carries the
/// low/high halves as two consecutive u16 values: the low bit marks an
/// enchanted value and the corresponding bit sixteen positions higher
/// selects the beneficial color.
/// </summary>
private static ItemAppraisalFontStyle EnchantmentStyle(
(ushort Highlight, ushort Color)? encoded,
uint lowBit)
{
if (encoded is not { } halves)
return ItemAppraisalFontStyle.Normal;
uint bitfield = halves.Highlight | ((uint)halves.Color << 16);
if ((bitfield & lowBit) == 0u)
return ItemAppraisalFontStyle.Normal;
return (bitfield & (lowBit << 16)) != 0u
? ItemAppraisalFontStyle.Beneficial
: ItemAppraisalFontStyle.Detrimental;
}
private static string DamageTypeName(uint type) => type switch
{
1u => "Slashing",
@ -1093,39 +1256,18 @@ public static class ItemAppraisalTextFormatter
private sealed class RetailReportBuilder
{
private readonly StringBuilder _text = new();
private readonly ItemAppraisalReportBuilder _report = new();
public bool IsEmpty => _text.Length == 0;
public void Line(
string value,
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
=> _report.Line(value, style);
public void Line(string value)
{
if (string.IsNullOrWhiteSpace(value))
return;
if (_text.Length != 0 && _text[^1] != '\n')
_text.Append('\n');
_text.Append(value);
}
public void Paragraph(
string value,
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
=> _report.Paragraph(value, style);
public void Paragraph(string value)
{
if (string.IsNullOrWhiteSpace(value))
return;
if (_text.Length != 0)
_text.Append("\n\n");
_text.Append(value);
}
public void Spell(string? name, string? description, uint spellId)
{
string resolvedName = !string.IsNullOrWhiteSpace(name)
? name
: $"Spell {spellId.ToString(CultureInfo.CurrentCulture)}";
Paragraph(
string.IsNullOrWhiteSpace(description)
? resolvedName
: $"{resolvedName}\n{description}");
}
public override string ToString() => _text.ToString().TrimEnd();
public ItemAppraisalReport Build() => _report.Build();
}
}

View file

@ -64,6 +64,15 @@ public sealed class UiText : UiElement, IUiDatStateful
/// </summary>
public Vector4 DefaultColor { get; set; } = Vector4.One;
/// <summary>
/// Authored <c>UIElement_Text</c> font-color list from LayoutDesc property
/// <c>0x1B</c>. Retail <c>AppendTextWithFont @ 0x00469D70</c> selects an
/// entry by index for every appended fragment. Controllers that port that
/// API use this palette instead of hard-coded colors.
/// </summary>
public IReadOnlyList<Vector4> FontColorPalette { get; set; }
= Array.Empty<Vector4>();
/// <summary>Backing fill behind the text. Defaults to transparent so an unbound
/// UiText (no controller) draws nothing. Set to the retail translucent value by
/// the controller (e.g. <c>ChatWindowController</c>).</summary>