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:
parent
bc47bc4917
commit
d3c5e06fdd
21 changed files with 982 additions and 145 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
177
src/AcDream.App/UI/Layout/ItemAppraisalReport.cs
Normal file
177
src/AcDream.App/UI/Layout/ItemAppraisalReport.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue