From d78d09cfbcde9730e95c519890dada81590d0a20 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 23 Jul 2026 19:08:05 +0200 Subject: [PATCH] feat(ui): port retail item appraisal reports Follow ItemExamineUI's EoR dispatch and wording for item assessment instead of the generic projection. Resolve material and creature names through installed DAT maps, cover specialized item branches and item-XP curves, and narrow AP-110 to the remaining live/localized preview seams. --- docs/ISSUES.md | 9 + .../retail-divergence-register.md | 2 +- docs/plans/2026-04-11-roadmap.md | 11 +- docs/plans/2026-05-12-milestones.md | 11 +- ...2026-07-23-world-interaction-completion.md | 17 + ...26-07-23-retail-appraisal-ui-pseudocode.md | 151 +++- .../UI/Layout/AppraisalUiController.cs | 14 +- .../UI/Layout/ItemAppraisalTextFormatter.cs | 834 ++++++++++++++---- .../UI/Layout/RetailAppraisalNameResolver.cs | 91 ++ src/AcDream.App/UI/RetailUiRuntime.cs | 7 +- .../Items/ItemInteractionPolicy.cs | 2 + .../Layout/ItemAppraisalTextFormatterTests.cs | 302 ++++++- 12 files changed, 1258 insertions(+), 193 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 27502cb3..a2771944 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -123,6 +123,15 @@ bytes into `255/255`. CreateObject now sign-extends both capacity bytes, so `Appraisal_ShowCapacity`'s existing positive tests naturally suppress the sentinels without a name/type workaround. +The subsequent exhaustive item-report pass ports the remaining EoR formatter +branches: literal equipment-set names, positive-only ratings, salvage +workmanship, clothing coverage, failed weapon unknowns, exact item-XP curves, +activation/healer/rare/magic prose, and decorated lifespan/material/gem/portal +descriptions. Installed DAT maps now supply material and creature/slayer names. +AP-110 retains only item preview, live player/localization projections, +character detail, and creature FontInfo state; the connected item visual is +the remaining acceptance gate. + **Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 456170e7..2d6aa8aa 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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, 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. Item reports also preserve appraisal-only unknowns, hook/capacity/lock presence rules, paragraph boundaries, and the authored normal/beneficial/detrimental color list. 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 creature 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 creature 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::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `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-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 the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose; ordinary/enchantment DAT spell descriptions; DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature 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/RetailAppraisalNameResolver.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 residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature 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::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `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` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index d2baa4df..d2b9bf38 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -14,8 +14,8 @@ bodies begin: spell-bar overflow controls, status-bar Use/Assess commands, assessment information, equipped-child picking, vendor browsing, and server-authoritative vendor transactions. Slice 1's DAT-authored arrow-only favorite-spell scrollbar, fixed 18-cell capacity, authored arrow sides/media, -and persistent manual offset passed the connected gate. Slice 2 is active: the -status hand follows canonical selection/object notices, ghosts empty and +and persistent manual offset passed the connected gate. Slice 2's status hand +follows canonical selection/object notices, ghosts empty and explicitly unusable selections, and routes enabled weapons, direct-use items, and use-on-target tools through the existing interaction transaction and cursor owners. Its connected state gate passed. A named-retail correction now @@ -38,8 +38,11 @@ shared main-panel content; and inscription was display-only. Exact flag decoding now reaches the balanced request owner, retail's `gmFloatyExaminationUI` has an independent top-level lifetime, and the imported multiline field sends the exact `SetInscription (0x00BF)` -transaction under retail ownership/authorship rules. The corrective connected -gate is next. +transaction under retail ownership/authorship rules; those corrections are +user-confirmed. The item view now follows the complete EoR formatter dispatch, +including literal set/rating/tinkering prose, weapon/armor/magic/XP/ +activation/healer/rare branches, and installed-DAT material and creature +names. Its connected item-report comparison is Slice 3's remaining gate. --- diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 20744b95..705c5e75 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -89,8 +89,8 @@ transactions. This is deliberately using the extracted interaction owners and canonical shared main-panel host before quest/emote/character-creation bodies broaden the feature surface. -Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2 is -active: the status hand's selected-object availability and existing +Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2's +status hand selected-object availability and existing Use/AutoWield/use-on-target command routing passed their connected gate. The follow-up retail correction for zero-valued direct-use items, named AutoWear blockers, and carried-use delivery is user-confirmed. The requested @@ -104,8 +104,11 @@ appraisal busy reference held, retail's examination UI had been mounted as a shared main-panel page rather than its own floaty window, and inscription was display-only. Exact response flags, the independent `gmFloatyExaminationUI` lifetime, and the retail `SetInscription (0x00BF)` -edit transaction are now ported. The corrective connected -examination/inscription gate is next. +edit transaction are now ported and user-confirmed. Its item path now follows +the complete EoR formatter dispatch, including literal set/rating/tinkering +prose, weapon/armor/magic/XP/activation/healer/rare branches, and installed-DAT +material/creature names. The connected item-report comparison is Slice 3's +remaining gate. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index 70b22671..cf111309 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -196,6 +196,23 @@ skips and 7,942 complete-solution tests / 5 skips pass while the previously launched client retains the normal Release output lock. The corrected connected item visual remains the closeout gate. +The exhaustive item-report correction replaces the remaining generic/numeric +approximations with the complete named-retail dispatch. Equipment sets use the +literal EoR table; ratings, tinkering/salvage averages, coverage, failed weapon +unknowns, level restrictions, all three item-XP curves, activation heritage, +healer/ordinary boost behavior, rare timers, and magic `~ Name: Description` +rows now preserve retail wording and ordering. Description construction ports +lifespan prose, workmanship/material/gem decoration and portal/PK restriction +bits. `RetailAppraisalNameResolver` follows retail's master +`EnumIDMap -> sub-enum 1 DualEnumIDMap` material chain and shares the installed +creature mapper for slayers and wield requirements; a production-DAT test pins +Ruby and Ghost. AP-110 is narrowed to item preview and the projections that +need live player/localization state (effective shield, cooldown remaining, and +augmentation-cost `StringInfo`), plus character and creature-font residuals. +The focused item formatter suite passes 21/21, App Release passes 3,531 tests / +3 skips, and the complete Release solution passes 7,952 tests / 5 skips. The +connected item-report comparison is the remaining Slice 3 gate. + ## Slice 1 — spell-bar overflow arrows ### Retail oracle diff --git a/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md b/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md index ae252c2e..57aea162 100644 --- a/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md +++ b/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md @@ -517,6 +517,139 @@ ShowRemainingItemFields(profile): short and long descriptions ``` +### Exhaustive EoR item-report branches + +The common projection above was re-audited against every helper called by +`ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`. The remaining item branches are +not generic property labels; retail applies the following exact rules. + +```text +ShowTinkering: + if NumTimesTinkered (171): + "This item has been tinkered %d time[s]." + if TinkerName (39): "Last tinkered by %s." + if ImbuerName (40): "Imbued by %s." + if Workmanship (105): + if NumItemsInMaterial (170) is absent: + "Workmanship: ()" + else: + average = workmanship / NumItemsInMaterial + "Workmanship: ()" + blank line + "Salvaged from %d items." + +ShowSet: + EquipmentSetId (265) selects the literal EoR set-name table. + Unknown and explicitly unused entries produce no row. + Known entries produce "Set: ". + +ShowRatings: + append only positive ratings, in this order: + Dam, Dam Resist, Crit, Crit Dam, Crit Resist, Crit Dam Resist, + Heal Boost, Nether Resist, Life Resist + render as "Ratings: Dam 3, Crit 2" (there is no plus sign) + positive Vitality (379) is a separate + "This item adds %d Vitality." + +ShowWeaponAndArmorData: + only interpret WeaponProfile inside weapon/shield valid-location bits + preserve retail Unknown sentinels for damage, speed, and range + for ordinary clothing append: + "Covers Head, Chest, Abdomen, Upper Arms, Lower Arms, Hands, + Upper Legs, Lower Legs, Feet" + selecting the names from ClothingPriority bits in that exact order + +ShowShortMagic: + list only ordinary spell ids (high bit clear) + a failed appraisal prints "Spells: unknown." + +ShowMagic: + a failed appraisal also prints "Spells: unknown." + ordinary spell rows are: + "Spell Descriptions:\n~ : " + enchantment rows are: + "Enchantments:\n~ : " + +ShowLevelLimits: + min == max: "Restricted to characters of Level %d." + min + max: "Restricted to characters of Levels %d to %d." + min only: "Restricted to characters of Level %d or greater." + max only: "Restricted to characters of Level %d or below." + +ShowItemLevel: + require ItemBaseXp (int64 5), ItemMaxLevel (319), and ItemXpStyle (320) + currentLevel = ItemTotalXPToLevel(ItemTotalXp, baseXp, maxLevel, style) + displayedLevel = min(currentLevel + 1, maxLevel) + nextThreshold = ItemLevelToTotalXP(min(currentLevel + 1, maxLevel), ...) + "Item Level: / " + "Item XP: / " + +ItemLevelToTotalXP(level, base, max, style): + clamp level to [0, max] + style 1: base * level + style 2: base * (2^level - 1) + style 3: base * level * (level + 1) / 2 + +ShowActivationRequirements: + successful appraisals only + append Arcane Lore, Allegiance Rank, Heritage, skill, primary-attribute, + and secondary-attribute requirements to one "Activation requires " row + in that order + +ShowBoostValue / ShowHealKitValues: + ordinary consumables render Health/Mana "... when used." + Stamina is "... when consumed." + healer and hooked-healer profiles instead interpret BoostValue as + "Bonus to Healing Skill" and float 100 as "Restoration Bonus" + +ShowRareInfo: + RareUsesTimer (bool 108): + "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." + RareId (17): "Rare #%d" + +ShowDescription: + if lifespan, creation timestamp, and remaining lifespan are present: + remaining < 0: "This item is in the act of disintegrating." + otherwise: "This item expires in " + LongDesc replaces ShortDesc when present; both are never printed + GearPlatingName (52) replaces LongDesc before decoration + AppraisalItemSkill (172) decorates that description: + bit 1 prepends workmanship + MaterialType (131) prepends the DAT material display name and removes + its first occurrence from the authored description + bit 4 appends ", set with " + PortalBitmask (111) appends, in bit order: + Player Killers may not use this portal. + Lite Player Killers may not use this portal. + Non-Player Killers may not use this portal. + This portal cannot be recalled nor linked to. + This portal cannot be summoned. +``` + +The material name is not a locally invented table. +`MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500` performs +`DBObj::GetByEnum(DBObjType=0x28, clientEnum=0x10000001, subEnum=1)`: +the master map resolves the root `EnumIDMap`, sub-enum 1 resolves the material +`DualEnumIDMap`, and its client name is read with underscores replaced by +spaces. Creature/slayer +names use `EnumMapper::GetString(0x10000005, value)`; heritage restrictions use +`EnumMapper::GetString(0x10000002, value)` with retail's explicit +Gharu'ndim/Umbraen/Olthoi spellings. + +Additional named sources for these branches: + +- `ItemExamineUI::Appraisal_ShowSet @ 0x004AE880` +- `ItemExamineUI::Appraisal_ShowRatings @ 0x004AF080` +- `ItemExamineUI::Appraisal_ShowTinkeringInfo @ 0x004B0E70` +- `ItemExamineUI::Appraisal_ShowLevelLimitInfo @ 0x004B2920` +- `ItemExamineUI::Appraisal_ShowItemLevelInfo @ 0x004B3C60` +- `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990` +- `ExperienceSystem::ItemLevelToTotalXP @ 0x005C8100` +- `ExperienceSystem::ItemTotalXPToLevel @ 0x005C81B0` +- `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500` + Sources: - `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0` @@ -677,11 +810,13 @@ implements the same behavior. The request lifetime, exact packet flags, independent floaty window, inscription permission/edit/write transaction, subview choice, authored outer layout, ordered creature stats and ratings, animated creature preview, -viewport/list/text compositing, selection-follow, scrollbar ownership, and -combat refresh are in scope. Common item report rows now preserve retail's -appraisal-only unknown values, hook/capacity/lock presence rules, exact -paragraph boundaries, and authored normal/beneficial/detrimental font-color -selection. Retail's item-object preview, exhaustive specialized -`ItemExamineUI` detail branches, creature incomplete/high/low FontInfo -selection, and PSR-only scribe-account/override behavior remain explicit -AP-110 work. +viewport/list/text compositing, selection-follow, scrollbar ownership, combat +refresh, and the complete EoR item-report dispatch are in scope. Item rows now +preserve retail's appraisal-only unknown values, hook/capacity/lock presence +rules, exact paragraph boundaries, authored normal/beneficial/detrimental +font-color selection, equipment-set/ratings/tinkering/weapon/armor/magic/XP/ +activation/healer/rare/description ordering, and installed-DAT material and +creature names. Retail's item-object preview, live player-dependent effective +shield/cooldown projections, localized augmentation-cost `StringInfo`, +creature incomplete/high/low FontInfo selection, complete character detail, +and PSR-only scribe-account/override behavior remain explicit AP-110 work. diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index 880f89d8..7e5f4dc6 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -63,6 +63,7 @@ public sealed class AppraisalUiController : IRetainedPanelController private readonly CreatureAppraisalLayeredList? _creatureExtra; private readonly CreatureAppraisalRowTemplateFactory? _creatureRowTemplates; private readonly CreatureDisplayNameResolver _creatureNames; + private readonly RetailAppraisalNameResolver _itemNames; private AppraisalView _activeView; private uint _itemObjectId; private uint _creatureObjectId; @@ -95,7 +96,8 @@ public sealed class AppraisalUiController : IRetainedPanelController UiText title, UiText itemText, CreatureAppraisalRowTemplateFactory? creatureRowTemplates, - CreatureDisplayNameResolver? creatureNames) + CreatureDisplayNameResolver? creatureNames, + RetailAppraisalNameResolver? itemNames) { _layout = layout; _objects = objects; @@ -117,6 +119,7 @@ public sealed class AppraisalUiController : IRetainedPanelController _creatureNames = creatureNames ?? new CreatureDisplayNameResolver( new Dictionary()); + _itemNames = itemNames ?? RetailAppraisalNameResolver.Empty; _inscriptionText = layout.FindElement(InscriptionTextId) as UiText; _inscriptionField = layout.FindElement(InscriptionTextId) as UiField; _signature = layout.FindElement(SignatureTextId) as UiText; @@ -200,7 +203,8 @@ public sealed class AppraisalUiController : IRetainedPanelController Action show, Action close, CreatureAppraisalRowTemplateFactory? creatureRowTemplates = null, - CreatureDisplayNameResolver? creatureNames = null) + CreatureDisplayNameResolver? creatureNames = null, + RetailAppraisalNameResolver? itemNames = null) { ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(objects); @@ -237,7 +241,8 @@ public sealed class AppraisalUiController : IRetainedPanelController title, itemText, creatureRowTemplates, - creatureNames); + creatureNames, + itemNames); } /// @@ -343,7 +348,8 @@ public sealed class AppraisalUiController : IRetainedPanelController _itemReport = ItemAppraisalTextFormatter.BuildReport( obj, appraisal, - ResolveSpell); + ResolveSpell, + _itemNames); SetInscription(obj, appraisal); if (newlySelected) { diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs index e1d15d0f..31f59f06 100644 --- a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs +++ b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs @@ -26,43 +26,51 @@ public static class ItemAppraisalTextFormatter public static string Build( ClientObject obj, AppraiseInfoParser.Parsed appraisal, - Func resolveSpell) - => BuildReport(obj, appraisal, resolveSpell).ToString(); + Func resolveSpell, + RetailAppraisalNameResolver? names = null) + => BuildReport(obj, appraisal, resolveSpell, names).ToString(); public static ItemAppraisalReport BuildReport( ClientObject obj, AppraiseInfoParser.Parsed appraisal, - Func resolveSpell) + Func 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, obj, properties); + ShowTinkering(report, properties); ShowSetAndRatings(report, properties); ShowWeaponAndArmor(report, obj, appraisal); ShowDefenseModifiers(report, appraisal); + ShowArmorModifiers(report, appraisal); ShowShortMagicInfo(report, appraisal, resolveSpell); - ShowSpecialProperties(report, obj, properties); + ShowSpecialProperties(report, properties, names); ShowUsage(report, properties); ShowLevelLimits(report, properties); - ShowWieldRequirements(report, properties); + ShowWieldRequirements(report, properties, names); ShowUsageLimits(report, properties); ShowItemLevel(report, properties); - ShowActivationRequirements(report, properties); + ShowActivationRequirements( + report, + properties, + appraisal.Success, + names); ShowCasterData(report, appraisal); - ShowBoostAndHealing(report, properties); + 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); + ShowDescription(report, properties, names); return report.Build(); } @@ -88,29 +96,13 @@ public static class ItemAppraisalTextFormatter /// private static void ShowTinkering( RetailReportBuilder report, - ClientObject obj, PropertyBundle properties) { - int tinkers = properties.GetInt(171u); - if (tinkers != 0) + if (properties.Ints.TryGetValue(171u, out int tinkers)) + { report.Line( $"This item has been tinkered {tinkers.ToString(CultureInfo.CurrentCulture)} " + (tinkers == 1 ? "time." : "times.")); - - int integerWorkmanship = properties.GetInt(105u); - float workmanship = integerWorkmanship != 0 - ? integerWorkmanship - : obj.Workmanship; - if (workmanship != 0f) - { - int workmanshipBand = Math.Clamp( - (int)MathF.Round(workmanship), - 0, - 10); - string adjective = WorkmanshipAdjective(workmanshipBand); - report.Line( - $"Workmanship: {adjective} " - + $"({workmanship.ToString("0.##", CultureInfo.CurrentCulture)})"); } string lastTinkeredBy = properties.GetString(39u); @@ -119,6 +111,29 @@ public static class ItemAppraisalTextFormatter string imbuedBy = properties.GetString(40u); if (!string.IsNullOrWhiteSpace(imbuedBy)) report.Line($"Imbued by {imbuedBy}."); + + if (!properties.Ints.TryGetValue(105u, out int workmanship)) + return; + + 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."); + return; + } + + report.Line( + $"Workmanship: {WorkmanshipAdjective(Math.Clamp(workmanship, 0, 10))} " + + $"({workmanship.ToString(CultureInfo.CurrentCulture)})"); } /// @@ -129,8 +144,11 @@ public static class ItemAppraisalTextFormatter RetailReportBuilder report, PropertyBundle properties) { - if (properties.Ints.TryGetValue(265u, out int setId) && setId != 0) - report.Line($"Set: {setId.ToString(CultureInfo.CurrentCulture)}"); + if (properties.Ints.TryGetValue(265u, out int setId) + && EquipmentSetName(setId) is { } setName) + { + report.Line($"Set: {setName}"); + } (uint Property, string Label)[] ratingProperties = [ @@ -146,13 +164,13 @@ public static class ItemAppraisalTextFormatter ]; string[] ratings = ratingProperties .Where(pair => properties.Ints.TryGetValue(pair.Property, out int value) - && value != 0) + && value > 0) .Select(pair => - $"{pair.Label} {properties.GetInt(pair.Property).ToString("+0;-0;0", CultureInfo.CurrentCulture)}") + $"{pair.Label} {properties.GetInt(pair.Property).ToString(CultureInfo.CurrentCulture)}") .ToArray(); if (ratings.Length != 0) report.Line($"Ratings: {string.Join(", ", ratings)}"); - if (properties.GetInt(379u) is int vitality && vitality != 0) + if (properties.GetInt(379u) is int vitality && vitality > 0) report.Line( $"This item adds {vitality.ToString(CultureInfo.CurrentCulture)} Vitality."); } @@ -167,12 +185,18 @@ public static class ItemAppraisalTextFormatter AppraiseInfoParser.Parsed appraisal) { PropertyBundle properties = appraisal.Properties; - uint validLocations = (uint)obj.ValidLocations; - uint ammoType = obj.IsHook && appraisal.HookProfile is { } hook - ? hook.AmmoType + 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 ((validLocations & (uint)EquipMask.Shield) != 0) + if (hasWeaponOrShieldLocation + && (validLocations & (uint)EquipMask.Shield) != 0) { if (properties.Ints.TryGetValue(28u, out int shieldLevel)) report.Line( @@ -180,11 +204,12 @@ public static class ItemAppraisalTextFormatter EnchantmentStyle( appraisal.ArmorEnchantments, 0x0001u)); - else if (!appraisal.Success) + else report.Line("Shield Level: Unknown"); } - if (appraisal.WeaponProfile is { } weapon) + if (hasWeaponOrShieldLocation + && appraisal.WeaponProfile is { } weapon) { string skill = SkillName((int)weapon.WeaponSkill); int weaponType = properties.GetInt(353u); @@ -194,13 +219,26 @@ public static class ItemAppraisalTextFormatter bool launcher = (validLocations & (uint)EquipMask.MissileWeapon) != 0 && ammoType != 0u; string damageLabel = launcher ? "Damage Bonus" : "Damage"; - double minimumDamage = (1d - weapon.DamageVariance) * weapon.Damage; - string damage = weapon.Damage - minimumDamage > 0.0002d - ? $"{minimumDamage.ToString("0.###", CultureInfo.CurrentCulture)}" - + $" - {weapon.Damage.ToString(CultureInfo.CurrentCulture)}" - : weapon.Damage.ToString(CultureInfo.CurrentCulture); - if (!launcher) - damage += $", {DamageTypeName(weapon.DamageType)}"; + 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); @@ -220,41 +258,56 @@ public static class ItemAppraisalTextFormatter + $"{DamageTypeName(weapon.DamageType)}."); if (launcher) + { report.Line( - $"Damage Modifier: {FormatModifier(weapon.DamageMod)}.", + 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( - $"Speed: {WeaponTimeName((int)weapon.WeaponTime)} " - + $"({weapon.WeaponTime.ToString(CultureInfo.CurrentCulture)})", + weapon.WeaponTime == uint.MaxValue + ? "Speed: Unknown" + : $"Speed: {WeaponTimeName((int)weapon.WeaponTime)} " + + $"({weapon.WeaponTime.ToString(CultureInfo.CurrentCulture)})", EnchantmentStyle( appraisal.WeaponEnchantments, 0x0004u)); + } - if (launcher && weapon.MaxVelocity > 0d) + if (launcher) { - // 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 (!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) @@ -268,14 +321,28 @@ public static class ItemAppraisalTextFormatter ShowAmmunitionDescription(report, validLocations, ammoType); - if (appraisal.ArmorProfile is not { } armor) - return; + if (!hasWeaponOrShieldLocation + && (validLocations & 0x0800_7FFFu) != 0u + && ClothingCoverage(obj.Priority) is { Length: > 0 } coverage) + { + report.Line($"Covers {coverage}"); + } + } + + /// Appraisal_ShowArmorModsData @ 0x004B1F60. + 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; + } - int armorLevel = properties.GetInt(28u); report.Line( - appraisal.Success - ? $"Armor Level: {armorLevel.ToString(CultureInfo.CurrentCulture)}" - : "Armor Level: Unknown", + $"Armor Level: {armorLevel.ToString(CultureInfo.CurrentCulture)}", EnchantmentStyle(appraisal.ArmorEnchantments, 0x0001u)); ShowProtection( report, "Slashing", armorLevel, armor.SlashingProtection, @@ -403,13 +470,18 @@ public static class ItemAppraisalTextFormatter return; } + uint[] ordinary = appraisal.SpellBook + .Where(raw => (raw & 0x8000_0000u) == 0u) + .ToArray(); + if (ordinary.Length == 0) + return; + string names = string.Join( ", ", - appraisal.SpellBook.Select(raw => + ordinary.Select(raw => { - uint id = raw & 0x7FFF_FFFFu; - return resolveSpell(id)?.Name - ?? $"Spell {id.ToString(CultureInfo.CurrentCulture)}"; + return resolveSpell(raw)?.Name + ?? $"Spell {raw.ToString(CultureInfo.CurrentCulture)}"; })); report.Paragraph($"Spells: {names}"); } @@ -417,8 +489,8 @@ public static class ItemAppraisalTextFormatter /// Appraisal_ShowSpecialProperties @ 0x004B0140. private static void ShowSpecialProperties( RetailReportBuilder report, - ClientObject obj, - PropertyBundle properties) + PropertyBundle properties, + RetailAppraisalNameResolver names) { int carryLimit = properties.GetInt(279u); if (carryLimit > 0) @@ -427,11 +499,11 @@ public static class ItemAppraisalTextFormatter + $"{carryLimit.ToString("N0", CultureInfo.CurrentCulture)} " + "of these items."); - double cooldown = properties.Floats.TryGetValue(167u, out double assessedCooldown) - ? assessedCooldown - : obj.CooldownDuration ?? 0d; - if (cooldown > 0d) + if (properties.Floats.TryGetValue(167u, out double cooldown) + && cooldown > 0d) + { report.Line($"Cooldown When Used: {FormatDeltaTime(cooldown)}"); + } int cleave = properties.GetInt(292u); if (cleave > 1) @@ -441,9 +513,17 @@ public static class ItemAppraisalTextFormatter var special = new List(); int slayer = properties.GetInt(166u); if (slayer != 0) - special.Add(slayer == 31 + { + string slayerName = slayer == 31 ? "Bael'Zharon's Hate" - : $"Creature type {slayer.ToString(CultureInfo.CurrentCulture)} slayer"); + : names.ResolveCreature(slayer); + if (!string.IsNullOrWhiteSpace(slayerName)) + { + special.Add(slayer == 31 + ? slayerName + : $"{slayerName} slayer"); + } + } if ((properties.GetInt(47u) & 0x79E0) != 0) special.Add("Multi-Strike"); @@ -501,7 +581,7 @@ public static class ItemAppraisalTextFormatter if (imbuedEffects != 0u) report.Line("This item cannot be further imbued."); if (properties.GetBool(130u)) - report.Line("This item is tethered to the left hand."); + report.Line("This item is tethered to the left side."); } private static void AppendImbuedEffects( @@ -547,18 +627,22 @@ public static class ItemAppraisalTextFormatter int minimum = properties.GetInt(86u); int maximum = properties.GetInt(87u); if (minimum > 0 && maximum > 0) - report.Paragraph( - $"Restricted to characters of level " - + $"{minimum.ToString(CultureInfo.CurrentCulture)} to " - + $"{maximum.ToString(CultureInfo.CurrentCulture)}."); + { + 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 " + $"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 lower."); + $"Restricted to characters of Level " + + $"{maximum.ToString(CultureInfo.CurrentCulture)} or below."); string destination = properties.GetString(38u); if (!string.IsNullOrWhiteSpace(destination)) @@ -568,7 +652,8 @@ public static class ItemAppraisalTextFormatter /// Appraisal_ShowWieldRequirements @ 0x004AF9A0. private static void ShowWieldRequirements( RetailReportBuilder report, - PropertyBundle properties) + PropertyBundle properties, + RetailAppraisalNameResolver names) { if (properties.GetBool(85u)) { @@ -585,24 +670,30 @@ public static class ItemAppraisalTextFormatter // requirement triplets. if (properties.Ints.TryGetValue(324u, out int heritage)) { - string? heritageName = - CharacterIdentityText.HeritageGroupDisplayName(heritage); - if (heritageName is not null) + string heritageName = names.ResolveHeritage(heritage); + if (!string.IsNullOrEmpty(heritageName)) report.Line($"Wield requires {heritageName}"); } - foreach ((uint requirementId, uint statId, uint difficultyId) - in WieldRequirements) + 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); + string quality = RequirementQuality( + requirement, + stat, + difficulty, + names); if (requirement == 8) { - string training = difficulty == 3 ? "specialized" : "trained"; + string training = difficulty == 3 + ? index == 3 ? "Specialized" : "specialized" + : index == 3 ? "Trained" : "trained"; report.Line($"Wield requires {training} {quality}"); } else if (requirement == 11) @@ -625,7 +716,8 @@ public static class ItemAppraisalTextFormatter private static string RequirementQuality( int requirement, int stat, - int difficulty) + int difficulty, + RetailAppraisalNameResolver names) { string basePrefix = requirement is 2 or 4 or 6 ? "base " : string.Empty; return requirement switch @@ -641,9 +733,8 @@ public static class ItemAppraisalTextFormatter 0x121 => "Standing with the Radiant Blood", _ => "unknown quality", }, - 11 => $"creature {difficulty.ToString(CultureInfo.CurrentCulture)}", - 12 => CharacterIdentityText.HeritageGroupDisplayName(difficulty) - ?? string.Empty, + 11 => names.ResolveCreature(difficulty), + 12 => names.ResolveHeritage(difficulty), _ => string.Empty, }; } @@ -661,12 +752,12 @@ public static class ItemAppraisalTextFormatter int difficulty = properties.GetInt(367u); if (skill > 0 && difficulty > 0) report.Line( - $"Use requires {SkillName(skill)} of at least " + $"Use requires {UsageSkillName(skill)} of at least " + $"{difficulty.ToString(CultureInfo.CurrentCulture)}."); int specializedSkill = properties.GetInt(368u); if (specializedSkill > 0) report.Line( - $"Use requires specialized {SkillName(specializedSkill)}."); + $"Use requires specialized {UsageSkillName(specializedSkill)}."); } /// Appraisal_ShowItemLevel @ 0x004B3C60. @@ -674,18 +765,41 @@ public static class ItemAppraisalTextFormatter RetailReportBuilder report, PropertyBundle properties) { - int level = properties.GetInt(25u); - int maximum = properties.GetInt(319u); - if (maximum > 0) - report.Line( - $"Item Level: {level.ToString(CultureInfo.CurrentCulture)} / " - + $"{maximum.ToString(CultureInfo.CurrentCulture)}"); - long experience = properties.GetInt64(4u); - long baseExperience = properties.GetInt64(5u); - if (experience != 0 || baseExperience != 0) - report.Line( - $"Item XP: {experience.ToString("N0", CultureInfo.CurrentCulture)} / " - + $"{baseExperience.ToString("N0", CultureInfo.CurrentCulture)}"); + 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)}"); + + if (properties.GetInt(352u) == 2) + { + report.Paragraph( + "This cloak has a chance to reduce an incoming attack by 200 damage."); + } } /// @@ -693,17 +807,29 @@ public static class ItemAppraisalTextFormatter /// private static void ShowActivationRequirements( RetailReportBuilder report, - PropertyBundle properties) + PropertyBundle properties, + bool appraisalSucceeded, + RetailAppraisalNameResolver names) { + if (!appraisalSucceeded) + return; + var requirements = new List(); 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( - $"{SkillName(skill)}: {skillLevel.ToString(CultureInfo.CurrentCulture)}"); + $"{UsageSkillName(skill)}: {skillLevel.ToString(CultureInfo.CurrentCulture)}"); int attributeLevel = properties.GetInt(258u); int attribute = properties.GetInt(257u); if (attributeLevel > 0 && attribute > 0) @@ -773,25 +899,42 @@ public static class ItemAppraisalTextFormatter /// private static void ShowBoostAndHealing( RetailReportBuilder report, - PropertyBundle properties) + 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? vital = properties.GetInt(89u) switch + string? boostText = properties.GetInt(89u) switch { - 2 => "Health", - 4 => "Stamina", - 6 => "Mana", + 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 (boost != 0 && vital is not null) - report.Paragraph( - $"{(boost > 0 ? "Restores" : "Depletes")} " - + $"{Math.Abs(boost).ToString(CultureInfo.CurrentCulture)} " - + $"{vital} when used."); + if (!healer && boostText is not null + && properties.Ints.ContainsKey(90u)) + { + report.Paragraph(boostText); + } - if (boost != 0 && properties.Floats.ContainsKey(100u)) + if (!healer) + return; + + if (properties.Ints.TryGetValue(90u, out int healingBonus)) report.Paragraph( - $"Bonus to Healing Skill: {boost.ToString(CultureInfo.CurrentCulture)}"); + $"Bonus to Healing Skill: {healingBonus.ToString(CultureInfo.CurrentCulture)}"); if (properties.Floats.TryGetValue(100u, out double healKitModifier)) report.Line( $"Restoration Bonus: " @@ -914,15 +1057,23 @@ public static class ItemAppraisalTextFormatter return; } - int uses = obj.Structure != 0 ? obj.Structure : properties.GetInt(92u); - if (uses != 0) + if (properties.Ints.TryGetValue(92u, out int uses)) + { report.Line( $"Number of uses remaining: {uses.ToString(CultureInfo.CurrentCulture)}"); - else if (!appraisal.Success - && (obj.MaxStructure != 0 - || appraisal.HookProfile is { Flags: var flags } - && (flags & 0xAu) != 0)) + 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( @@ -942,6 +1093,13 @@ public static class ItemAppraisalTextFormatter { 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)}"); @@ -953,8 +1111,13 @@ public static class ItemAppraisalTextFormatter AppraiseInfoParser.Parsed appraisal, Func resolveSpell) { - if (appraisal.SpellBook.Length == 0 || !appraisal.Success) + 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)>(); @@ -1023,27 +1186,373 @@ public static class ItemAppraisalTextFormatter string name = !string.IsNullOrWhiteSpace(metadata?.Name) ? metadata.Name : $"Spell {id.ToString(CultureInfo.CurrentCulture)}"; - text.Append('\n').Append(name); + text.Append("\n~ ").Append(name).Append(": "); if (!string.IsNullOrWhiteSpace(metadata?.Description)) - text.Append('\n').Append(metadata.Description); + text.Append(metadata.Description); } return text.ToString(); } private static void ShowDescription( RetailReportBuilder report, - PropertyBundle properties) + PropertyBundle properties, + RetailAppraisalNameResolver names) { - string shortDescription = properties.GetString(15u); - if (!string.IsNullOrWhiteSpace(shortDescription)) - report.Paragraph(shortDescription); + 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.Equals( - longDescription, - shortDescription, - StringComparison.Ordinal)) - report.Paragraph(longDescription); + 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(); + 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; + } + + /// ClothingPriorityToString @ 0x005B4F30. + private static string ClothingCoverage(uint priority) + { + var coverage = new List(); + 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); + } + + /// ExperienceSystem::ItemLevelToTotalXP @ 0x005C8100. + 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, + }; + } + + /// ExperienceSystem::ItemTotalXPToLevel @ 0x005C81B0. + 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(); + } + + /// AppraisalSystem::InqPluralizedGemName @ 0x005B5800. + 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); + } + + /// ItemExamineUI::Appraisal_ShowSet @ 0x004AE880. + 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 @@ -1144,21 +1653,10 @@ public static class ItemAppraisalTextFormatter : ItemAppraisalFontStyle.Detrimental; } - private static string DamageTypeName(uint type) => type switch - { - 1u => "Slashing", - 2u => "Piercing", - 4u => "Bludgeoning", - 8u => "Cold", - 16u => "Fire", - 32u => "Acid", - 64u => "Electric", - 128u => "Health", - 256u => "Stamina", - 512u => "Mana", - 1024u => "Nether", - _ => $"type {type.ToString(CultureInfo.CurrentCulture)}", - }; + 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 { @@ -1234,6 +1732,14 @@ public static class ItemAppraisalTextFormatter _ => $"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", diff --git a/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs b/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs new file mode 100644 index 00000000..653711dd --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs @@ -0,0 +1,91 @@ +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.UI.Layout; + +/// +/// Resolves the authored enum display names consumed by retail's appraisal +/// helpers. Material names follow +/// MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500; +/// creature and heritage names follow +/// AppraisalSystem::InqCreatureDisplayName @ 0x005B59E0 and +/// InqHeritageGroupDisplayName @ 0x005B4710. +/// +public sealed class RetailAppraisalNameResolver +{ + private const uint MaterialClientEnum = 0x10000001u; + private const uint MaterialSubEnum = 1u; + + public static RetailAppraisalNameResolver Empty { get; } = new( + new Dictionary(), + new CreatureDisplayNameResolver(new Dictionary())); + + private readonly IReadOnlyDictionary _materials; + private readonly CreatureDisplayNameResolver _creatures; + + public RetailAppraisalNameResolver( + IReadOnlyDictionary materials, + CreatureDisplayNameResolver creatures) + { + _materials = materials + ?? throw new ArgumentNullException(nameof(materials)); + _creatures = creatures + ?? throw new ArgumentNullException(nameof(creatures)); + } + + public static RetailAppraisalNameResolver Load( + IDatReaderWriter dats, + CreatureDisplayNameResolver creatures) + { + ArgumentNullException.ThrowIfNull(dats); + ArgumentNullException.ThrowIfNull(creatures); + + var materials = new Dictionary(); + uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId; + EnumIDMap? master = masterDid == 0u + ? null + : dats.Get(masterDid); + if (master is not null + && master.ClientEnumToID.TryGetValue( + MaterialClientEnum, + out uint materialRootMapDid) + && dats.Get(materialRootMapDid) is { } materialRootMap + && materialRootMap.ClientEnumToID.TryGetValue( + MaterialSubEnum, + out uint materialMapDid) + && dats.Get(materialMapDid) is { } materialMap) + { + foreach ((uint id, PStringBase text) + in materialMap.ClientEnumToName) + { + materials.TryAdd(id, Normalize(text.Value)); + } + } + + return new RetailAppraisalNameResolver(materials, creatures); + } + + public string ResolveCreature(int creatureType) + => _creatures.Resolve(creatureType); + + public string ResolveHeritage(int heritageGroup) + => heritageGroup switch + { + 2 => "Gharu'ndim", + 5 => "Umbraen", + 13 => "Olthoi", + _ => CharacterIdentityText.HeritageGroupDisplayName(heritageGroup) + ?? string.Empty, + }; + + public string ResolveMaterial(int materialType) + => materialType > 0 + && _materials.TryGetValue((uint)materialType, out string? name) + ? name + : string.Empty; + + private static string Normalize(string value) + => value.Replace('_', ' '); +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index aa614ef4..c6f7cd99 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -966,6 +966,7 @@ public sealed class RetailUiRuntime : IDisposable ImportedLayout? layout; CreatureAppraisalRowTemplateFactory? creatureRows; CreatureDisplayNameResolver? creatureNames; + RetailAppraisalNameResolver? itemNames; lock (_bindings.Assets.DatLock) { layout = LayoutImporter.Import( @@ -982,6 +983,9 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.ResolveFont); creatureNames = CreatureDisplayNameResolver.Load( _bindings.Assets.Dats); + itemNames = RetailAppraisalNameResolver.Load( + _bindings.Assets.Dats, + creatureNames); } if (layout is null) { @@ -1003,7 +1007,8 @@ public sealed class RetailUiRuntime : IDisposable show: () => Host.ShowWindow(WindowNames.Examination), close: () => CloseWindow(WindowNames.Examination), creatureRowTemplates: creatureRows, - creatureNames: creatureNames); + creatureNames: creatureNames, + itemNames: itemNames); if (controller is null) { Console.WriteLine( diff --git a/src/AcDream.Core/Items/ItemInteractionPolicy.cs b/src/AcDream.Core/Items/ItemInteractionPolicy.cs index bf4c02be..4de5cd70 100644 --- a/src/AcDream.Core/Items/ItemInteractionPolicy.cs +++ b/src/AcDream.Core/Items/ItemInteractionPolicy.cs @@ -23,6 +23,8 @@ public enum PublicWeenieFlags : uint NonPlayerKillerSwitch = 0x00000800, Door = 0x00001000, Corpse = 0x00002000, + Healer = 0x00010000, + Lockpick = 0x00020000, RequiresPackSlot = 0x00800000, VolatileRare = 0x10000000, WieldOnUse = 0x20000000, diff --git a/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs b/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs index 8267ca3a..33fa589b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs @@ -27,6 +27,7 @@ public sealed class ItemAppraisalTextFormatterTests properties.Ints[19u] = 12_500; properties.Ints[5u] = 450; properties.Ints[204u] = 4; + properties.Ints[105u] = 8; properties.Ints[106u] = 300; properties.Ints[107u] = 250; properties.Ints[108u] = 500; @@ -63,21 +64,24 @@ public sealed class ItemAppraisalTextFormatterTests }); Assert.Contains("Value: 12,500", report); - Assert.Contains("Workmanship: Utterly flawless (8.4)", report); + Assert.Contains("Workmanship: Utterly flawless (8)", 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("Spells: Flame Bolt VI", report); + Assert.DoesNotContain("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("~ Flame Bolt VI: Hurls a bolt of flame.", report); Assert.Contains("Enchantments:", report); - Assert.Contains("Impenetrability VI\nIncreases the target's armor.", report); + Assert.Contains( + "~ Impenetrability VI: Increases the target's armor.", + report); Assert.True( report.IndexOf("Skill:", StringComparison.Ordinal) < report.IndexOf("Spells:", StringComparison.Ordinal)); @@ -353,6 +357,7 @@ public sealed class ItemAppraisalTextFormatterTests properties.Ints[324u] = 4; properties.Ints[369u] = 50; properties.Ints[370u] = 3; + properties.Ints[92u] = 8; properties.Bools[69u] = false; properties.Strings[25u] = "Tester"; AppraiseInfoParser.Parsed appraisal = Parsed( @@ -372,7 +377,7 @@ public sealed class ItemAppraisalTextFormatterTests appraisal, _ => null); - Assert.Contains("Ratings: Dam +3", report); + 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); @@ -461,17 +466,260 @@ public sealed class ItemAppraisalTextFormatterTests Assert.Contains("This item cannot be further imbued.", report); } + [Fact] + public void SpecializedItemBranches_FollowRetailNamesWordingAndOrder() + { + var obj = new ClientObject + { + ObjectId = 0x50000020u, + Name = "Ruby Coat", + Type = ItemType.Clothing, + ValidLocations = EquipMask.ChestWear, + Priority = 0x0008u | 0x0010u | 0x0020u | 0x0040u, + }; + var properties = new PropertyBundle(); + properties.Ints[171u] = 2; + properties.Strings[39u] = "Alyssa"; + properties.Strings[40u] = "Borelean"; + properties.Ints[105u] = 10; + properties.Ints[170u] = 4; + properties.Ints[265u] = 13; + properties.Ints[370u] = 3; + properties.Ints[371u] = -4; + properties.Ints[379u] = 2; + properties.Ints[86u] = 50; + properties.Ints[87u] = 50; + properties.Int64s[4u] = 300; + properties.Int64s[5u] = 100; + properties.Ints[319u] = 10; + properties.Ints[320u] = 3; + properties.Ints[109u] = 250; + properties.Ints[188u] = 4; + properties.Bools[108u] = true; + properties.Ints[17u] = 42; + properties.Ints[267u] = 1; + properties.Ints[98u] = 1; + properties.Ints[268u] = 90_061; + properties.Strings[16u] = "Ruby sword"; + properties.Ints[172u] = 5; + properties.Ints[131u] = 0x26; + properties.Ints[177u] = 2; + properties.Ints[178u] = 0x26; + properties.Ints[111u] = 2 | 0x20 | 0x10; + + string report = ItemAppraisalTextFormatter.Build( + obj, + Parsed(properties), + _ => null, + Names( + materials: new Dictionary { [0x26u] = "Ruby" })); + + Assert.Contains("This item has been tinkered 2 times.", report); + Assert.Contains("Last tinkered by Alyssa.", report); + Assert.Contains("Imbued by Borelean.", report); + Assert.Contains("Workmanship: Well-crafted (2.50)", report); + Assert.Contains("Salvaged from 4 items.", report); + Assert.Contains("Set: Soldier's", report); + Assert.Contains("Ratings: Dam 3", report); + Assert.DoesNotContain("Dam Resist", report); + Assert.Contains("This item adds 2 Vitality.", report); + Assert.Contains( + "Covers Chest, Abdomen, Upper Arms, Lower Arms", + report); + Assert.Contains("Restricted to characters of Level 50.", report); + Assert.Contains("Item Level: 3 / 10", report); + Assert.Contains("Item XP: 300 / 600", report); + Assert.Contains("Activation requires Arcane Lore: 250, Viamontian", report); + Assert.Contains( + "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.", + report); + Assert.Contains("Rare #42", report); + Assert.Contains( + "This item expires in 1 days, 1 hours, 1 minutes, 1 seconds.", + report); + Assert.Contains("Priceless Ruby sword, set with 2 Rubies", report); + Assert.Contains("Player Killers may not use this portal.", report); + Assert.Contains("This portal cannot be recalled nor linked to.", report); + Assert.Contains("This portal cannot be summoned.", report); + Assert.True( + report.IndexOf("Set: Soldier's", StringComparison.Ordinal) + < report.IndexOf("Covers Chest", StringComparison.Ordinal)); + Assert.True( + report.IndexOf("Rare #42", StringComparison.Ordinal) + < report.IndexOf("This item expires", StringComparison.Ordinal)); + } + + [Fact] + public void SlayerAndWieldCreatureRequirements_UseRetailEnumNames() + { + var properties = new PropertyBundle(); + properties.Ints[166u] = 10; + properties.Ints[158u] = 11; + properties.Ints[159u] = 0; + properties.Ints[160u] = 10; + + string report = ItemAppraisalTextFormatter.Build( + new ClientObject + { + ObjectId = 0x50000021u, + Name = "Drudge Slayer", + Type = ItemType.MeleeWeapon, + }, + Parsed(properties), + _ => null, + Names(creatures: new Dictionary { [10u] = "Drudge" })); + + Assert.Contains("Properties: Drudge slayer", report); + Assert.Contains("Wield requires Drudge type", report); + Assert.DoesNotContain("Creature type 10", report); + Assert.DoesNotContain("creature 10", report); + } + + [Fact] + public void HealerProfile_UsesHealingRowsInsteadOfConsumableBoostProse() + { + var properties = new PropertyBundle(); + properties.Ints[89u] = 2; + properties.Ints[90u] = 12; + properties.Floats[100u] = 1.25d; + var obj = new ClientObject + { + ObjectId = 0x50000022u, + Name = "Healing Kit", + Type = ItemType.Misc, + PublicWeenieBitfield = (uint)PublicWeenieFlags.Healer, + }; + + string report = ItemAppraisalTextFormatter.Build( + obj, + Parsed(properties), + _ => null); + + Assert.Contains("Bonus to Healing Skill: 12", report); + Assert.Contains("Restoration Bonus: 125%", report); + Assert.DoesNotContain("Restores 12 Health when used.", report); + } + + [Fact] + public void FailedMagicAndLauncher_PreserveRetailUnknownRows() + { + var obj = new ClientObject + { + ObjectId = 0x50000023u, + Name = "Unknown Bow", + Type = ItemType.MissileWeapon, + ValidLocations = EquipMask.MissileWeapon, + AmmoType = 1, + }; + AppraiseInfoParser.Parsed appraisal = Parsed( + new PropertyBundle(), + spells: [101u], + weapon: new AppraiseInfoParser.WeaponProfile( + DamageType: 2u, + WeaponTime: uint.MaxValue, + WeaponSkill: 47u, + Damage: uint.MaxValue, + DamageVariance: 0d, + DamageMod: 1d, + WeaponLength: 1d, + MaxVelocity: 0d, + WeaponOffense: 1d, + MaxVelocityEstimated: 0u), + success: false); + + string report = ItemAppraisalTextFormatter.Build( + obj, + appraisal, + _ => null); + + Assert.Equal(2, Count(report, "Spells: unknown.")); + Assert.Contains("Damage Bonus: Unknown", report); + Assert.Contains("Damage Modifier: Unknown", report); + Assert.Contains("Speed: Unknown", report); + Assert.Contains("Range: Unknown", report); + } + + [Fact] + public void OrdinaryStaminaConsumable_UsesRetailConsumedWording() + { + var properties = new PropertyBundle(); + properties.Ints[89u] = 4; + properties.Ints[90u] = -20; + + string report = ItemAppraisalTextFormatter.Build( + new ClientObject + { + ObjectId = 0x50000024u, + Name = "Stamina Food", + Type = ItemType.Food, + }, + Parsed(properties), + _ => null); + + Assert.Contains("Depletes 20 Stamina when consumed.", report); + } + + [Theory] + [InlineData(1, 250L, "Item Level: 3 / 10", "Item XP: 250 / 300")] + [InlineData(2, 300L, "Item Level: 3 / 10", "Item XP: 300 / 700")] + public void ItemExperienceStyles_UseRetailCumulativeCurves( + int style, + long experience, + string expectedLevel, + string expectedExperience) + { + var properties = new PropertyBundle(); + properties.Int64s[4u] = experience; + properties.Int64s[5u] = 100L; + properties.Ints[319u] = 10; + properties.Ints[320u] = style; + + string report = ItemAppraisalTextFormatter.Build( + new ClientObject + { + ObjectId = 0x50000025u, + Name = "Leveling Item", + Type = ItemType.Misc, + }, + Parsed(properties), + _ => null); + + Assert.Contains(expectedLevel, report); + Assert.Contains(expectedExperience, report); + } + + [Fact] + public void EndOfRetailDatNameResolver_LoadsMaterialAndCreatureNames() + { + string? datDirectory = ResolveDatDirectory(); + if (datDirectory is null) + return; + + using var dats = + new AcDream.App.Tests.BoundedTestDatCollection(datDirectory); + CreatureDisplayNameResolver creatures = + CreatureDisplayNameResolver.Load(dats); + RetailAppraisalNameResolver names = + RetailAppraisalNameResolver.Load(dats, creatures); + + Assert.Equal("Ruby", names.ResolveMaterial(0x26)); + Assert.Equal("Ghost", names.ResolveCreature(77)); + } + private static AppraiseInfoParser.Parsed Parsed( PropertyBundle properties, uint[]? spells = null, AppraiseInfoParser.WeaponProfile? weapon = null, AppraiseInfoParser.ArmorProfile? armor = null, AppraiseInfoParser.HookProfile? hook = null, - (ushort Highlight, ushort Color)? weaponEnchantments = null) + (ushort Highlight, ushort Color)? weaponEnchantments = null, + bool success = true) => new( Guid: 0x50000001u, Flags: AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable, - Success: true, + Success: success, Properties: properties, SpellBook: spells ?? [], ArmorProfile: armor, @@ -511,4 +759,44 @@ public sealed class ItemAppraisalTextFormatterTests TargetEffect: 0u, TargetMask: 0u, SpellType: 0); + + private static RetailAppraisalNameResolver Names( + IReadOnlyDictionary? materials = null, + IReadOnlyDictionary? creatures = null) + => new( + materials ?? new Dictionary(), + new CreatureDisplayNameResolver( + creatures ?? new Dictionary())); + + private static int Count(string source, string value) + { + int count = 0; + int start = 0; + while ((start = source.IndexOf( + value, + start, + StringComparison.Ordinal)) >= 0) + { + count++; + start += value.Length; + } + return count; + } + + private static string? ResolveDatDirectory() + { + string? configured = + Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(configured) + && Directory.Exists(configured)) + { + return configured; + } + + string fallback = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + return Directory.Exists(fallback) ? fallback : null; + } }