acdream/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs
Erik ec854db045 fix(app,runtime,headless): Campaign CC slice CC4 review-fix round — F1-F12
Dual-lens review of CC4 (0e71d3b8) returned architectural FAIL (F1, F6)
and retail-fidelity PASS-with-reservations (F2, F3, F4), plus LOW
findings F5, F7-F12. F13 (TS-82's merge collision with campaign-cc6a) is
merge mechanics for the orchestrator, not addressed here.

F1 (HIGH, blocking): CharacterCreationUiController never released
UiRoot.FixedCanvasSize, on a FALSE premise that CharacterManagementUi-
Controller does a per-tick set (it does not — it sets once on activation
and nulls on Deactivate/Dispose). Root cause: RuntimeCharacterCreation-
State had no CompleteEnter() analogue to RuntimeCharacterSelectionState's,
so the creation view reported IsActive=true for an entire in-world
session. Added CompleteEnter(), wired at both LiveSessionController
in-world edges (StartCore, EnterHighlightedCore); made Open/Close/
Deactivate/Dispose set/null the canvas symmetrically; corrected the false
comment and ledger claim; added FixedCanvasSize test coverage.

F2 (MEDIUM-HIGH, blocking): the attribute-slider scalar mapping was not
retail's. Fixed display to value/100f (UpdateAttributeValues @
0x0048251d) and the drag inverse to truncate+clamp-low-only, no rescale
(ListenToElementMessage @ 0x004829c0, independently re-verified against
the decomp). Added tests at scalar 0.5/0.0 plus a display-direction test.

F3 (MEDIUM, blocking): ported the unported heritage-button tab-restore
arm (ListenToElementMessage @ 0x004e9450) — SHOW/HIDE id sets independently
re-derived from the decomp, including the genuine Lugian (0x100005f1)
no-restore quirk, reproduced faithfully. Wired via a new HeritagePage
click callback; added restore + quirk tests.

F4 (MEDIUM): ported SetTown's (@ 0x0047c360) separate per-town page-root
state literal (Holtburg->0x10000034 etc.), independently re-derived from
the decomp's tail-merged branches; wired via the existing
IUiDatStateful.TrySetRetailState seam; added a test.

F5 (MEDIUM): softened AD-103's unmeasured pixel-equivalence claim.

F6 (MEDIUM, blocking): DECISION — install ChargenOptions in the headless
content path (chosen over marking headless creation out-of-scope).
HeadlessSessionHost now calls InstallOptions off the shared content
lease's Dats, beside the existing InstallSpellMetadata call.

F7: AP-213 already named the label format and click/double-click
substitution explicitly on inspection — no edit needed.
F8: AP-212 now names all six DoRandom primitives with a known landing site.
F9: AD-101 retirement corrected to precede CC5's Finish un-ghosting.
F10: merged ItemAppraisalTextFormatter's duplicate <summary> block.
F11: fixed TS-82's wrong AP-211 cross-reference.
F12: cached the chargen DatStringResolver once per composition instead of
per ResolveText call.

Runtime 1713/0, App 5125/13 skips (+8 new tests), Headless 165/0, full
solution Release build green. Live-DAT probes 7/7 under
ACDREAM_PROBE_LIVE_MOUNT=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 18:29:49 +02:00

1830 lines
66 KiB
C#

using System.Globalization;
using System.Text;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Pure text projection for retail's authored item-examination list.
/// Ordering and wording follow <c>ItemExamineUI::SetAppraiseInfo @ 0x004B72B0</c>
/// and its <c>Appraisal_Show*</c> helpers. Static spell prose comes from the
/// installed portal.dat spell table, like retail's <c>ClientMagicSystem</c>.
/// </summary>
public static class ItemAppraisalTextFormatter
{
private static readonly (uint Requirement, uint Stat, uint Difficulty)[]
WieldRequirements =
[
(158u, 159u, 160u),
(270u, 271u, 272u),
(273u, 274u, 275u),
(276u, 277u, 278u),
];
public static string Build(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
Func<uint, SpellMetadata?> resolveSpell,
RetailAppraisalNameResolver? names = null)
=> BuildReport(obj, appraisal, resolveSpell, names).ToString();
public static ItemAppraisalReport BuildReport(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
Func<uint, SpellMetadata?> resolveSpell,
RetailAppraisalNameResolver? names = null)
{
ArgumentNullException.ThrowIfNull(obj);
ArgumentNullException.ThrowIfNull(resolveSpell);
names ??= RetailAppraisalNameResolver.Empty;
PropertyBundle properties = appraisal.Properties;
var report = new RetailReportBuilder();
// ItemExamineUI::SetAppraiseInfo @ 0x004B72B0.
ShowValueAndBurden(report, properties);
ShowTinkering(report, properties);
ShowSetAndRatings(report, properties);
ShowWeaponAndArmor(report, obj, appraisal);
ShowDefenseModifiers(report, appraisal);
ShowArmorModifiers(report, appraisal);
ShowShortMagicInfo(report, appraisal, resolveSpell);
ShowSpecialProperties(report, properties, names);
ShowUsage(report, properties);
ShowLevelLimits(report, properties);
ShowWieldRequirements(report, properties, names);
ShowUsageLimits(report, properties);
ShowItemLevel(report, properties);
ShowActivationRequirements(
report,
properties,
appraisal.Success,
names);
ShowCasterData(report, appraisal);
ShowBoostAndHealing(report, obj, appraisal);
ShowCapacityAndLock(report, obj, appraisal);
ShowManaStone(report, appraisal);
ShowRemainingUses(report, obj, appraisal);
ShowCraftsman(report, properties);
ShowSaleAndRareInfo(report, properties);
ShowMagicInfo(report, appraisal, resolveSpell);
ShowDescription(report, properties, names);
return report.Build();
}
private static void ShowValueAndBurden(
RetailReportBuilder report,
PropertyBundle properties)
{
// 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>
/// <c>Appraisal_ShowTinkeringInfo @ 0x004B0E70</c>.
/// </summary>
private static void ShowTinkering(
RetailReportBuilder report,
PropertyBundle properties)
{
if (properties.Ints.TryGetValue(171u, out int tinkers))
{
report.Line(
$"This item has been tinkered {tinkers.ToString(CultureInfo.CurrentCulture)} "
+ (tinkers == 1 ? "time." : "times."));
}
string lastTinkeredBy = properties.GetString(39u);
if (!string.IsNullOrWhiteSpace(lastTinkeredBy))
report.Line($"Last tinkered by {lastTinkeredBy}.");
string imbuedBy = properties.GetString(40u);
if (!string.IsNullOrWhiteSpace(imbuedBy))
report.Line($"Imbued by {imbuedBy}.");
if (properties.Ints.TryGetValue(105u, out int workmanship))
{
if (properties.Ints.TryGetValue(170u, out int salvagedItems)
&& salvagedItems > 0)
{
double average = (double)workmanship / salvagedItems;
int workmanshipBand = Math.Clamp(
(int)Math.Round(average),
0,
10);
report.Line(
$"Workmanship: {WorkmanshipAdjective(workmanshipBand)} "
+ $"({average.ToString("0.00", CultureInfo.CurrentCulture)})");
report.Paragraph(
$"Salvaged from {salvagedItems.ToString(CultureInfo.CurrentCulture)} items.");
}
else
{
report.Line(
$"Workmanship: {WorkmanshipAdjective(Math.Clamp(workmanship, 0, 10))} "
+ $"({workmanship.ToString(CultureInfo.CurrentCulture)})");
}
}
// Appraisal_ShowTinkeringInfo @ 0x004B1090 appends an empty
// AddItemInfo entry unconditionally, establishing the next section.
report.BlankLine();
}
/// <summary>
/// <c>Appraisal_ShowSet @ 0x004AE880</c> and
/// <c>Appraisal_ShowRatings @ 0x004AF080</c>.
/// </summary>
private static void ShowSetAndRatings(
RetailReportBuilder report,
PropertyBundle properties)
{
string? setName = properties.Ints.TryGetValue(265u, out int setId)
? EquipmentSetName(setId)
: null;
bool setShown = setName is not null;
if (setShown)
report.Line($"Set: {setName}");
(uint Property, string Label)[] ratingProperties =
[
(370u, "Dam"),
(371u, "Dam Resist"),
(372u, "Crit"),
(374u, "Crit Dam"),
(373u, "Crit Resist"),
(375u, "Crit Dam Resist"),
(376u, "Heal Boost"),
(377u, "Nether Resist"),
(378u, "Life Resist"),
];
string[] ratings = ratingProperties
.Where(pair => properties.Ints.TryGetValue(pair.Property, out int value)
&& value > 0)
.Select(pair =>
$"{pair.Label} {properties.GetInt(pair.Property).ToString(CultureInfo.CurrentCulture)}")
.ToArray();
bool ratingsShown = ratings.Length != 0;
if (ratingsShown)
report.Line($"Ratings: {string.Join(", ", ratings)}");
if (properties.GetInt(379u) is int vitality && vitality > 0)
{
report.Line(
$"This item adds {vitality.ToString(CultureInfo.CurrentCulture)} Vitality.");
ratingsShown = true;
}
// Appraisal_ShowRatings terminates its emitted block with an empty
// append; SetAppraiseInfo adds another when either Set or Ratings
// contributed content.
if (ratingsShown)
report.BlankLine();
if (setShown || ratingsShown)
report.BlankLine();
}
/// <summary>
/// <c>Appraisal_ShowWeaponAndArmorData @ 0x004B10E0</c> and
/// <c>Appraisal_ShowArmorModsData @ 0x004B1F60</c>.
/// </summary>
private static void ShowWeaponAndArmor(
RetailReportBuilder report,
ClientObject obj,
AppraiseInfoParser.Parsed appraisal)
{
PropertyBundle properties = appraisal.Properties;
uint validLocations = obj.IsHook && appraisal.HookProfile is { } hook
? hook.ValidLocations
: (uint)obj.ValidLocations;
uint ammoType = obj.IsHook && appraisal.HookProfile is { } hookProfile
? hookProfile.AmmoType
: obj.AmmoType ?? 0u;
const uint weaponAndShieldLocations = 0x03F0_0000u;
bool hasWeaponOrShieldLocation =
(validLocations & weaponAndShieldLocations) != 0u;
if (hasWeaponOrShieldLocation
&& (validLocations & (uint)EquipMask.Shield) != 0)
{
if (properties.Ints.TryGetValue(28u, out int shieldLevel))
report.Line(
$"Base Shield Level: {shieldLevel.ToString(CultureInfo.CurrentCulture)}",
EnchantmentStyle(
appraisal.ArmorEnchantments,
0x0001u));
else
report.Line("Shield Level: Unknown");
}
if (hasWeaponOrShieldLocation
&& appraisal.WeaponProfile is { } weapon)
{
string skill = SkillName((int)weapon.WeaponSkill);
int weaponType = properties.GetInt(353u);
report.Line(
$"Skill: {skill}{WeaponSubtype(weaponType)}");
bool launcher = (validLocations & (uint)EquipMask.MissileWeapon) != 0
&& ammoType != 0u;
string damageLabel = launcher ? "Damage Bonus" : "Damage";
string damage;
if (weapon.Damage == uint.MaxValue)
{
damage = "Unknown";
}
else
{
double minimumDamage =
(1d - weapon.DamageVariance) * weapon.Damage;
damage = weapon.Damage - minimumDamage > 0.0002d
? $"{FormatRetailDamage(minimumDamage)}"
+ $" - {weapon.Damage.ToString(CultureInfo.CurrentCulture)}"
: weapon.Damage.ToString(CultureInfo.CurrentCulture);
if (!launcher)
{
damage += TryDamageTypeName(weapon.DamageType, out string? type)
? $", {type}"
: ", unknown type";
}
}
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)
report.Line(
$"Elemental Damage Bonus: "
+ $"{elementalBonus.ToString(CultureInfo.CurrentCulture)}, "
+ $"{DamageTypeName(weapon.DamageType)}.");
if (launcher)
{
report.Line(
appraisal.Success
? $"Damage Modifier: {FormatModifier(weapon.DamageMod)}."
: "Damage Modifier: Unknown",
EnchantmentStyle(
appraisal.WeaponEnchantments,
0x0020u));
}
const uint timedWeaponLocations =
(uint)(EquipMask.MeleeWeapon
| EquipMask.MissileWeapon
| EquipMask.TwoHanded);
if ((validLocations & timedWeaponLocations) != 0)
{
report.Line(
weapon.WeaponTime == uint.MaxValue
? "Speed: Unknown"
: $"Speed: {WeaponTimeName((int)weapon.WeaponTime)} "
+ $"({weapon.WeaponTime.ToString(CultureInfo.CurrentCulture)})",
EnchantmentStyle(
appraisal.WeaponEnchantments,
0x0004u));
}
if (launcher)
{
if (!appraisal.Success)
{
report.Line("Range: Unknown");
}
else
{
// Appraisal_ShowWeaponAndArmorData @ 0x004B1845:
// 2*v^2/g, converted from meters to yards and capped at 85.
double rawRange = Math.Min(
85d,
2d * Math.Pow(weapon.MaxVelocity, 2d)
* (1d / 9.8d)
* 1.094d);
int range = rawRange < 10d
? (int)Math.Ceiling(rawRange)
: (int)rawRange - (int)rawRange % 5;
report.Line(
$"Range: {range.ToString(CultureInfo.CurrentCulture)} yds."
+ (weapon.MaxVelocityEstimated != 0u
? " (based on STRENGTH 100)"
: string.Empty));
}
}
if (!launcher && Math.Abs(weapon.WeaponOffense - 1d) > 0.000001d)
report.Line(
$"Bonus to Attack Skill: "
+ $"{FormatSignedPercent(weapon.WeaponOffense - 1d)}.",
EnchantmentStyle(
appraisal.WeaponEnchantments,
0x0001u));
}
ShowAmmunitionDescription(report, validLocations, ammoType);
if (!hasWeaponOrShieldLocation
&& (validLocations & 0x0800_7FFFu) != 0u
&& ClothingCoverage(obj.Priority) is { Length: > 0 } coverage)
{
report.Line($"Covers {coverage}");
}
}
/// <summary><c>Appraisal_ShowArmorModsData @ 0x004B1F60</c>.</summary>
private static void ShowArmorModifiers(
RetailReportBuilder report,
AppraiseInfoParser.Parsed appraisal)
{
if (appraisal.ArmorProfile is not { } armor
|| !appraisal.Properties.Ints.TryGetValue(28u, out int armorLevel)
|| armorLevel <= 0)
{
return;
}
// Retail's first armor string begins with a literal '\n' before
// AddItemInfo adds its ordinary separator.
report.Paragraph(
$"Armor Level: {armorLevel.ToString(CultureInfo.CurrentCulture)}",
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(
RetailReportBuilder report,
uint validLocations,
uint ammoType)
{
if (ammoType == 0u)
return;
// Retail gives the crystal and chorizite variants their own bits
// (acclient.h:4221); each folds back to the base kind for this sentence.
AmmoType baseAmmoType = (AmmoType)ammoType switch
{
AmmoType.ArrowCrystal or AmmoType.ArrowChorizite => AmmoType.Arrow,
AmmoType.BoltCrystal or AmmoType.BoltChorizite => AmmoType.Bolt,
AmmoType.AtlatlCrystal or AmmoType.AtlatlChorizite => AmmoType.Atlatl,
var other => other,
};
bool launcher = (validLocations & (uint)EquipMask.MissileWeapon) != 0;
string? description = (launcher, baseAmmoType) switch
{
(true, AmmoType.Arrow) => "Uses arrows as ammunition.",
(true, AmmoType.Bolt) => "Uses quarrels as ammunition.",
(true, AmmoType.Atlatl) => "Uses atlatl darts as ammunition.",
(false, AmmoType.Arrow) => "Used as ammunition by bows.",
(false, AmmoType.Bolt) => "Used as ammunition by crossbows.",
(false, AmmoType.Atlatl) => "Used as ammunition by atlatls.",
_ => null,
};
if (description is not null)
report.Line(description);
}
private static void ShowProtection(
RetailReportBuilder report,
string damageType,
int armorLevel,
float modifier,
ItemAppraisalFontStyle style)
{
// AppraisalSystem::DamageResistanceToString @ 0x005B5490.
string quality = modifier switch
{
<= 0.0002f => "None",
< 0.4f => "Poor",
< 0.8f => "Below Average",
< 1.2f => "Average",
< 1.6f => "Above Average",
< 2.0f => "Excellent",
_ => "Unparalleled",
};
double effective = armorLevel * modifier;
report.Line(
$"{damageType}: {quality} "
+ $"({effective.ToString("0", CultureInfo.CurrentCulture)})",
style);
}
/// <summary><c>Appraisal_ShowDefenseModData @ 0x004B1DF0</c>.</summary>
private static void ShowDefenseModifiers(
RetailReportBuilder report,
AppraiseInfoParser.Parsed appraisal)
{
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");
}
private static void ShowModifier(
RetailReportBuilder report,
PropertyBundle properties,
uint property,
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)}.",
style);
}
/// <summary><c>Appraisal_ShowShortMagicInfo @ 0x004B2C90</c>.</summary>
private static void ShowShortMagicInfo(
RetailReportBuilder report,
AppraiseInfoParser.Parsed appraisal,
Func<uint, SpellMetadata?> resolveSpell)
{
if (appraisal.SpellBook.Length == 0)
return;
if (!appraisal.Success)
{
report.Paragraph("Spells: unknown.");
return;
}
uint[] ordinary = appraisal.SpellBook
.Where(raw => (raw & 0x8000_0000u) == 0u)
.ToArray();
if (ordinary.Length == 0)
return;
string names = string.Join(
", ",
ordinary.Select(raw =>
{
return resolveSpell(raw)?.Name
?? $"Spell {raw.ToString(CultureInfo.CurrentCulture)}";
}));
report.Paragraph($"Spells: {names}");
}
/// <summary><c>Appraisal_ShowSpecialProperties @ 0x004B0140</c>.</summary>
private static void ShowSpecialProperties(
RetailReportBuilder report,
PropertyBundle properties,
RetailAppraisalNameResolver names)
{
// Appraisal_ShowSpecialProperties @ 0x004B0159.
report.BlankLine();
int carryLimit = properties.GetInt(279u);
if (carryLimit > 0)
{
report.BlankLine();
report.Line(
$"You can only carry "
+ $"{carryLimit.ToString("N0", CultureInfo.CurrentCulture)} "
+ "of these items.");
}
if (properties.Floats.TryGetValue(167u, out double cooldown)
&& cooldown > 0d)
{
report.Line($"Cooldown When Used: {FormatDeltaTime(cooldown)}");
if (properties.Ints.ContainsKey(280u))
report.BlankLine();
}
int cleave = properties.GetInt(292u);
if (cleave > 1)
{
report.Line(
$"Cleave: {cleave.ToString(CultureInfo.CurrentCulture)} enemies in front arc.");
report.BlankLine();
}
var special = new List<string>();
int slayer = properties.GetInt(166u);
if (slayer != 0)
{
string slayerName = slayer == 31
? "Bael'Zharon's Hate"
: names.ResolveCreature(slayer);
if (!string.IsNullOrWhiteSpace(slayerName))
{
special.Add(slayer == 31
? slayerName
: $"{slayerName} slayer");
}
}
if ((properties.GetInt(47u) & 0x79E0) != 0)
special.Add("Multi-Strike");
uint imbuedEffects = unchecked((uint)(
properties.GetInt(179u)
| properties.GetInt(303u)
| properties.GetInt(304u)
| properties.GetInt(305u)
| properties.GetInt(306u)));
AppendImbuedEffects(special, imbuedEffects);
if (properties.Floats.ContainsKey(159u))
special.Add("Magic Absorbing");
if (properties.GetInt(36u) >= 9_999)
special.Add("Unenchantable");
int attuned = properties.GetInt(114u);
if (attuned is 1 or 2)
special.Add("Attuned");
int bonded = properties.GetInt(33u);
switch (bonded)
{
case -2:
special.Add("Destroyed on Death");
break;
case -1:
special.Add("Dropped on Death");
break;
case 1:
special.Add("Bonded");
break;
}
if (properties.GetBool(91u))
special.Add("Retained");
if (properties.Floats.ContainsKey(136u))
special.Add("Crushing Blow");
if (properties.Floats.ContainsKey(147u))
special.Add("Biting Strike");
if (properties.Floats.ContainsKey(155u))
special.Add("Armor Cleaving");
if (properties.Floats.ContainsKey(157u)
&& properties.Ints.TryGetValue(263u, out int resistanceType))
special.Add(
$"Resistance Cleaving: {DamageTypeName((uint)resistanceType)}");
if (properties.DataIds.ContainsKey(55u))
special.Add("Cast on Strike");
if (properties.GetBool(99u))
special.Add("Ivoryable");
if (properties.GetBool(100u))
special.Add("Dyeable");
if (special.Count != 0)
report.Line($"Properties: {string.Join(", ", special)}");
if (imbuedEffects != 0u)
report.Line("This item cannot be further imbued.");
if (properties.GetBool(130u))
report.Line("This item is tethered to the left side.");
}
private static void AppendImbuedEffects(
List<string> properties,
uint imbuedEffects)
{
(uint Flag, string Text)[] names =
[
(0x0000_0001u, "Critical Strike"),
(0x0000_0002u, "Crippling Blow"),
(0x0000_0004u, "Armor Rending"),
(0x0000_0008u, "Slash Rending"),
(0x0000_0010u, "Pierce Rending"),
(0x0000_0020u, "Bludgeon Rending"),
(0x0000_0040u, "Acid Rending"),
(0x0000_0080u, "Cold Rending"),
(0x0000_0100u, "Lightning Rending"),
(0x0000_0200u, "Fire Rending"),
(0x0000_0400u, "+1 Melee Defense"),
(0x0000_0800u, "+1 Missile Defense"),
(0x0000_1000u, "+1 Magic Defense"),
(0x0000_4000u, "Nether Rending"),
(0x8000_0000u, "Phantasmal"),
];
foreach ((uint flag, string text) in names)
if ((imbuedEffects & flag) != 0)
properties.Add(text);
}
private static void ShowUsage(
RetailReportBuilder report,
PropertyBundle properties)
{
string use = properties.GetString(14u);
if (!string.IsNullOrWhiteSpace(use))
report.Paragraph(use);
}
private static void ShowLevelLimits(
RetailReportBuilder report,
PropertyBundle properties)
{
int minimum = properties.GetInt(86u);
int maximum = properties.GetInt(87u);
if (minimum > 0 && maximum > 0)
{
report.Paragraph(minimum == maximum
? $"Restricted to characters of Level "
+ $"{minimum.ToString(CultureInfo.CurrentCulture)}."
: $"Restricted to characters of Levels "
+ $"{minimum.ToString(CultureInfo.CurrentCulture)} to "
+ $"{maximum.ToString(CultureInfo.CurrentCulture)}.");
}
else if (minimum > 0)
report.Paragraph(
$"Restricted to characters of Level "
+ $"{minimum.ToString(CultureInfo.CurrentCulture)} or greater.");
else if (maximum > 0)
report.Paragraph(
$"Restricted to characters of Level "
+ $"{maximum.ToString(CultureInfo.CurrentCulture)} or below.");
string destination = properties.GetString(38u);
if (!string.IsNullOrWhiteSpace(destination))
report.Paragraph($"Destination: {destination}");
}
/// <summary><c>Appraisal_ShowWieldRequirements @ 0x004AF9A0</c>.</summary>
private static void ShowWieldRequirements(
RetailReportBuilder report,
PropertyBundle properties,
RetailAppraisalNameResolver names)
{
if (properties.GetBool(85u))
{
string owner = properties.GetString(25u);
report.Line(
$"Wield requires "
+ $"{(string.IsNullOrWhiteSpace(owner) ? "the original owner" : owner)}");
}
if (properties.GetInt(26u) == 1)
report.Line("Use requires Throne of Destiny.");
// ItemExamineUI::Appraisal_ShowWieldRequirements @ 0x004AF9A0
// queries the direct HeritageGroup property before the four generic
// requirement triplets.
if (properties.Ints.TryGetValue(324u, out int heritage))
{
string heritageName = names.ResolveHeritage(heritage);
if (!string.IsNullOrEmpty(heritageName))
report.Line($"Wield requires {heritageName}");
}
for (int index = 0; index < WieldRequirements.Length; index++)
{
(uint requirementId, uint statId, uint difficultyId) =
WieldRequirements[index];
if (!properties.Ints.TryGetValue(requirementId, out int requirement)
|| !properties.Ints.TryGetValue(statId, out int stat)
|| !properties.Ints.TryGetValue(difficultyId, out int difficulty))
continue;
string quality = RequirementQuality(
requirement,
stat,
difficulty,
names);
if (requirement == 8)
{
string training = difficulty == 3
? index == 3 ? "Specialized" : "specialized"
: index == 3 ? "Trained" : "trained";
report.Line($"Wield requires {training} {quality}");
}
else if (requirement == 11)
{
report.Line($"Wield requires {quality} type");
}
else if (requirement == 12)
{
report.Line($"Wield requires {quality} race");
}
else if (!string.IsNullOrEmpty(quality))
{
report.Line(
$"Wield requires {quality} "
+ $"{difficulty.ToString(CultureInfo.CurrentCulture)}");
}
}
}
private static string RequirementQuality(
int requirement,
int stat,
int difficulty,
RetailAppraisalNameResolver names)
{
string basePrefix = requirement is 2 or 4 or 6 ? "base " : string.Empty;
return requirement switch
{
1 or 2 or 8 => basePrefix + SkillName(stat),
3 or 4 => basePrefix + PrimaryAttributeName(stat),
5 or 6 => basePrefix + SecondaryAttributeName(stat),
7 => "level",
9 or 10 => stat switch
{
0x11F => "Standing with the Celestial Hand",
0x120 => "Standing with the Eldrytch Web",
0x121 => "Standing with the Radiant Blood",
_ => "unknown quality",
},
11 => names.ResolveCreature(difficulty),
12 => names.ResolveHeritage(difficulty),
_ => string.Empty,
};
}
/// <summary><c>Appraisal_ShowUsageLimits @ 0x004B2A70</c>.</summary>
private static void ShowUsageLimits(
RetailReportBuilder report,
PropertyBundle properties)
{
int level = properties.GetInt(369u);
int skill = properties.GetInt(366u);
int difficulty = properties.GetInt(367u);
int specializedSkill = properties.GetInt(368u);
if (level > 0
|| (skill > 0 && difficulty > 0)
|| specializedSkill > 0)
{
report.BlankLine();
}
if (level > 0)
report.Line(
$"Use requires level {level.ToString(CultureInfo.CurrentCulture)}.");
if (skill > 0 && difficulty > 0)
report.Line(
$"Use requires {UsageSkillName(skill)} of at least "
+ $"{difficulty.ToString(CultureInfo.CurrentCulture)}.");
if (specializedSkill > 0)
report.Line(
$"Use requires specialized {UsageSkillName(specializedSkill)}.");
}
/// <summary><c>Appraisal_ShowItemLevel @ 0x004B3C60</c>.</summary>
private static void ShowItemLevel(
RetailReportBuilder report,
PropertyBundle properties)
{
if (!properties.Int64s.TryGetValue(5u, out long baseExperience)
|| baseExperience <= 0
|| !properties.Ints.TryGetValue(319u, out int maximum)
|| maximum <= 0
|| !properties.Ints.TryGetValue(320u, out int style)
|| style <= 0)
{
return;
}
long experience = Math.Max(0, properties.GetInt64(4u));
int currentLevel = ItemTotalXpToLevel(
experience,
baseExperience,
maximum,
style);
int displayedLevel = Math.Min(currentLevel + 1, maximum);
long nextExperience = ItemLevelToTotalXp(
Math.Min(currentLevel + 1, maximum),
baseExperience,
maximum,
style);
report.Line(
$"Item Level: {displayedLevel.ToString(CultureInfo.CurrentCulture)} / "
+ $"{maximum.ToString(CultureInfo.CurrentCulture)}");
report.Line(
$"Item XP: {experience.ToString("N0", CultureInfo.CurrentCulture)} / "
+ $"{nextExperience.ToString("N0", CultureInfo.CurrentCulture)}");
report.BlankLine();
if (properties.GetInt(352u) == 2)
{
report.Line(
"This cloak has a chance to reduce an incoming attack by 200 damage.");
report.BlankLine();
}
}
/// <summary>
/// <c>Appraisal_ShowActivationRequirements @ 0x004B3270</c>.
/// </summary>
private static void ShowActivationRequirements(
RetailReportBuilder report,
PropertyBundle properties,
bool appraisalSucceeded,
RetailAppraisalNameResolver names)
{
if (!appraisalSucceeded)
return;
var requirements = new List<string>();
AddRequirement(requirements, "Arcane Lore", properties.GetInt(109u));
AddRequirement(requirements, "Allegiance Rank", properties.GetInt(110u));
int heritage = properties.GetInt(188u);
if (heritage != 0
&& names.ResolveHeritage(heritage) is { Length: > 0 } heritageName)
{
requirements.Add(heritageName);
}
int skillLevel = properties.GetInt(115u);
int skill = properties.GetInt(176u);
if (skillLevel > 0 && skill > 0)
requirements.Add(
$"{UsageSkillName(skill)}: {skillLevel.ToString(CultureInfo.CurrentCulture)}");
int attributeLevel = properties.GetInt(258u);
int attribute = properties.GetInt(257u);
if (attributeLevel > 0 && attribute > 0)
requirements.Add(
$"{PrimaryAttributeName(attribute)}: "
+ $"{attributeLevel.ToString(CultureInfo.CurrentCulture)}");
int secondaryLevel = properties.GetInt(260u);
int secondary = properties.GetInt(259u);
if (secondaryLevel > 0 && secondary > 0)
requirements.Add(
$"{SecondaryAttributeName(secondary)}: "
+ $"{secondaryLevel.ToString(CultureInfo.CurrentCulture)}");
if (requirements.Count != 0)
report.Line($"Activation requires {string.Join(", ", requirements)}");
if (properties.GetBool(94u))
{
string owner = properties.GetString(25u);
report.Line(
$"This item can only be activated by "
+ $"{(string.IsNullOrWhiteSpace(owner) ? "the original owner" : owner)}.");
}
}
private static void AddRequirement(
List<string> requirements,
string name,
int value)
{
if (value > 0)
requirements.Add(
$"{name}: {value.ToString(CultureInfo.CurrentCulture)}");
}
/// <summary><c>Appraisal_ShowCasterData @ 0x004B1B10</c>.</summary>
private static void ShowCasterData(
RetailReportBuilder report,
AppraiseInfoParser.Parsed appraisal)
{
PropertyBundle properties = appraisal.Properties;
if (properties.Floats.TryGetValue(144u, out double manaConversion))
report.Paragraph(
$"Bonus to Mana Conversion: "
+ $"{FormatSignedPercent(manaConversion)}.",
EnchantmentStyle(
appraisal.ResistEnchantments,
0x1000u));
if (properties.Floats.TryGetValue(152u, out double elemental)
&& properties.Ints.TryGetValue(45u, out int damageType))
{
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(
$" vs. Players: {FormatSignedPercent(playerModifier - 1d)}.");
}
}
/// <summary>
/// <c>Appraisal_ShowBoostValue @ 0x004B2480</c> and
/// <c>Appraisal_ShowHealKitData @ 0x004B25A0</c>.
/// </summary>
private static void ShowBoostAndHealing(
RetailReportBuilder report,
ClientObject obj,
AppraiseInfoParser.Parsed appraisal)
{
PropertyBundle properties = appraisal.Properties;
bool healer =
((PublicWeenieFlags)obj.PublicWeenieBitfield.GetValueOrDefault()
& PublicWeenieFlags.Healer) != 0
|| obj.IsHook
&& appraisal.HookProfile is { } hook
&& (hook.Flags & 0x2u) != 0u;
int boost = properties.GetInt(90u);
string? boostText = properties.GetInt(89u) switch
{
2 => $"{(boost >= 0 ? "Restores" : "Depletes")} "
+ $"{Math.Abs(boost).ToString(CultureInfo.CurrentCulture)} "
+ "Health when used.",
4 => $"{(boost >= 0 ? "Restores" : "Depletes")} "
+ $"{Math.Abs(boost).ToString(CultureInfo.CurrentCulture)} "
+ "Stamina when consumed.",
6 => $"{(boost >= 0 ? "Restores" : "Depletes")} "
+ $"{Math.Abs(boost).ToString(CultureInfo.CurrentCulture)} "
+ "Mana when used.",
_ => null,
};
if (!healer && boostText is not null
&& properties.Ints.ContainsKey(90u))
{
report.Paragraph(boostText);
}
if (!healer)
return;
if (properties.Ints.TryGetValue(90u, out int healingBonus))
report.Paragraph(
$"Bonus to Healing Skill: {healingBonus.ToString(CultureInfo.CurrentCulture)}");
if (properties.Floats.TryGetValue(100u, out double healKitModifier))
report.Line(
$"Restoration Bonus: "
+ $"{(healKitModifier * 100d).ToString("0", CultureInfo.CurrentCulture)}%");
}
/// <summary>
/// <c>Appraisal_ShowCapacity @ 0x004B2680</c> and
/// <c>Appraisal_ShowLockData @ 0x004B2790</c>.
/// </summary>
private static void ShowCapacityAndLock(
RetailReportBuilder report,
ClientObject obj,
AppraiseInfoParser.Parsed appraisal)
{
PropertyBundle properties = appraisal.Properties;
// Appraisal_ShowCapacity @ 0x004B2680 suppresses a hook object's own
// capacity while a HookProfile describes the item mounted on it.
// Non-container sentinels are independently preserved as -1 by
// PublicWeenieDesc::UnPack's signed-byte loads, so they fail the
// positive-capacity tests below exactly as they do in retail.
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(175u);
int pagesUsed = properties.GetInt(174u);
if (pages > 0)
report.Paragraph(
$"{pagesUsed.ToString(CultureInfo.CurrentCulture)} of "
+ $"{pages.ToString(CultureInfo.CurrentCulture)} pages full.");
}
// 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>
private static void ShowManaStone(
RetailReportBuilder report,
AppraiseInfoParser.Parsed appraisal)
{
if (appraisal.SpellBook.Length != 0)
return;
PropertyBundle properties = appraisal.Properties;
if (properties.Ints.TryGetValue(107u, out int storedMana))
report.Line(
$"Stored Mana: {storedMana.ToString(CultureInfo.CurrentCulture)}");
if (properties.Floats.TryGetValue(87u, out double efficiency))
report.Line(
$"Efficiency: {(efficiency * 100d).ToString("0", CultureInfo.CurrentCulture)}%");
if (properties.Floats.TryGetValue(137u, out double destruction))
report.Line(
$"Chance of Destruction: "
+ $"{(destruction * 100d).ToString("0", CultureInfo.CurrentCulture)}%");
}
/// <summary><c>Appraisal_ShowRemainingUses @ 0x004B3A20</c>.</summary>
private static void ShowRemainingUses(
RetailReportBuilder report,
ClientObject obj,
AppraiseInfoParser.Parsed appraisal)
{
PropertyBundle properties = appraisal.Properties;
if (properties.Ints.TryGetValue(193u, out int keys))
report.Line(
$"Contains {keys.ToString(CultureInfo.CurrentCulture)} "
+ (keys == 1 ? "key." : "keys."));
if (properties.GetBool(63u))
{
report.Line("Number of uses remaining: Unlimited");
return;
}
if (properties.Ints.TryGetValue(92u, out int uses))
{
report.Line(
$"Number of uses remaining: {uses.ToString(CultureInfo.CurrentCulture)}");
return;
}
var publicFlags =
(PublicWeenieFlags)obj.PublicWeenieBitfield.GetValueOrDefault();
if (!appraisal.Success
&& ((publicFlags
& (PublicWeenieFlags.Healer | PublicWeenieFlags.Lockpick)) != 0
|| appraisal.HookProfile is { Flags: var flags }
&& (flags & 0xAu) != 0))
{
report.Paragraph("Number of uses remaining: Unknown");
}
}
private static void ShowCraftsman(
RetailReportBuilder report,
PropertyBundle properties)
{
string craftsman = properties.GetString(25u);
if (!string.IsNullOrWhiteSpace(craftsman)
&& !properties.GetBool(85u)
&& !properties.GetBool(94u))
report.Line($"Created by {craftsman}.");
}
private static void ShowSaleAndRareInfo(
RetailReportBuilder report,
PropertyBundle properties)
{
if (properties.Bools.TryGetValue(69u, out bool sellable) && !sellable)
report.Line("This item cannot be sold.");
if (properties.GetBool(108u))
{
report.Paragraph(
"This rare item has a timer restriction of 3 minutes. "
+ "You will not be able to use another rare item with a timer "
+ "within 3 minutes of using this one.");
}
int rare = properties.GetInt(17u);
if (rare > 0)
report.Paragraph($"Rare #{rare.ToString(CultureInfo.CurrentCulture)}");
}
/// <summary><c>Appraisal_ShowMagicInfo @ 0x004B2E10</c>.</summary>
private static void ShowMagicInfo(
RetailReportBuilder report,
AppraiseInfoParser.Parsed appraisal,
Func<uint, SpellMetadata?> resolveSpell)
{
if (appraisal.SpellBook.Length == 0)
return;
if (!appraisal.Success)
{
report.Paragraph("Spells: unknown.");
return;
}
var ordinary = new List<(uint Id, SpellMetadata? Metadata)>();
var enchantments = new List<(uint Id, SpellMetadata? Metadata)>();
foreach (uint rawSpellId in appraisal.SpellBook)
{
uint spellId = rawSpellId & 0x7FFF_FFFFu;
var spell = (spellId, resolveSpell(spellId));
if ((rawSpellId & 0x8000_0000u) == 0)
ordinary.Add(spell);
else
enchantments.Add(spell);
}
PropertyBundle properties = appraisal.Properties;
if (ordinary.Count != 0)
{
if (properties.Ints.TryGetValue(106u, out int spellcraft))
report.Line(
$"Spellcraft: {spellcraft.ToString(CultureInfo.CurrentCulture)}.");
if (properties.Ints.TryGetValue(107u, out int currentMana)
&& properties.Ints.TryGetValue(108u, out int maximumMana))
report.Line(
$"Mana: {currentMana.ToString(CultureInfo.CurrentCulture)} / "
+ $"{maximumMana.ToString(CultureInfo.CurrentCulture)}.");
if (properties.Floats.TryGetValue(5u, out double manaRate)
&& Math.Abs(manaRate) > 0.000001d)
{
int seconds = (int)Math.Round(1d / manaRate);
report.Line(
$"Mana Cost: 1 point per {seconds.ToString(CultureInfo.CurrentCulture)} "
+ (seconds == 1 ? "second." : "seconds."));
}
else if (properties.Ints.TryGetValue(117u, out int manaCost))
{
string manaCostText =
$"Mana Cost: {manaCost.ToString(CultureInfo.CurrentCulture)}.";
if (manaCost > 0)
{
manaCostText +=
"\n(Can be reduced by the Mana Conversion skill).";
}
report.Paragraph(manaCostText);
}
report.Paragraph(BuildSpellDescriptionBlock(
"Spell Descriptions:",
ordinary));
}
if (enchantments.Count != 0)
{
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).Append(": ");
if (!string.IsNullOrWhiteSpace(metadata?.Description))
text.Append(metadata.Description);
}
return text.ToString();
}
private static void ShowDescription(
RetailReportBuilder report,
PropertyBundle properties,
RetailAppraisalNameResolver names)
{
if (properties.Ints.ContainsKey(267u)
&& properties.Ints.ContainsKey(98u)
&& properties.Ints.TryGetValue(268u, out int remainingLifetime))
{
report.Line(remainingLifetime < 0
? "This item is in the act of disintegrating."
: $"This item expires in {FormatExpiry(remainingLifetime)}");
}
string longDescription = properties.GetString(16u);
if (string.IsNullOrWhiteSpace(longDescription))
{
string shortDescription = properties.GetString(15u);
if (!string.IsNullOrWhiteSpace(shortDescription))
report.Paragraph(shortDescription);
}
else
{
string description = properties.GetString(52u);
if (string.IsNullOrWhiteSpace(description))
description = longDescription;
string prefix = string.Empty;
string suffix = string.Empty;
if (properties.Ints.TryGetValue(172u, out int decorations))
{
if ((decorations & 1) != 0
&& properties.Ints.TryGetValue(105u, out int workmanship))
{
prefix += WorkmanshipAdjective(
Math.Clamp(workmanship, 0, 10)) + " ";
}
int materialType = properties.GetInt(131u);
string material = names.ResolveMaterial(materialType);
if (!string.IsNullOrWhiteSpace(material))
{
prefix += material + " ";
description = RemoveFirst(description, material).Trim();
}
if ((decorations & 4) != 0
&& properties.Ints.TryGetValue(177u, out int gemCount)
&& properties.Ints.TryGetValue(178u, out int gemMaterial))
{
string gemName = gemCount == 1
? names.ResolveMaterial(gemMaterial)
: PluralizedGemName(
gemMaterial,
names.ResolveMaterial(gemMaterial));
if (!string.IsNullOrWhiteSpace(gemName))
{
suffix =
$", set with {gemCount.ToString(CultureInfo.CurrentCulture)} "
+ gemName;
}
}
}
report.Paragraph(prefix + description + suffix);
}
int portalRestrictions = properties.GetInt(111u);
if (portalRestrictions != 0)
{
var restrictions = new List<string>();
if ((portalRestrictions & 2) != 0)
restrictions.Add("Player Killers may not use this portal.");
if ((portalRestrictions & 4) != 0)
restrictions.Add("Lite Player Killers may not use this portal.");
if ((portalRestrictions & 8) != 0)
restrictions.Add("Non-Player Killers may not use this portal.");
if ((portalRestrictions & 0x20) != 0)
restrictions.Add("This portal cannot be recalled nor linked to.");
if ((portalRestrictions & 0x10) != 0)
restrictions.Add("This portal cannot be summoned.");
if (restrictions.Count != 0)
report.Paragraph(string.Join('\n', restrictions));
}
}
private static string FormatRetailDamage(double damage)
=> damage.ToString(
damage > 10d ? "G4" : "G3",
CultureInfo.CurrentCulture);
private static bool TryDamageTypeName(uint type, out string? name)
{
name = type switch
{
1u => "Slashing",
2u => "Piercing",
4u => "Bludgeoning",
8u => "Cold",
16u => "Fire",
32u => "Acid",
64u => "Electric",
128u => "Health",
256u => "Stamina",
512u => "Mana",
1024u => "Nether",
_ => null,
};
return name is not null;
}
/// <summary><c>ClothingPriorityToString @ 0x005B4F30</c>.</summary>
private static string ClothingCoverage(uint priority)
{
var coverage = new List<string>();
if ((priority & 0x4000u) != 0u)
coverage.Add("Head");
if ((priority & (0x0008u | 0x0400u)) != 0u)
coverage.Add("Chest");
if ((priority & (0x0010u | 0x0800u)) != 0u)
coverage.Add("Abdomen");
if ((priority & (0x0020u | 0x1000u)) != 0u)
coverage.Add("Upper Arms");
if ((priority & (0x0040u | 0x2000u)) != 0u)
coverage.Add("Lower Arms");
if ((priority & 0x0080u) != 0u)
coverage.Add("Hands");
if ((priority & (0x0002u | 0x0100u)) != 0u)
coverage.Add("Upper Legs");
if ((priority & (0x0004u | 0x0200u)) != 0u)
coverage.Add("Lower Legs");
if ((priority & 0x10000u) != 0u)
coverage.Add("Feet");
return string.Join(", ", coverage);
}
/// <summary><c>ExperienceSystem::ItemLevelToTotalXP @ 0x005C8100</c>.</summary>
private static long ItemLevelToTotalXp(
int level,
long baseExperience,
int maximumLevel,
int style)
{
int cappedLevel = Math.Clamp(level, 0, maximumLevel);
return style switch
{
1 => baseExperience * cappedLevel,
2 => SumGeometricExperience(baseExperience, cappedLevel),
3 => baseExperience * cappedLevel * (cappedLevel + 1L) / 2L,
_ => 0L,
};
}
/// <summary><c>ExperienceSystem::ItemTotalXPToLevel @ 0x005C81B0</c>.</summary>
private static int ItemTotalXpToLevel(
long totalExperience,
long baseExperience,
int maximumLevel,
int style)
{
if (totalExperience <= 0 || baseExperience <= 0 || maximumLevel <= 0)
return 0;
if (style == 1)
{
return (int)Math.Min(
maximumLevel,
totalExperience / baseExperience);
}
long remaining = totalExperience;
long nextCost = baseExperience;
int level = 0;
while (level < maximumLevel && remaining >= nextCost)
{
remaining -= nextCost;
level++;
if (style == 2)
nextCost *= 2L;
else if (style == 3)
nextCost = baseExperience * (level + 1L);
else
return 0;
}
return level;
}
private static long SumGeometricExperience(
long baseExperience,
int levels)
{
long total = 0;
long cost = baseExperience;
for (int i = 0; i < levels; i++)
{
total += cost;
cost *= 2L;
}
return total;
}
private static string FormatExpiry(int remainingSeconds)
{
int remaining = Math.Max(0, remainingSeconds);
var text = new StringBuilder();
if (remaining > 31_536_000)
{
text.Append(remaining / 31_536_000).Append(" years, ");
remaining %= 31_536_000;
}
if (remaining > 86_400)
{
text.Append(remaining / 86_400).Append(" days, ");
remaining %= 86_400;
}
if (remaining > 3_600)
{
text.Append(remaining / 3_600).Append(" hours, ");
remaining %= 3_600;
}
if (remaining > 60)
{
text.Append(remaining / 60).Append(" minutes, ");
remaining %= 60;
}
text.Append(remaining).Append(" seconds.");
return text.ToString();
}
/// <summary><c>AppraisalSystem::InqPluralizedGemName @ 0x005B5800</c>.</summary>
private static string PluralizedGemName(int materialType, string material)
{
if (materialType == 0x26)
return "Rubies";
if (materialType is 0x0B or 0x18 or 0x1B or 0x1D or 0x20
or 0x25 or 0x28 or 0x2E or 0x24 or 0x2D)
{
return $"pieces of {material}";
}
if (materialType is 0x1A or 0x31)
return material + "es";
return materialType == 0x1C ? material : material + "s";
}
private static string RemoveFirst(string value, string remove)
{
int index = value.IndexOf(remove, StringComparison.Ordinal);
return index < 0
? value
: value.Remove(index, remove.Length);
}
/// <summary><c>ItemExamineUI::Appraisal_ShowSet @ 0x004AE880</c>.</summary>
private static string? EquipmentSetName(int setId)
{
if (setId is >= 94 and <= 129)
{
string tier = ((setId - 94) / 12) switch
{
0 => "Minor",
1 => "Major",
_ => "Blackfire",
};
int member = (setId - 94) % 12;
string effect = (member % 4) switch
{
0 => "Stinging",
1 => "Sparking",
2 => "Smoldering",
_ => "Shivering",
};
string soul = (member / 4) switch
{
0 => "Shrouded Soul",
1 => "Darkened Mind",
_ => "Clouded Spirit",
};
return $"{tier} {effect} {soul}";
}
return setId switch
{
4 => "Carraida's Benediction",
5 => "Noble Relic",
6 => "Ancient Relic",
7 => "Alduressa Relic",
8 => "Shou-jen",
9 => "Empyrean Rings",
10 => "Arm, Mind, Heart",
11 => "Coat of Perfect Light",
12 => "Leggings of Perfect Light",
13 => "Soldier's",
14 => "Adept's",
15 => "Archer's",
16 => "Defender's",
17 => "Tinker's",
18 => "Crafter's",
19 => "Hearty",
20 => "Dexterous",
21 => "Wise",
22 => "Swift",
23 => "Hardened",
24 => "Reinforced",
25 => "Interlocking",
26 => "Flame Proof",
27 => "Acid Proof",
28 => "Cold Proof",
29 => "Lightning Proof",
30 => "Dedication",
31 => "Gladiatorial Clothing",
32 => "Ceremonial Clothing",
33 => "Protective Clothing",
35 => "Sigil of Defense",
36 => "Sigil of Destruction",
37 => "Sigil of Fury",
38 => "Sigil of Growth",
39 => "Sigil of Vigor",
40 => "Heroic Protector",
41 => "Heroic Destroyer",
49 => "Weave of Alchemy",
50 => "Weave of Arcane Lore",
51 => "Weave of Armor Tinkering",
52 => "Weave of Assess Person",
53 or 67 or 74 or 75 or 79 => "Weave of Light Weapons",
54 or 57 or 77 => "Weave of Missile Weapons",
55 => "Weave of Cooking",
56 => "Weave of Creature Enchantment",
58 => "Weave of Finesse Weapons",
59 => "Weave of Deception",
60 => "Weave of Fletching",
61 => "Weave of Healing",
62 => "Weave of Item Enchantment",
63 => "Weave of Item Tinkering",
64 => "Weave of Leadership",
65 => "Weave of Life Magic",
66 => "Weave of Loyalty",
68 => "Weave of Magic Defense",
69 => "Weave of Magic Item Tinkering",
70 => "Weave of Mana Conversion",
71 => "Weave of Melee Defense",
72 => "Weave of Missile Defense",
73 => "Weave of Salvaging",
76 => "Weave of Heavy Weapons",
78 => "Weave of Two Handed Combat",
80 => "Weave of Void Magic",
81 => "Weave of War Magic",
82 => "Weave of Weapon Tinkering",
83 => "Weave of Assess Creature",
84 => "Weave of Dirty Fighting",
85 => "Weave of Dual Wield",
86 => "Weave of Recklessness",
87 => "Weave of Shield",
88 => "Weave of Sneak Attack",
89 => "Shou-jen Shozoku",
90 => "Weave of Summoning",
91 => "Shrouded Soul",
92 => "Darkened Mind",
93 => "Clouded Spirit",
130 => "Shimmering Shadows",
_ => null,
};
}
private static string WeaponTimeName(int weaponTime) => weaponTime switch
{
< 11 => "Very Fast",
< 31 => "Fast",
< 50 => "Average",
< 80 => "Slow",
_ => "Very Slow",
};
/// <summary><c>AppraisalSystem::InqWorkmanshipAdjective @ 0x005B48E0</c>.</summary>
private static string WorkmanshipAdjective(int workmanship) => workmanship switch
{
<= 0 => string.Empty,
1 => "Poorly crafted",
2 => "Well-crafted",
3 => "Finely crafted",
4 => "Exquisitely crafted",
5 => "Magnificent",
6 => "Nearly flawless",
7 => "Flawless",
8 => "Utterly flawless",
9 => "Incomparable",
_ => "Priceless",
};
/// <summary><c>ClientUISystem::DeltaTimeToString @ 0x00565E10</c>.</summary>
private static string FormatDeltaTime(double seconds)
{
int remaining = Math.Max(0, (int)Math.Truncate(seconds));
int months = remaining / 2_592_000;
remaining %= 2_592_000;
int days = remaining / 86_400;
remaining %= 86_400;
int hours = remaining / 3_600;
remaining %= 3_600;
int minutes = remaining / 60;
int finalSeconds = remaining % 60;
var result = new StringBuilder();
if (months != 0)
result.Append(months).Append("mo ");
if (days != 0)
result.Append(days).Append("d ");
if (hours != 0)
result.Append(hours).Append("h ");
if (minutes != 0)
result.Append(minutes).Append("m ");
result.Append(finalSeconds).Append("s ");
return result.ToString();
}
private static string FormatModifier(double modifier)
=> FormatSignedPercent(modifier - 1d);
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)
=> TryDamageTypeName(type, out string? name)
? name!
: $"type {type.ToString(CultureInfo.CurrentCulture)}";
private static string WeaponSubtype(int type) => type switch
{
1 => " (Unarmed Weapon)",
2 => " (Sword)",
3 => " (Axe)",
4 => " (Mace)",
5 => " (Spear)",
6 => " (Dagger)",
7 => " (Staff)",
8 => " (Bow)",
9 => " (Crossbow)",
10 => " (Thrown)",
_ => string.Empty,
};
/// <summary><c>AppraisalSystem::SkillToString @ 0x005B4A30</c> — retail
/// skill-id -&gt; display-name table. Made <c>internal</c> (Campaign CC
/// slice CC4) so the chargen Skills page can reuse the same names
/// instead of duplicating this table.</summary>
internal static string SkillName(int skill) => skill switch
{
1 => "Axe",
2 => "Bow",
3 => "Crossbow",
4 => "Dagger",
5 => "Mace",
6 => "Melee Defense",
7 => "Missile Defense",
8 => "Sling",
9 => "Spear",
10 => "Staff",
11 => "Sword",
12 => "Thrown Weapon",
13 => "Unarmed Combat",
14 => "Arcane Lore",
15 => "Magic Defense",
16 => "Mana Conversion",
17 => "Spellcraft",
18 => "Item Tinkering",
19 => "Person Appraisal",
20 => "Deception",
21 => "Healing",
22 => "Jump",
23 => "Lockpick",
24 => "Run",
25 => "Awareness",
26 => "Armor Repair",
27 => "Creature Appraisal",
28 => "Weapon Tinkering",
29 => "Armor Tinkering",
30 => "Magic Item Tinkering",
31 => "Creature Enchantment",
32 => "Item Enchantment",
33 => "Life Magic",
34 => "War Magic",
35 => "Leadership",
36 => "Loyalty",
37 => "Fletching",
38 => "Alchemy",
39 => "Cooking",
40 => "Salvaging",
41 => "Two Handed Combat",
42 => "Gearcraft",
43 => "Void Magic",
44 => "Heavy Weapons",
45 => "Light Weapons",
46 => "Finesse Weapons",
47 => "Missile Weapons",
49 => "Dual Wield",
50 => "Recklessness",
51 => "Sneak Attack",
52 => "Dirty Fighting",
53 => "Challenge",
54 => "Summoning",
_ => $"Skill {skill.ToString(CultureInfo.CurrentCulture)}",
};
private static string UsageSkillName(int skill)
{
string name = SkillName(skill);
return name.StartsWith("Skill ", StringComparison.Ordinal)
? "Unknown Skill"
: name;
}
private static string PrimaryAttributeName(int attribute) => attribute switch
{
1 => "Strength",
2 => "Endurance",
3 => "Quickness",
4 => "Coordination",
5 => "Focus",
6 => "Self",
_ => $"Attribute {attribute.ToString(CultureInfo.CurrentCulture)}",
};
private static string SecondaryAttributeName(int attribute) => attribute switch
{
1 => "Max Health",
2 => "Health",
3 => "Max Stamina",
4 => "Stamina",
5 => "Max Mana",
6 => "Mana",
_ => $"Vital {attribute.ToString(CultureInfo.CurrentCulture)}",
};
private sealed class RetailReportBuilder
{
private readonly ItemAppraisalReportBuilder _report = new();
public void Line(
string value,
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
=> _report.Line(value, style);
public void Paragraph(
string value,
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
=> _report.Paragraph(value, style);
public void BlankLine(
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
=> _report.BlankLine(style);
public ItemAppraisalReport Build() => _report.Build();
}
}