feat(ui): complete retail item appraisal reports

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

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

View file

@ -101,11 +101,18 @@ animated clone, bounding-box camera, and private-viewport light.
Its correction ports the second authored damage/critical/resistance rating
list, composites row chrome behind the animated preview and row text in front,
adds balanced text inset, and automatically reassesses each new selection
while the floaty remains visible.
while the floaty remains visible. The next correction restores the authored
310 x 400 profile size, insets only foreground row text, lays generated item
text out from the top, and replaces the sparse property dump with retail's
ordered item-report branches for common weapons, armor, magic and DAT spell
descriptions, requirements, capacities, cooldown/special properties, uses,
crafting, ratings, rare state, and prose. AP-110 records the remaining
specialized/player-dependent/DAT-name/font/object-preview branches explicitly.
**Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`;
`src/AcDream.App/UI/RetailUiRuntime.cs`;
`src/AcDream.App/UI/Layout/AppraisalUiController.cs`;
`src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`;
`src/AcDream.App/UI/UiField.cs`;
`src/AcDream.Core.Net/Messages/InventoryActions.cs`.
@ -119,7 +126,9 @@ noninscribable cases show retail's exact permission messages. Monster pages
show the animated target, mapped creature type, level, and nine ordered
retail stat rows; rated creatures show retail's paired rating rows; the
animated target remains above row chrome but below text; changing selection
updates the open window without another magnifier click.
updates the open window without another magnifier click. Item text begins at
the top and assessed melee/missile/armor/magic items show their retail-ordered
stats and full DAT spell descriptions.
---

View file

@ -205,7 +205,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
| AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` |
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, item/creature/character reports, inscription transaction, retail creature stat/rating/animated-preview presentation, and default selection-follow, but still lacks item-object preview, exhaustive specialized item/character detail regions, and exact appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview loop cover the active loops; the remaining examination mechanisms require additional retail field/font/object-preview projections rather than fabricated content | Specialized item/character assessments omit some retail fields and item-object presentation; enchanted/incomplete appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog |
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, and retail-ordered item reports covering weapon/armor/caster modifiers, spell names plus DAT descriptions, requirements, capacities, cooldown/special properties, uses, crafting, rare, and description data. It still lacks item-object preview, player-dependent effective shield projection, DAT display-name resolution for equipment sets and slayer creature types, some expiry/material/portal/PK-specialized description branches, exhaustive character detail regions, and exact appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the remaining examination mechanisms require player-state or DAT display-name resolvers and object-preview ownership rather than fabricated content | Uncommon item/character assessments may omit the listed specialized branches and item-object presentation; unresolved equipment-set/slayer values use their numeric identity; enchanted/incomplete appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog |
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |

View file

@ -6,7 +6,9 @@ shared item-cooldown follow-up passed their connected gates. Slice 3's first
connected gate exposed an incorrect IdentifyResponse flag table, a non-retail
shared-panel mount, and its missing inscription transaction. All three are
corrected and live-confirmed. The creature-page follow-up now has retail's
ordered stat rows and animated private preview; its visual gate is pending.
ordered stat rows and animated private preview. The follow-up item report and
authored 310 x 400 layout correction are implemented; their connected visual
gate is pending.
**Milestone:** M4 prerequisite/preamble.
**Architecture:** retained gameplay UI over shared selection, object, and
interaction state. `GameWindow` remains a composition/callback shell.
@ -160,6 +162,24 @@ passes the Release solution build with the 17 warnings already tracked by
#228, 3,510 App tests / 3 skips, and 7,927 complete-solution tests / 5 skips.
Its corrected connected visual result remains the closeout gate.
The item-report/layout correction restores the authored 310 x 400 examination
size for the connected profile, leaves retail's ordinary resize range intact,
keeps row chrome at its LayoutDesc origin while insetting only foreground
creature text, and starts generated item prose at the top of its authored
scroll surface. `ItemAppraisalTextFormatter` now owns the decomp-ordered item
projection outside `GameWindow` and outside the examination controller. It
ports common weapon damage ranges/speed/range/ammunition, armor protection
bands, defense/caster modifiers, workmanship, ratings, wield/use/activation
requirements, item XP/capacity/lock/mana/uses/creator/rare data, cooldown and
imbued special properties, and both short spell lists and full DAT spell
descriptions. AP-110 now names only the remaining specialized,
player-dependent, DAT-display-name, font-state, and object-preview gaps.
Focused conformance fixtures cover the geometry layering plus melee, launcher,
armor, spell, and special-property reports. The Release solution build passes
with the 17 warnings already tracked by #228, 3,514 App tests / 3 skips, and
7,931 complete-solution tests / 5 skips. The connected visual result remains
the closeout gate.
## Slice 1 — spell-bar overflow arrows
### Retail oracle

View file

@ -329,6 +329,143 @@ this order:
9. caster/boost/healer/capacity/lock/mana/uses/craftsman details;
10. sale restriction, rare status, full magic, and description.
The individual branches are not a generic property dump. They project the
wire profile into retail prose:
```text
ShowWeaponAndArmor(validLocations, profile):
ammoType = live PublicWeenieDesc ammo type
if object is a hook:
ammoType = HookProfile ammo type
if validLocations contains Shield:
append "Base Shield Level: <ArmorLevel>"
append the player-dependent effective Shield level when available
if validLocations contains a weapon location:
weapon = profile.WeaponProfile
append "Skill: <WeaponSkill>"
if WeaponType exists:
append the parenthesized weapon subtype
launcher = validLocations contains MissileWeapon and ammoType != None
append launcher ? "Damage Bonus: " : "Damage: "
minimum = (1 - DamageVariance) * Damage
if Damage - minimum > 0.0002:
append "<minimum> - <maximum>"
else:
append "<maximum>"
append damage type for non-launchers
append Elemental Damage Bonus when present
append launcher Damage Modifier when present
if validLocations contains MeleeWeapon, MissileWeapon, or TwoHanded:
append "Speed: <category> (<raw weapon time>)"
categories:
raw < 11 -> Very Fast
raw < 31 -> Fast
raw < 50 -> Average
raw < 80 -> Slow
otherwise -> Very Slow
if launcher:
rangeYards = min(
85,
2 * MaxVelocity^2 * (1 / 9.8) * 1.094)
if rangeYards < 10:
displayedRange = ceil(rangeYards)
else:
displayedRange = truncate down to a multiple of 5
append "Range: <displayedRange> yds."
if MaxVelocityEstimated:
append " (based on STRENGTH 100)"
if WeaponOffense != 1 and this is not launcher damage:
append "Bonus to Attack Skill: <signed percent>."
append the retail ammunition sentence:
ammunition -> "Used as ammunition by bows/crossbows/atlatls."
launcher -> "Uses arrows/quarrels/atlatl darts as ammunition."
ShowArmor(profile):
append "Armor Level: <base AL>"
for Slashing, Piercing, Bludgeoning, Fire, Cold, Acid, Electric, Nether:
adjective from DamageResistanceToString:
0.0 -> None
(0.0, 0.4) -> Poor
[0.4, 0.8) -> Below Average
[0.8, 1.2) -> Average
[1.2, 1.6) -> Above Average
[1.6, 2.0) -> Excellent
>= 2.0 -> Unparalleled
append "<damage>: <adjective> (<base AL * modifier, rounded>)"
ShowMagic(profile):
if the spellbook exists and appraisal failed:
append "Spells: unknown."
return
split spell ids by the high enchantment bit
resolve every id with that bit masked through ClientMagicSystem
for ordinary spells:
append Spellcraft, Mana, and Mana Cost when present
append "Spell Descriptions:"
append each spell name and its DAT description
for high-bit enchantments:
append "Enchantments:"
append each spell name and its DAT description
ShowSpecialProperties(profile):
append carry limit and authored cooldown duration when present
append cleave count
build one "Properties: " line from:
creature slayer, Multi-Strike, imbued-effect flags,
Magic Absorbing, Unenchantable, Attuned/Bonded/death behavior,
Retained, Crushing Blow, Biting Strike, Armor/Resistance Cleaving,
Cast on Strike, Ivoryable, and Dyeable
if an imbued-effect flag exists:
append "This item cannot be further imbued."
if AutowieldLeft:
append the left-hand tether sentence
ShowRemainingItemFields(profile):
append, in SetAppraiseInfo order:
usage text
level, direct heritage restriction, and wield requirements
(up to four requirement triplets)
use restrictions
item level and item XP
activation requirements
caster, boost, healing, capacity, lock, and mana-stone data
remaining key/charge counts
creator, last tinkerer, and imbuer
cannot-be-sold and rare notices
short and long descriptions
```
Sources:
- `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`
- `ItemExamineUI::Appraisal_ShowWieldRequirements @ 0x004AF9A0`
- `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`
- `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`
- `ItemExamineUI::Appraisal_ShowArmorModsData @ 0x004B1F60`
- `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`
- `ItemExamineUI::Appraisal_ShowActivationRequirements @ 0x004B3270`
- `AppraisalSystem::WeaponTimeToString @ 0x005B4970`
- `AppraisalSystem::DamageResistanceToString @ 0x005B5490`
- `AppraisalSystem::InqWorkmanshipAdjective @ 0x005B48E0`
- `ClientUISystem::DeltaTimeToString @ 0x00565E10`
ACE's `AppraiseInfo` serializer and property enums were used to pin the
typed-table IDs carried by the network profile. ACViewer's portal.dat
SpellTable path was used as the second reference check that item spell names
and descriptions come from DAT spell metadata rather than a hand-maintained
client list. Retail's named functions remain the behavioral oracle.
The main text and inscription areas use their authored scrollbars. A newly
selected object resets those scroll positions to the top; a refresh of the
same object preserves the user's position.
@ -344,6 +481,14 @@ Inventory/Skills/Spellbook panel.
`gmFloatyExaminationUI::ResizeTo` delegates to ordinary `UIElement::ResizeTo`
and persists width/height; it adds no special resize algorithm.
The authored root is exactly 310 by 400 pixels and has 310-by-400 minimum
constraints. Its maximum constraints are 2000 by 2000, so 310 by 400 is the
retail opening/default size, not a fixed-size panel. The main item report
control is authored with bottom vertical justification, but
`ItemExamineUI::AddItemInfo` appends rows from the start of the cleared text
surface. The retained projection must therefore lay out that generated report
from the top while leaving the separate authored inscription behavior intact.
## Inscription presentation and eligibility
```text

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -61,6 +61,7 @@ public sealed class AppraisalUiControllerTests
Assert.Equal("Atlan Weapon", Assert.Single(title.LinesProvider()).Text);
UiText itemText = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.ItemTextId));
Assert.Equal(VJustify.Top, itemText.VerticalJustify);
string report = string.Join('\n', itemText.LinesProvider().Select(line => line.Text));
Assert.Contains("Value: 1,250", report);
Assert.Contains("Burden: 350", report);
@ -237,6 +238,7 @@ public sealed class AppraisalUiControllerTests
UiItemList list = Assert.Single(
creaturePanel.Children.OfType<UiItemList>(),
candidate => candidate.Top == host.Top);
Assert.Equal(0f, background.Left);
Assert.Equal(CreatureAppraisalLayeredList.TextInset, list.Left);
Assert.True(host.ZOrder < viewport.ZOrder);
Assert.True(viewport.ZOrder < list.ZOrder);

View file

@ -0,0 +1,262 @@
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.UI.Layout;
public sealed class ItemAppraisalTextFormatterTests
{
[Fact]
public void WeaponAndMagic_AreProjectedInRetailOrderWithDatDescriptions()
{
var obj = new ClientObject
{
ObjectId = 0x50000001u,
Name = "Flaming Sword",
Type = ItemType.MeleeWeapon,
ValidLocations = EquipMask.MeleeWeapon,
Value = 12_500,
Burden = 450,
Workmanship = 8.4f,
};
var properties = new PropertyBundle();
properties.Ints[353u] = 2;
properties.Ints[204u] = 4;
properties.Ints[106u] = 300;
properties.Ints[107u] = 250;
properties.Ints[108u] = 500;
properties.Ints[117u] = 20;
properties.Floats[29u] = 1.05d;
properties.Strings[14u] = "Use this item to attack.";
properties.Strings[16u] = "A finely balanced enchanted blade.";
SpellMetadata flame = Spell(101u, "Flame Bolt VI", "Hurls a bolt of flame.");
SpellMetadata armor = Spell(102u, "Impenetrability VI", "Increases the target's armor.");
AppraiseInfoParser.Parsed appraisal = Parsed(
properties,
spells: [101u, 0x8000_0066u],
weapon: new AppraiseInfoParser.WeaponProfile(
DamageType: 1u,
WeaponTime: 30u,
WeaponSkill: 44u,
Damage: 40u,
DamageVariance: 0.25d,
DamageMod: 1d,
WeaponLength: 1d,
MaxVelocity: 0d,
WeaponOffense: 1.15d,
MaxVelocityEstimated: 0u));
string report = ItemAppraisalTextFormatter.Build(
obj,
appraisal,
id => id switch
{
101u => flame,
102u => armor,
_ => null,
});
Assert.Contains("Value: 12,500", report);
Assert.Contains("Workmanship: Utterly flawless (8.4)", report);
Assert.Contains("Skill: Heavy Weapons (Sword)", report);
Assert.Contains("Damage: 30 - 40, Slashing", report);
Assert.Contains("Elemental Damage Bonus: 4, Slashing.", report);
Assert.Contains("Speed: Fast (30)", report);
Assert.Contains("Bonus to Attack Skill: +15%.", report);
Assert.Contains("Bonus to Melee Defense: +5%.", report);
Assert.Contains("Spells: Flame Bolt VI, Impenetrability VI", report);
Assert.Contains("Spellcraft: 300.", report);
Assert.Contains("Mana: 250 / 500.", report);
Assert.Contains("Mana Cost: 20.", report);
Assert.Contains("Spell Descriptions:", report);
Assert.Contains("Flame Bolt VI\nHurls a bolt of flame.", report);
Assert.Contains("Enchantments:", report);
Assert.Contains("Impenetrability VI\nIncreases the target's armor.", report);
Assert.True(
report.IndexOf("Skill:", StringComparison.Ordinal)
< report.IndexOf("Spells:", StringComparison.Ordinal));
Assert.True(
report.IndexOf("Spells:", StringComparison.Ordinal)
< report.IndexOf("Spell Descriptions:", StringComparison.Ordinal));
Assert.EndsWith("A finely balanced enchanted blade.", report);
}
[Fact]
public void ArmorRequirementsAndUses_UseRetailLabelsAndResistanceBands()
{
var obj = new ClientObject
{
ObjectId = 0x50000002u,
Name = "Platemail Hauberk",
Type = ItemType.Armor,
ValidLocations = EquipMask.ChestArmor,
Structure = 8,
MaxStructure = 10,
};
var properties = new PropertyBundle();
properties.Ints[28u] = 200;
properties.Ints[158u] = 1;
properties.Ints[159u] = 6;
properties.Ints[160u] = 250;
properties.Ints[324u] = 4;
properties.Ints[369u] = 50;
properties.Ints[370u] = 3;
properties.Bools[69u] = false;
properties.Strings[25u] = "Tester";
AppraiseInfoParser.Parsed appraisal = Parsed(
properties,
armor: new AppraiseInfoParser.ArmorProfile(
SlashingProtection: 1.2f,
PiercingProtection: 0.8f,
BludgeoningProtection: 0.4f,
ColdProtection: 0f,
FireProtection: 1.6f,
AcidProtection: 2f,
NetherProtection: 1f,
LightningProtection: 0.2f));
string report = ItemAppraisalTextFormatter.Build(
obj,
appraisal,
_ => null);
Assert.Contains("Ratings: Dam +3", report);
Assert.Contains("Armor Level: 200", report);
Assert.Contains("Slashing: Above Average (240)", report);
Assert.Contains("Piercing: Average (160)", report);
Assert.Contains("Bludgeoning: Below Average (80)", report);
Assert.Contains("Cold: None (0)", report);
Assert.Contains("Fire: Excellent (320)", report);
Assert.Contains("Acid: Unparalleled (400)", report);
Assert.Contains("Electric: Poor (40)", report);
Assert.Contains("Wield requires Viamontian", report);
Assert.Contains("Wield requires Melee Defense 250", report);
Assert.Contains("Use requires level 50.", report);
Assert.Contains("Number of uses remaining: 8", report);
Assert.Contains("Created by Tester.", report);
Assert.Contains("This item cannot be sold.", report);
}
[Fact]
public void Launcher_UsesDamageBonusModifierAndAmmunitionWording()
{
var obj = new ClientObject
{
ObjectId = 0x50000003u,
Name = "Longbow",
Type = ItemType.MissileWeapon,
ValidLocations = EquipMask.MissileWeapon,
AmmoType = 1,
};
AppraiseInfoParser.Parsed appraisal = Parsed(
new PropertyBundle(),
weapon: new AppraiseInfoParser.WeaponProfile(
DamageType: 2u,
WeaponTime: 45u,
WeaponSkill: 47u,
Damage: 12u,
DamageVariance: 0d,
DamageMod: 1.13d,
WeaponLength: 1d,
MaxVelocity: 50d,
WeaponOffense: 1d,
MaxVelocityEstimated: 1u));
string report = ItemAppraisalTextFormatter.Build(
obj,
appraisal,
_ => null);
Assert.Contains("Skill: Missile Weapons", report);
Assert.Contains("Damage Bonus: 12", report);
Assert.DoesNotContain("Damage Bonus: 12, Piercing", report);
Assert.Contains("Damage Modifier: +13%.", report);
Assert.Contains("Speed: Average (45)", report);
Assert.Contains("Range: 85 yds. (based on STRENGTH 100)", report);
Assert.Contains("Uses arrows as ammunition.", report);
}
[Fact]
public void SpecialProperties_AreCombinedUsingRetailNames()
{
var obj = new ClientObject
{
ObjectId = 0x50000004u,
Name = "Imbued Katar",
Type = ItemType.MeleeWeapon,
};
var properties = new PropertyBundle();
properties.Ints[279u] = 3;
properties.Ints[292u] = 4;
properties.Ints[179u] = 0x0000_0481;
properties.Ints[114u] = 1;
properties.Ints[33u] = 1;
properties.Floats[167u] = 90d;
properties.Bools[99u] = true;
properties.Bools[100u] = true;
string report = ItemAppraisalTextFormatter.Build(
obj,
Parsed(properties),
_ => null);
Assert.Contains("You can only carry 3 of these items.", report);
Assert.Contains("Cooldown When Used: 1m 30s ", report);
Assert.Contains("Cleave: 4 enemies in front arc.", report);
Assert.Contains(
"Properties: Critical Strike, Cold Rending, +1 Melee Defense, Attuned, Bonded, Ivoryable, Dyeable",
report);
Assert.Contains("This item cannot be further imbued.", report);
}
private static AppraiseInfoParser.Parsed Parsed(
PropertyBundle properties,
uint[]? spells = null,
AppraiseInfoParser.WeaponProfile? weapon = null,
AppraiseInfoParser.ArmorProfile? armor = null)
=> new(
Guid: 0x50000001u,
Flags: AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable,
Success: true,
Properties: properties,
SpellBook: spells ?? [],
ArmorProfile: armor,
CreatureProfile: null,
WeaponProfile: weapon,
HookProfile: null,
ArmorLevels: null,
ArmorEnchantments: null,
WeaponEnchantments: null,
ResistEnchantments: null);
private static SpellMetadata Spell(
uint id,
string name,
string description)
=> new(
SpellId: id,
Name: name,
School: "War Magic",
Family: id,
IconId: 0u,
SpellWords: string.Empty,
Duration: 0f,
ManaCost: 10,
IsDebuff: false,
IsFellowship: false,
Description: description,
SortKey: 0,
Difficulty: 0,
Flags: 0u,
Generation: 6,
IsFastWindup: false,
IsOffensive: true,
IsUntargeted: false,
Speed: 0f,
CasterEffect: 0u,
TargetEffect: 0u,
TargetMask: 0u,
SpellType: 0);
}