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; + } }