diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b246bceb..ef6e9eac 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -107,7 +107,21 @@ text out from the top, and replaces the sparse property dump with retail's ordered item-report branches for common weapons, armor, magic and DAT spell descriptions, requirements, capacities, cooldown/special properties, uses, crafting, ratings, rare state, and prose. AP-110 records the remaining -specialized/player-dependent/DAT-name/font/object-preview branches explicitly. +specialized/player-dependent/DAT-name/creature-font/object-preview branches +explicitly. + +The item-format conformance correction then fixed the live Black Phyntos Hive +counterexample at its source. CreateObject had walked past +`PublicWeenieDesc.HookItemTypes` and `HookType` without retaining them, so the +item formatter could not execute retail `ACCWeenieObject::IsHook`. It also +fell back from a deliberately incomplete appraisal to public Value/Burden and +printed the hook's 255/255 sentinel capacities as ordinary storage. Hook +identity now survives the wire/session/object-table path; Value/Burden use +only the appraisal profile and display `???`/`Unknown` when absent; +`HasHookedItem` suppresses the hook capacity; lock wording and page-count +property order match retail; and each report fragment preserves +`AddItemInfo`'s line/paragraph flag plus LayoutDesc `0x1B`'s white/green/red +font-color indices. **Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`; @@ -129,6 +143,9 @@ animated target remains above row chrome but below text; changing selection updates the open window without another magnifier click. Item text begins at the top and assessed melee/missile/armor/magic items show their retail-ordered stats and full DAT spell descriptions. +The Black Phyntos Hive specifically reads `Value: ???`, `Burden: Unknown`, +then its description after one retail paragraph break, without the bogus +255-item/255-container line. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index d515e194..456170e7 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. It still lacks item-object preview, player-dependent effective shield projection, DAT display-name resolution for equipment sets and slayer creature types, some expiry/material/portal/PK-specialized description branches, exhaustive character detail regions, and exact appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the remaining examination mechanisms require player-state or DAT display-name resolvers and object-preview ownership rather than fabricated content | Uncommon item/character assessments may omit the listed specialized branches and item-object presentation; unresolved equipment-set/slayer values use their numeric identity; enchanted/incomplete appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | +| AP-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-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-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index 92035df0..70b22671 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -144,7 +144,11 @@ damage/critical/resistance rating list, places authored row chrome behind the animated preview and text in front, adds the balanced row inset, and follows the current selection automatically while the examination window is visible. Item-object preview, specialized item/character detail regions, and exact -appraisal font-state selection remain the narrowed AP-110 residual. Research: +creature appraisal font-state selection remain the narrowed AP-110 residual. +The item report now retains `PublicWeenieDesc` hook identity, applies +appraisal-only Value/Burden unknowns, suppresses mounted-hook sentinel +capacities, preserves retail line/paragraph boundaries, and selects the +authored white/green/red item font-color entries. Research: [`../research/2026-07-23-retail-appraisal-ui-pseudocode.md`](../research/2026-07-23-retail-appraisal-ui-pseudocode.md). The focused parser/router/request/controller/fixture tests pass, as do the Release solution build with 17 pre-existing tracked test warnings, 3,496 App @@ -172,14 +176,26 @@ ports common weapon damage ranges/speed/range/ammunition, armor protection bands, defense/caster modifiers, workmanship, ratings, wield/use/activation requirements, item XP/capacity/lock/mana/uses/creator/rare data, cooldown and imbued special properties, and both short spell lists and full DAT spell -descriptions. AP-110 now names only the remaining specialized, -player-dependent, DAT-display-name, font-state, and object-preview gaps. +descriptions. The conformance follow-up replaces public-value fallbacks with +retail's appraisal presence semantics, restores hook/capacity/lock behavior, +and carries each `AddItemInfo` fragment's paragraph and font-color index into +the retained text shaper. AP-110 now names only the remaining specialized, +player-dependent, DAT-display-name, creature-font-state, and object-preview gaps. Focused conformance fixtures cover the geometry layering plus melee, launcher, armor, spell, and special-property reports. The Release solution build passes with the 17 warnings already tracked by #228, 3,514 App tests / 3 skips, and 7,931 complete-solution tests / 5 skips. The connected visual result remains the closeout gate. +The item-format conformance correction adds literal hook-tail cursor fixtures, +wire/session/object-table propagation tests, the exact Black Phyntos Hive +report, capacity/page/lock presence cases, structured paragraph checks, and +LayoutDesc color-palette/enchantment-style coverage. The isolated Release +solution build passes with the same 17 tracked warnings; 3,522 App tests / 3 +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. + ## 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 4438d502..59eeca37 100644 --- a/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md +++ b/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md @@ -23,6 +23,16 @@ retained examination window. - `Attribute2ndInfoRegion::Update @ 0x004F1E80` - `AppraisalSystem::InqCreatureDisplayName @ 0x005B59E0` - `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0` + - `ItemExamineUI::AddItemInfo @ 0x004AC050/0x004ADCA0` + - `ItemExamineUI::Appraisal_ShowValueInfo @ 0x004ADEA0` + - `ItemExamineUI::Appraisal_ShowBurdenInfo @ 0x004AE180` + - `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680` + - `ItemExamineUI::Appraisal_ShowLockAppraiseInfo @ 0x004B2790` + - `AppraisalProfile::HasHookedItem @ 0x00525C70` + - `AppraisalProfile::InqIntEnchantmentMod @ 0x005B2C10` + - `AppraisalProfile::InqFloatEnchantmentMod @ 0x005B2C70` + - `AppraisalSystem::LockpickSuccessPercentToString @ 0x005B4600` + - `ACCWeenieObject::IsHook @ 0x0058C660` - `ItemExamineUI::HandleInscriptionGainingFocus @ 0x004AC0E0` - `ItemExamineUI::HandleInscriptionMousePresses @ 0x004AC230` - `ItemExamineUI::SetInscriptionEditableState @ 0x004AC720` @@ -333,6 +343,57 @@ The individual branches are not a generic property dump. They project the wire profile into retail prose: ```text +AddItemInfo(text, fontColorIndex, sameParagraph): + if the report already contains text: + append sameParagraph ? "\n" : "\n\n" + append text with font-list index 0 and the requested color-list index + +ShowValueAndBurden(profile): + if profile contains int 19: + append "Value: " + else: + append "Value: ???" + + if profile contains int 5: + append "Burden: " + else: + append "Burden: Unknown" + +IsHook(object): + return PublicWeenieDesc.HookType != 0 + and PublicWeenieDesc.HookItemTypes != TYPE_UNDEF + +ShowCapacity(object, profile): + if object is not a hook or profile does not contain a HookProfile: + append authored item/container capacity as a new paragraph + if profile contains PageCount (175) and PageUsed (174): + append " of pages full." as a new paragraph + + # A hook with a HookProfile describes its mounted item. Its sentinel + # 255/255 PublicWeenieDesc capacities are not player-facing capacity. + +ShowLock(object, profile): + if object is a hook: + return + if Locked bool is absent: + append nonzero LockpickResistance as + "Bonus to Lockpick Skill: " + return + append "Unlocked" and return when Locked is false + append "Locked" + if LockpickResistance is absent: + append "You can't tell how hard the lock is to pick." + else if LockpickSuccessPercent is present: + map it through LockpickSuccessPercentToString + append "The lock looks to pick (Resistance )." + +FontColor(profileBitfield, lowBit): + if lowBit is absent: + return normal color index 0 + if the corresponding bit 16 positions higher is present: + return beneficial color index 1 + return detrimental color index 2 + ShowWeaponAndArmor(validLocations, profile): ammoType = live PublicWeenieDesc ammo type if object is a hook: @@ -470,6 +531,10 @@ The main text and inscription areas use their authored scrollbars. A newly selected object resets those scroll positions to the top; a refresh of the same object preserves the user's position. +LayoutDesc `0x2100006B` element `0x1000013C` authors the item report's +font-color list as white, green, and red. Those are the concrete colors selected +by the `AddItemInfo` indices above; they are not hard-coded by appraisal logic. + ## Window behavior The LayoutDesc root already contains retail's complete floaty window chrome, @@ -603,7 +668,10 @@ 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. Retail's item-object preview, exhaustive -specialized `ItemExamineUI` detail branches, exact appraisal FontInfo-list +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. diff --git a/docs/research/deepdives/r06-items-inventory.md b/docs/research/deepdives/r06-items-inventory.md index 4ff46df1..74c7a6ba 100644 --- a/docs/research/deepdives/r06-items-inventory.md +++ b/docs/research/deepdives/r06-items-inventory.md @@ -437,7 +437,7 @@ Per-flag (in exact order): 0x00400000 SpellId : uint16 0x02000000 OwnerId : uint32 0x04000000 Restrictions : RestrictionDB (house perms) - 0x20000000 HookItemTypes : uint16 (HookType) + 0x20000000 HookItemTypes : uint32 (ITEM_TYPE mask) 0x00000040 MonarchId : uint32 0x10000000 HookType : uint16 0x40000000 IconOverlay : packedDWORD (minus 0x06000000) diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index 3b0cd166..880f89d8 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -68,7 +68,7 @@ public sealed class AppraisalUiController : IRetainedPanelController private uint _creatureObjectId; private uint _characterObjectId; private string _titleValue = string.Empty; - private string _itemReport = string.Empty; + private ItemAppraisalReport _itemReport = ItemAppraisalReport.Empty; private string _inscriptionValue = string.Empty; private string _signatureValue = string.Empty; private string _scribeName = string.Empty; @@ -131,7 +131,7 @@ public sealed class AppraisalUiController : IRetainedPanelController // LayoutDesc 0x2100006B carries Bottom here, which is appropriate for // static text but not for the generated retail item list. _itemText.VerticalJustify = VJustify.Top; - ConfigureScrollableText(_itemText, ItemScrollbarId, () => _itemReport); + ConfigureScrollableItemText(_itemText, ItemScrollbarId); if (_inscriptionText is not null) ConfigureScrollableText( _inscriptionText, @@ -311,7 +311,7 @@ public sealed class AppraisalUiController : IRetainedPanelController public void ResetSession() { _titleValue = string.Empty; - _itemReport = string.Empty; + _itemReport = ItemAppraisalReport.Empty; _inscriptionValue = string.Empty; _inscriptionField?.SetText(string.Empty); if (_inscriptionField is not null) @@ -340,7 +340,7 @@ public sealed class AppraisalUiController : IRetainedPanelController AppraiseInfoParser.Parsed appraisal, bool newlySelected) { - _itemReport = ItemAppraisalTextFormatter.Build( + _itemReport = ItemAppraisalTextFormatter.BuildReport( obj, appraisal, ResolveSpell); @@ -370,8 +370,9 @@ public sealed class AppraisalUiController : IRetainedPanelController (PublicWeenieFlags)(obj.PublicWeenieBitfield ?? 0u); bool publicInscribable = (publicFlags & PublicWeenieFlags.Inscribable) != 0; - _presentationInscribable = appraisal.HookProfile is { } hook - ? (hook.Flags & 0x1u) != 0 + _presentationInscribable = obj.IsHook + ? appraisal.HookProfile is { } hook + && (hook.Flags & 0x1u) != 0 : publicInscribable; if (!_presentationInscribable) @@ -559,6 +560,19 @@ public sealed class AppraisalUiController : IRetainedPanelController scrollbar.Model = text.Scroll; } + private void ConfigureScrollableItemText( + UiText text, + uint scrollbarId) + { + text.PreserveEndOnLayout = false; + text.WheelScrollEnabled = true; + text.ClickThrough = false; + text.LinesProvider = () => + ItemAppraisalTextLayout.Shape(text, _itemReport); + if (_layout.FindElement(scrollbarId) is UiScrollbar scrollbar) + scrollbar.Model = text.Scroll; + } + private void SetText(uint elementId, string value, bool scrollable = false) { if (_layout.FindElement(elementId) is not UiText text) diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index ea25621e..2f28bb53 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -572,6 +572,9 @@ public static class DatWidgetFactory // it is the shared global fallback. Controllers that call FindElement and explicitly // set DatFont afterward STILL override this (backward-compat guarantee). DatFont = elementFont, + FontColorPalette = ElementReader.ReadEffectiveColorPalette( + info, + 0x1Bu), }; t.ConfigureDatState(info); diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index 10f6e974..b4976b42 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -385,4 +385,38 @@ public static class ElementReader } } } + + /// + /// Resolves an authored color or color-array property after the retail + /// DirectState/default-state inheritance rules have been applied. + /// + internal static Vector4[] ReadEffectiveColorPalette( + ElementInfo info, + uint propertyId) + { + ArgumentNullException.ThrowIfNull(info); + if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value)) + return []; + + IEnumerable entries = value.Kind switch + { + UiPropertyKind.Color => [value], + UiPropertyKind.Array => value.ArrayValue, + _ => [], + }; + + return entries + .Where(entry => entry.Kind == UiPropertyKind.Color) + .Select(entry => + { + UiColorValue color = entry.ColorValue; + float alpha = color.Alpha == 0 ? 1f : color.Alpha / 255f; + return new Vector4( + color.Red / 255f, + color.Green / 255f, + color.Blue / 255f, + alpha); + }) + .ToArray(); + } } diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs b/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs new file mode 100644 index 00000000..71a4ee3f --- /dev/null +++ b/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs @@ -0,0 +1,177 @@ +using System.Numerics; +using System.Text; + +namespace AcDream.App.UI.Layout; + +/// +/// Font-color indices selected by retail ItemExamineUI. The concrete +/// colors come from the item report's LayoutDesc property 0x1B. +/// +public enum ItemAppraisalFontStyle +{ + Normal = 0, + Beneficial = 1, + Detrimental = 2, +} + +/// +/// Separator inserted before an appraisal fragment. This is the retained +/// equivalent of ItemExamineUI::AddItemInfo's final argument. +/// +public enum ItemAppraisalSeparator +{ + None, + Line, + Paragraph, +} + +/// +/// One retail appraisal append operation: prose, separator, and authored +/// font-color index. Keeping these separate until shaping preserves style +/// when a long fragment wraps. +/// +public readonly record struct ItemAppraisalFragment( + string Text, + ItemAppraisalSeparator Separator, + ItemAppraisalFontStyle Style); + +/// +/// Immutable item appraisal report in retail append order. +/// +public sealed class ItemAppraisalReport +{ + public static ItemAppraisalReport Empty { get; } = new([]); + + public ItemAppraisalReport(IReadOnlyList fragments) + { + ArgumentNullException.ThrowIfNull(fragments); + Fragments = fragments; + } + + public IReadOnlyList Fragments { get; } + public bool IsEmpty => Fragments.Count == 0; + + public override string ToString() + { + var text = new StringBuilder(); + foreach (ItemAppraisalFragment fragment in Fragments) + { + if (text.Length != 0) + { + text.Append(fragment.Separator == ItemAppraisalSeparator.Paragraph + ? "\n\n" + : "\n"); + } + text.Append(fragment.Text); + } + return text.ToString(); + } +} + +/// +/// Port of ItemExamineUI::AddItemInfo @ 0x004AC050/0x004ADCA0. +/// Retail appends one newline when is true, +/// two otherwise, then applies font DID index zero and the supplied color index. +/// +internal sealed class ItemAppraisalReportBuilder +{ + private readonly List _fragments = []; + + public void Line( + string value, + ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal) + => Add(value, sameParagraph: true, style); + + public void Paragraph( + string value, + ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal) + => Add(value, sameParagraph: false, style); + + public ItemAppraisalReport Build() + => _fragments.Count == 0 + ? ItemAppraisalReport.Empty + : new ItemAppraisalReport(_fragments.ToArray()); + + private void Add( + string value, + bool sameParagraph, + ItemAppraisalFontStyle style) + { + if (string.IsNullOrWhiteSpace(value)) + return; + + _fragments.Add(new ItemAppraisalFragment( + value, + _fragments.Count == 0 + ? ItemAppraisalSeparator.None + : sameParagraph + ? ItemAppraisalSeparator.Line + : ItemAppraisalSeparator.Paragraph, + style)); + } +} + +/// +/// Width-aware projection of retail appraisal fragments into the retained +/// line model. +/// +internal static class ItemAppraisalTextLayout +{ + public static IReadOnlyList Shape( + UiText target, + ItemAppraisalReport report) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(report); + + float maxWidth = Math.Max(1f, target.Width - (2f * target.Padding)); + float Measure(string value) + => target.DatFont?.MeasureWidth(value) + ?? target.Font?.MeasureWidth(value) + ?? value.Length * 8f; + + var lines = new List(); + foreach (ItemAppraisalFragment fragment in report.Fragments) + { + if (lines.Count != 0 + && fragment.Separator == ItemAppraisalSeparator.Paragraph) + { + lines.Add(new UiText.Line(string.Empty, ResolveColor(target, fragment.Style))); + } + + Vector4 color = ResolveColor(target, fragment.Style); + string normalized = fragment.Text.Replace( + "\\n", + "\n", + StringComparison.Ordinal); + foreach (string logicalLine in normalized.Split('\n')) + { + if (logicalLine.Length == 0) + { + lines.Add(new UiText.Line(string.Empty, color)); + continue; + } + + foreach (string wrapped in ChatWindowController.WrapText( + logicalLine, + maxWidth, + Measure)) + { + lines.Add(new UiText.Line(wrapped, color)); + } + } + } + + return lines; + } + + private static Vector4 ResolveColor( + UiText target, + ItemAppraisalFontStyle style) + { + int index = (int)style; + return index >= 0 && index < target.FontColorPalette.Count + ? target.FontColorPalette[index] + : target.DefaultColor; + } +} diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs index 292b07a3..98df929d 100644 --- a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs +++ b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs @@ -27,6 +27,12 @@ public static class ItemAppraisalTextFormatter ClientObject obj, AppraiseInfoParser.Parsed appraisal, Func resolveSpell) + => BuildReport(obj, appraisal, resolveSpell).ToString(); + + public static ItemAppraisalReport BuildReport( + ClientObject obj, + AppraiseInfoParser.Parsed appraisal, + Func resolveSpell) { ArgumentNullException.ThrowIfNull(obj); ArgumentNullException.ThrowIfNull(resolveSpell); @@ -35,11 +41,11 @@ public static class ItemAppraisalTextFormatter var report = new RetailReportBuilder(); // ItemExamineUI::SetAppraiseInfo @ 0x004B72B0. - ShowValueAndBurden(report, obj, properties); + ShowValueAndBurden(report, properties); ShowTinkering(report, obj, properties); ShowSetAndRatings(report, properties); ShowWeaponAndArmor(report, obj, appraisal); - ShowDefenseModifiers(report, properties); + ShowDefenseModifiers(report, appraisal); ShowShortMagicInfo(report, appraisal, resolveSpell); ShowSpecialProperties(report, obj, properties); ShowUsage(report, properties); @@ -48,9 +54,9 @@ public static class ItemAppraisalTextFormatter ShowUsageLimits(report, properties); ShowItemLevel(report, properties); ShowActivationRequirements(report, properties); - ShowCasterData(report, properties); + ShowCasterData(report, appraisal); ShowBoostAndHealing(report, properties); - ShowCapacityAndLock(report, obj, properties); + ShowCapacityAndLock(report, obj, appraisal); ShowManaStone(report, appraisal); ShowRemainingUses(report, obj, appraisal); ShowCraftsman(report, properties); @@ -58,22 +64,23 @@ public static class ItemAppraisalTextFormatter ShowMagicInfo(report, appraisal, resolveSpell); ShowDescription(report, properties); - if (!appraisal.Success && report.IsEmpty) - report.Paragraph("Assessment incomplete"); - return report.ToString(); + return report.Build(); } private static void ShowValueAndBurden( RetailReportBuilder report, - ClientObject obj, PropertyBundle properties) { - int value = obj.Value != 0 ? obj.Value : properties.GetInt(19u); - int burden = obj.Burden != 0 ? obj.Burden : properties.GetInt(5u); - if (value != 0) - report.Line($"Value: {value.ToString("N0", CultureInfo.CurrentCulture)}"); - if (burden != 0) - report.Line($"Burden: {burden.ToString("N0", CultureInfo.CurrentCulture)}"); + // Appraisal_ShowValueInfo @ 0x004ADEA0 and + // Appraisal_ShowBurdenInfo @ 0x004AE180 query only the appraisal + // profile. PublicWeenieDesc values must not leak through a failed or + // deliberately incomplete assessment. + report.Line(properties.Ints.TryGetValue(19u, out int value) + ? $"Value: {value.ToString("N0", CultureInfo.InvariantCulture)}" + : "Value: ???"); + report.Line(properties.Ints.TryGetValue(5u, out int burden) + ? $"Burden: {burden.ToString("N0", CultureInfo.InvariantCulture)}" + : "Burden: Unknown"); } /// @@ -161,15 +168,18 @@ public static class ItemAppraisalTextFormatter { PropertyBundle properties = appraisal.Properties; uint validLocations = (uint)obj.ValidLocations; - uint ammoType = appraisal.HookProfile?.AmmoType - ?? obj.AmmoType - ?? 0u; + uint ammoType = obj.IsHook && appraisal.HookProfile is { } hook + ? hook.AmmoType + : obj.AmmoType ?? 0u; if ((validLocations & (uint)EquipMask.Shield) != 0) { if (properties.Ints.TryGetValue(28u, out int shieldLevel)) report.Line( - $"Base Shield Level: {shieldLevel.ToString(CultureInfo.CurrentCulture)}"); + $"Base Shield Level: {shieldLevel.ToString(CultureInfo.CurrentCulture)}", + EnchantmentStyle( + appraisal.ArmorEnchantments, + 0x0001u)); else if (!appraisal.Success) report.Line("Shield Level: Unknown"); } @@ -191,7 +201,16 @@ public static class ItemAppraisalTextFormatter : weapon.Damage.ToString(CultureInfo.CurrentCulture); if (!launcher) damage += $", {DamageTypeName(weapon.DamageType)}"; - report.Line($"{damageLabel}: {damage}"); + ItemAppraisalFontStyle damageStyle = EnchantmentStyle( + appraisal.WeaponEnchantments, + 0x0008u); + if (damageStyle == ItemAppraisalFontStyle.Normal) + { + damageStyle = EnchantmentStyle( + appraisal.WeaponEnchantments, + 0x0010u); + } + report.Line($"{damageLabel}: {damage}", damageStyle); int elementalBonus = properties.GetInt(204u); if (elementalBonus > 0) @@ -202,7 +221,10 @@ public static class ItemAppraisalTextFormatter if (launcher) report.Line( - $"Damage Modifier: {FormatModifier(weapon.DamageMod)}."); + $"Damage Modifier: {FormatModifier(weapon.DamageMod)}.", + EnchantmentStyle( + appraisal.WeaponEnchantments, + 0x0020u)); const uint timedWeaponLocations = (uint)(EquipMask.MeleeWeapon @@ -211,7 +233,10 @@ public static class ItemAppraisalTextFormatter if ((validLocations & timedWeaponLocations) != 0) report.Line( $"Speed: {WeaponTimeName((int)weapon.WeaponTime)} " - + $"({weapon.WeaponTime.ToString(CultureInfo.CurrentCulture)})"); + + $"({weapon.WeaponTime.ToString(CultureInfo.CurrentCulture)})", + EnchantmentStyle( + appraisal.WeaponEnchantments, + 0x0004u)); if (launcher && weapon.MaxVelocity > 0d) { @@ -235,7 +260,10 @@ public static class ItemAppraisalTextFormatter if (!launcher && Math.Abs(weapon.WeaponOffense - 1d) > 0.000001d) report.Line( $"Bonus to Attack Skill: " - + $"{FormatSignedPercent(weapon.WeaponOffense - 1d)}."); + + $"{FormatSignedPercent(weapon.WeaponOffense - 1d)}.", + EnchantmentStyle( + appraisal.WeaponEnchantments, + 0x0001u)); } ShowAmmunitionDescription(report, validLocations, ammoType); @@ -247,15 +275,32 @@ public static class ItemAppraisalTextFormatter report.Line( appraisal.Success ? $"Armor Level: {armorLevel.ToString(CultureInfo.CurrentCulture)}" - : "Armor Level: Unknown"); - ShowProtection(report, "Slashing", armorLevel, armor.SlashingProtection); - ShowProtection(report, "Piercing", armorLevel, armor.PiercingProtection); - ShowProtection(report, "Bludgeoning", armorLevel, armor.BludgeoningProtection); - ShowProtection(report, "Fire", armorLevel, armor.FireProtection); - ShowProtection(report, "Cold", armorLevel, armor.ColdProtection); - ShowProtection(report, "Acid", armorLevel, armor.AcidProtection); - ShowProtection(report, "Electric", armorLevel, armor.LightningProtection); - ShowProtection(report, "Nether", armorLevel, armor.NetherProtection); + : "Armor Level: Unknown", + EnchantmentStyle(appraisal.ArmorEnchantments, 0x0001u)); + ShowProtection( + report, "Slashing", armorLevel, armor.SlashingProtection, + EnchantmentStyle(appraisal.ArmorEnchantments, 0x0002u)); + ShowProtection( + report, "Piercing", armorLevel, armor.PiercingProtection, + EnchantmentStyle(appraisal.ArmorEnchantments, 0x0004u)); + ShowProtection( + report, "Bludgeoning", armorLevel, armor.BludgeoningProtection, + EnchantmentStyle(appraisal.ArmorEnchantments, 0x0008u)); + ShowProtection( + report, "Fire", armorLevel, armor.FireProtection, + EnchantmentStyle(appraisal.ArmorEnchantments, 0x0020u)); + ShowProtection( + report, "Cold", armorLevel, armor.ColdProtection, + EnchantmentStyle(appraisal.ArmorEnchantments, 0x0010u)); + ShowProtection( + report, "Acid", armorLevel, armor.AcidProtection, + EnchantmentStyle(appraisal.ArmorEnchantments, 0x0040u)); + ShowProtection( + report, "Electric", armorLevel, armor.LightningProtection, + EnchantmentStyle(appraisal.ArmorEnchantments, 0x0080u)); + ShowProtection( + report, "Nether", armorLevel, armor.NetherProtection, + EnchantmentStyle(appraisal.ArmorEnchantments, 0x0100u)); } private static void ShowAmmunitionDescription( @@ -292,7 +337,8 @@ public static class ItemAppraisalTextFormatter RetailReportBuilder report, string damageType, int armorLevel, - float modifier) + float modifier, + ItemAppraisalFontStyle style) { // AppraisalSystem::DamageResistanceToString @ 0x005B5490. string quality = modifier switch @@ -308,15 +354,22 @@ public static class ItemAppraisalTextFormatter double effective = armorLevel * modifier; report.Line( $"{damageType}: {quality} " - + $"({effective.ToString("0", CultureInfo.CurrentCulture)})"); + + $"({effective.ToString("0", CultureInfo.CurrentCulture)})", + style); } /// Appraisal_ShowDefenseModData @ 0x004B1DF0. private static void ShowDefenseModifiers( RetailReportBuilder report, - PropertyBundle properties) + AppraiseInfoParser.Parsed appraisal) { - ShowModifier(report, properties, 29u, "Bonus to Melee Defense"); + PropertyBundle properties = appraisal.Properties; + ShowModifier( + report, + properties, + 29u, + "Bonus to Melee Defense", + EnchantmentStyle(appraisal.WeaponEnchantments, 0x0002u)); ShowModifier(report, properties, 149u, "Bonus to Missile Defense"); ShowModifier(report, properties, 150u, "Bonus to Magic Defense"); } @@ -325,12 +378,15 @@ public static class ItemAppraisalTextFormatter RetailReportBuilder report, PropertyBundle properties, uint property, - string label) + string label, + ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal) { if (!properties.Floats.TryGetValue(property, out double modifier) || Math.Abs(modifier - 1d) <= 0.000001d) return; - report.Line($"{label}: {FormatSignedPercent(modifier - 1d)}."); + report.Line( + $"{label}: {FormatSignedPercent(modifier - 1d)}.", + style); } /// Appraisal_ShowShortMagicInfo @ 0x004B2C90. @@ -343,7 +399,7 @@ public static class ItemAppraisalTextFormatter return; if (!appraisal.Success) { - report.Line("Spells: unknown."); + report.Paragraph("Spells: unknown."); return; } @@ -355,7 +411,7 @@ public static class ItemAppraisalTextFormatter return resolveSpell(id)?.Name ?? $"Spell {id.ToString(CultureInfo.CurrentCulture)}"; })); - report.Line($"Spells: {names}"); + report.Paragraph($"Spells: {names}"); } /// Appraisal_ShowSpecialProperties @ 0x004B0140. @@ -491,22 +547,22 @@ public static class ItemAppraisalTextFormatter int minimum = properties.GetInt(86u); int maximum = properties.GetInt(87u); if (minimum > 0 && maximum > 0) - report.Line( + report.Paragraph( $"Restricted to characters of level " + $"{minimum.ToString(CultureInfo.CurrentCulture)} to " + $"{maximum.ToString(CultureInfo.CurrentCulture)}."); else if (minimum > 0) - report.Line( + report.Paragraph( $"Restricted to characters of level " + $"{minimum.ToString(CultureInfo.CurrentCulture)} or greater."); else if (maximum > 0) - report.Line( + report.Paragraph( $"Restricted to characters of level " + $"{maximum.ToString(CultureInfo.CurrentCulture)} or lower."); string destination = properties.GetString(38u); if (!string.IsNullOrWhiteSpace(destination)) - report.Line($"Destination: {destination}"); + report.Paragraph($"Destination: {destination}"); } /// Appraisal_ShowWieldRequirements @ 0x004AF9A0. @@ -685,18 +741,25 @@ public static class ItemAppraisalTextFormatter /// Appraisal_ShowCasterData @ 0x004B1B10. private static void ShowCasterData( RetailReportBuilder report, - PropertyBundle properties) + AppraiseInfoParser.Parsed appraisal) { + PropertyBundle properties = appraisal.Properties; if (properties.Floats.TryGetValue(144u, out double manaConversion)) - report.Line( + report.Paragraph( $"Bonus to Mana Conversion: " - + $"{FormatSignedPercent(manaConversion)}."); + + $"{FormatSignedPercent(manaConversion)}.", + EnchantmentStyle( + appraisal.ResistEnchantments, + 0x1000u)); if (properties.Floats.TryGetValue(152u, out double elemental) && properties.Ints.TryGetValue(45u, out int damageType)) { - report.Line( - $"Damage bonus for {DamageTypeName((uint)damageType)} spells:"); + report.Paragraph( + $"Damage bonus for {DamageTypeName((uint)damageType)} spells:", + EnchantmentStyle( + appraisal.ResistEnchantments, + 0x2000u)); report.Line($" vs. Monsters: {FormatSignedPercent(elemental - 1d)}."); double playerModifier = 1d + (elemental - 1d) * 0.25d; report.Line( @@ -721,13 +784,13 @@ public static class ItemAppraisalTextFormatter _ => null, }; if (boost != 0 && vital is not null) - report.Line( + report.Paragraph( $"{(boost > 0 ? "Restores" : "Depletes")} " + $"{Math.Abs(boost).ToString(CultureInfo.CurrentCulture)} " + $"{vital} when used."); if (boost != 0 && properties.Floats.ContainsKey(100u)) - report.Line( + report.Paragraph( $"Bonus to Healing Skill: {boost.ToString(CultureInfo.CurrentCulture)}"); if (properties.Floats.TryGetValue(100u, out double healKitModifier)) report.Line( @@ -742,36 +805,73 @@ public static class ItemAppraisalTextFormatter private static void ShowCapacityAndLock( RetailReportBuilder report, ClientObject obj, - PropertyBundle properties) + AppraiseInfoParser.Parsed appraisal) { - if (obj.ItemsCapacity > 0 && obj.ContainersCapacity > 0) - report.Line( - $"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.CurrentCulture)} " - + $"items and {obj.ContainersCapacity.ToString(CultureInfo.CurrentCulture)} containers."); - else if (obj.ItemsCapacity > 0) - report.Line( - $"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.CurrentCulture)} items."); - else if (obj.ContainersCapacity > 0) - report.Line( - $"Can hold up to {obj.ContainersCapacity.ToString(CultureInfo.CurrentCulture)} containers."); + PropertyBundle properties = appraisal.Properties; + // Appraisal_ShowCapacity @ 0x004B2680 suppresses the hook object's + // own container capacity while a HookProfile describes the item + // mounted on it. This is the Black Phyntos Hive 255/255 case. + if (!obj.IsHook || appraisal.HookProfile is null) + { + if (obj.ItemsCapacity > 0 && obj.ContainersCapacity > 0) + report.Paragraph( + $"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.CurrentCulture)} " + + $"items and {obj.ContainersCapacity.ToString(CultureInfo.CurrentCulture)} containers."); + else if (obj.ItemsCapacity > 0) + report.Paragraph( + $"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.CurrentCulture)} items."); + else if (obj.ContainersCapacity > 0) + report.Paragraph( + $"Can hold up to {obj.ContainersCapacity.ToString(CultureInfo.CurrentCulture)} containers."); - int pages = properties.GetInt(174u); - int pagesUsed = properties.GetInt(175u); - if (pages > 0) - report.Line( - $"Pages: {pagesUsed.ToString(CultureInfo.CurrentCulture)} / " - + $"{pages.ToString(CultureInfo.CurrentCulture)}"); + int pages = properties.GetInt(175u); + int pagesUsed = properties.GetInt(174u); + if (pages > 0) + report.Paragraph( + $"{pagesUsed.ToString(CultureInfo.CurrentCulture)} of " + + $"{pages.ToString(CultureInfo.CurrentCulture)} pages full."); + } - if (properties.GetBool(3u)) - report.Line("This item is locked."); - int resistance = properties.GetInt(38u); - if (resistance > 0) - report.Line( - $"Resistance to Lockpick: {resistance.ToString(CultureInfo.CurrentCulture)}"); - int chance = properties.GetInt(173u); - if (chance > 0) - report.Line( - $"Chance of success: {chance.ToString(CultureInfo.CurrentCulture)}%"); + // Appraisal_ShowLockAppraiseInfo @ 0x004B2790 does not present lock + // data for hook objects. + if (obj.IsHook) + return; + + bool hasLocked = properties.Bools.TryGetValue(3u, out bool locked); + bool hasResistance = properties.Ints.TryGetValue( + 38u, + out int resistance); + if (!hasLocked) + { + if (hasResistance && resistance != 0) + { + report.Paragraph( + $"Bonus to Lockpick Skill: " + + $"{resistance.ToString("+0;-0;0", CultureInfo.CurrentCulture)}"); + } + return; + } + + if (!locked) + { + report.Paragraph("Unlocked"); + return; + } + + report.Paragraph("Locked"); + if (!hasResistance) + { + report.Paragraph("You can't tell how hard the lock is to pick."); + return; + } + + if (properties.Ints.TryGetValue(173u, out int chance) + && LockpickDifficulty(chance) is { } difficulty) + { + report.Paragraph( + $"The lock looks {difficulty} to pick " + + $"(Resistance {resistance.ToString(CultureInfo.CurrentCulture)})."); + } } /// Appraisal_ShowManaStoneInfo @ 0x004B3900. @@ -808,7 +908,7 @@ public static class ItemAppraisalTextFormatter if (properties.GetBool(63u)) { - report.Line("Number of uses remaining: Unlimited"); + report.Line("Number of uses remaining: Unlimited"); return; } @@ -820,7 +920,7 @@ public static class ItemAppraisalTextFormatter && (obj.MaxStructure != 0 || appraisal.HookProfile is { Flags: var flags } && (flags & 0xAu) != 0)) - report.Line("Number of uses remaining: Unknown"); + report.Paragraph("Number of uses remaining: Unknown"); } private static void ShowCraftsman( @@ -842,7 +942,7 @@ public static class ItemAppraisalTextFormatter report.Line("This item cannot be sold."); int rare = properties.GetInt(17u); if (rare > 0) - report.Line($"Rare #{rare.ToString(CultureInfo.CurrentCulture)}"); + report.Paragraph($"Rare #{rare.ToString(CultureInfo.CurrentCulture)}"); } /// Appraisal_ShowMagicInfo @ 0x004B2E10. @@ -888,24 +988,46 @@ public static class ItemAppraisalTextFormatter } else if (properties.Ints.TryGetValue(117u, out int manaCost)) { - report.Line($"Mana Cost: {manaCost.ToString(CultureInfo.CurrentCulture)}."); + string manaCostText = + $"Mana Cost: {manaCost.ToString(CultureInfo.CurrentCulture)}."; if (manaCost > 0) - report.Line("(Can be reduced by the Mana Conversion skill)."); + { + manaCostText += + "\n(Can be reduced by the Mana Conversion skill)."; + } + report.Paragraph(manaCostText); } - report.Paragraph("Spell Descriptions:"); - foreach ((uint id, SpellMetadata? metadata) in ordinary) - report.Spell(metadata?.Name, metadata?.Description, id); + report.Paragraph(BuildSpellDescriptionBlock( + "Spell Descriptions:", + ordinary)); } if (enchantments.Count != 0) { - report.Paragraph("Enchantments:"); - foreach ((uint id, SpellMetadata? metadata) in enchantments) - report.Spell(metadata?.Name, metadata?.Description, id); + report.Paragraph(BuildSpellDescriptionBlock( + "Enchantments:", + enchantments)); } } + private static string BuildSpellDescriptionBlock( + string heading, + IReadOnlyList<(uint Id, SpellMetadata? Metadata)> spells) + { + var text = new StringBuilder(heading); + foreach ((uint id, SpellMetadata? metadata) in spells) + { + string name = !string.IsNullOrWhiteSpace(metadata?.Name) + ? metadata.Name + : $"Spell {id.ToString(CultureInfo.CurrentCulture)}"; + text.Append('\n').Append(name); + if (!string.IsNullOrWhiteSpace(metadata?.Description)) + text.Append('\n').Append(metadata.Description); + } + return text.ToString(); + } + private static void ShowDescription( RetailReportBuilder report, PropertyBundle properties) @@ -979,6 +1101,47 @@ public static class ItemAppraisalTextFormatter private static string FormatSignedPercent(double modifier) => modifier.ToString("+0%;-0%;0%", CultureInfo.CurrentCulture); + /// + /// Port of AppraisalSystem::LockpickSuccessPercentToString + /// @ 0x005B4600. + /// + private static string? LockpickDifficulty(int successPercent) + => successPercent switch + { + < 0 => null, + 0 => "impossible", + < 5 => "ridiculously difficult", + < 15 => "extremely difficult", + < 35 => "quite difficult", + < 50 => "difficult", + < 70 => "challenging", + < 85 => "mildly challenging", + < 95 => "easy", + _ => "trivial", + }; + + /// + /// Port of AppraisalProfile::InqIntEnchantmentMod @ 0x005B2C10 + /// and InqFloatEnchantmentMod @ 0x005B2C70. The wire carries the + /// low/high halves as two consecutive u16 values: the low bit marks an + /// enchanted value and the corresponding bit sixteen positions higher + /// selects the beneficial color. + /// + private static ItemAppraisalFontStyle EnchantmentStyle( + (ushort Highlight, ushort Color)? encoded, + uint lowBit) + { + if (encoded is not { } halves) + return ItemAppraisalFontStyle.Normal; + + uint bitfield = halves.Highlight | ((uint)halves.Color << 16); + if ((bitfield & lowBit) == 0u) + return ItemAppraisalFontStyle.Normal; + return (bitfield & (lowBit << 16)) != 0u + ? ItemAppraisalFontStyle.Beneficial + : ItemAppraisalFontStyle.Detrimental; + } + private static string DamageTypeName(uint type) => type switch { 1u => "Slashing", @@ -1093,39 +1256,18 @@ public static class ItemAppraisalTextFormatter private sealed class RetailReportBuilder { - private readonly StringBuilder _text = new(); + private readonly ItemAppraisalReportBuilder _report = new(); - public bool IsEmpty => _text.Length == 0; + public void Line( + string value, + ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal) + => _report.Line(value, style); - public void Line(string value) - { - if (string.IsNullOrWhiteSpace(value)) - return; - if (_text.Length != 0 && _text[^1] != '\n') - _text.Append('\n'); - _text.Append(value); - } + public void Paragraph( + string value, + ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal) + => _report.Paragraph(value, style); - public void Paragraph(string value) - { - if (string.IsNullOrWhiteSpace(value)) - return; - if (_text.Length != 0) - _text.Append("\n\n"); - _text.Append(value); - } - - public void Spell(string? name, string? description, uint spellId) - { - string resolvedName = !string.IsNullOrWhiteSpace(name) - ? name - : $"Spell {spellId.ToString(CultureInfo.CurrentCulture)}"; - Paragraph( - string.IsNullOrWhiteSpace(description) - ? resolvedName - : $"{resolvedName}\n{description}"); - } - - public override string ToString() => _text.ToString().TrimEnd(); + public ItemAppraisalReport Build() => _report.Build(); } } diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index df54eea1..73d57334 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -64,6 +64,15 @@ public sealed class UiText : UiElement, IUiDatStateful /// public Vector4 DefaultColor { get; set; } = Vector4.One; + /// + /// Authored UIElement_Text font-color list from LayoutDesc property + /// 0x1B. Retail AppendTextWithFont @ 0x00469D70 selects an + /// entry by index for every appended fragment. Controllers that port that + /// API use this palette instead of hard-coded colors. + /// + public IReadOnlyList FontColorPalette { get; set; } + = Array.Empty(); + /// Backing fill behind the text. Defaults to transparent so an unbound /// UiText (no controller) draws nothing. Set to the retail translucent value by /// the controller (e.g. ChatWindowController). diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 1babe390..cfafabd5 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -184,6 +184,11 @@ public static class CreateObject int? Burden = null, int? ItemsCapacity = null, int? ContainersCapacity = null, + // PublicWeenieDesc hook identity. ACCWeenieObject::IsHook + // @ 0x0058C660 requires both values to be non-zero. + // The wire stores HookItemTypes as u32 and HookType as u16. + uint? HookItemTypes = null, + uint? HookType = null, uint? ContainerId = null, uint? WielderId = null, uint? ValidLocations = null, @@ -778,6 +783,8 @@ public static class CreateObject int? wBurden = null; int? wItemsCapacity = null; int? wContainersCapacity = null; + uint? wHookItemTypes = null; + uint? wHookType = null; uint? wContainerId = null; uint? wWielderId = null; uint? wValidLocations = null; @@ -852,9 +859,9 @@ public static class CreateObject // 0x00400000 Spell u16 (skip) // 0x02000000 HouseOwner u32 (skip) // 0x04000000 HouseRestrictions RestrictionDB (skip, variable-length) - // 0x20000000 HookItemTypes u32 (skip) + // 0x20000000 HookItemTypes u32 CAPTURE // 0x00000040 Monarch u32 (skip) - // 0x10000000 HookType u16 (skip) + // 0x10000000 HookType u16 CAPTURE // 0x40000000 IconOverlay PackedDwordKnownType(0x06000000) CAPTURE // weenieFlags2 bit 0x01: // IconUnderlay PackedDwordKnownType(0x06000000) CAPTURE @@ -1050,7 +1057,7 @@ public static class CreateObject if ((weenieFlags & 0x20000000u) != 0) // HookItemTypes u32 { if (body.Length - pos < 4) throw new FormatException("trunc HookItemTypes"); - pos += 4; + wHookItemTypes = ReadU32(body, ref pos); } if ((weenieFlags & 0x00000040u) != 0) // Monarch u32 { @@ -1060,6 +1067,7 @@ public static class CreateObject if ((weenieFlags & 0x10000000u) != 0) // HookType u16 { if (body.Length - pos < 2) throw new FormatException("trunc HookType"); + wHookType = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; } if ((weenieFlags & 0x40000000u) != 0) // IconOverlay PackedDwordOfKnownType(0x06000000) ← CAPTURE @@ -1117,6 +1125,7 @@ public static class CreateObject WeenieClassId: weenieClassId, Value: wValue, StackSize: wStackSize, StackSizeMax: wMaxStackSize, Burden: wBurden, ItemsCapacity: wItemsCapacity, ContainersCapacity: wContainersCapacity, + HookItemTypes: wHookItemTypes, HookType: wHookType, ContainerId: wContainerId, WielderId: wWielderId, ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation, Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure, diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs index acab8097..306d7aeb 100644 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -172,6 +172,8 @@ public static class ObjectTableWiring Priority: s.Priority, ItemsCapacity: s.ItemsCapacity, ContainersCapacity: s.ContainersCapacity, + HookItemTypes: s.HookItemTypes, + HookType: s.HookType, Structure: s.Structure, MaxStructure: s.MaxStructure, Workmanship: s.Workmanship, diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 8d95eed0..0f3f0d06 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -156,7 +156,9 @@ public sealed class WorldSession : IDisposable uint? SpellId = null, uint? CooldownId = null, double? CooldownDuration = null, - PhysicsSpawnData? Physics = null); + PhysicsSpawnData? Physics = null, + uint? HookItemTypes = null, + uint? HookType = null); /// /// Projects the wire-level CreateObject result into the stable session @@ -219,7 +221,9 @@ public sealed class WorldSession : IDisposable SpellId: parsed.SpellId, CooldownId: parsed.CooldownId, CooldownDuration: parsed.CooldownDuration, - Physics: parsed.Physics); + Physics: parsed.Physics, + HookItemTypes: parsed.HookItemTypes, + HookType: parsed.HookType); /// Fires when the session finishes parsing a CreateObject. public event Action? EntitySpawned; diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index 2953aeb7..8c399282 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -177,6 +177,15 @@ public sealed class ClientObject public uint WielderId { get; set; } // PropertyInstanceId.Wielder; 0 = not wielded public int ItemsCapacity { get; set; } // main-pack slots (containers) public int ContainersCapacity{ get; set; } // side-pack slots (containers) + /// Retail PublicWeenieDesc._hook_item_types. + public uint HookItemTypes { get; set; } + /// Retail PublicWeenieDesc._hook_type. + public uint HookType { get; set; } + /// + /// Port of ACCWeenieObject::IsHook @ 0x0058C660. + /// A hook must publish both a hook type and an accepted item-type mask. + /// + public bool IsHook => HookType != 0u && HookItemTypes != 0u; public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility @@ -274,7 +283,9 @@ public readonly record struct WeenieData( ushort? AmmoType = null, uint? SpellId = null, uint? CooldownId = null, - double? CooldownDuration = null); + double? CooldownDuration = null, + uint? HookItemTypes = null, + uint? HookType = null); /// /// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0). diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 9663150c..bf7df10b 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -194,6 +194,8 @@ public sealed class ClientObjectTable /// . public const uint UiEffectsPropertyId = 18u; public const uint CurrentWieldedLocationPropertyId = 10u; + public const uint HookTypePropertyId = 151u; + public const uint HookItemTypesPropertyId = 152u; public const uint SharedCooldownPropertyId = 280u; public const uint CooldownDurationPropertyId = 167u; @@ -663,6 +665,8 @@ public sealed class ClientObjectTable if (propertyId == SharedCooldownPropertyId) item.CooldownId = (uint)value; if (propertyId == CurrentWieldedLocationPropertyId) item.CurrentlyEquippedLocation = (EquipMask)(uint)value; + if (propertyId == HookTypePropertyId) item.HookType = (uint)value; + if (propertyId == HookItemTypesPropertyId) item.HookItemTypes = (uint)value; if (propertyId == CurrentWieldedLocationPropertyId) UpdateEquipmentIndex(itemId, previous, ClientObjectPlacement.From(item)); ObjectUpdated?.Invoke(item); @@ -764,6 +768,8 @@ public sealed class ClientObjectTable if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior; if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic; if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc; + if (d.HookItemTypes is { } hookItemTypes) obj.HookItemTypes = hookItemTypes; + if (d.HookType is { } hookType) obj.HookType = hookType; if (d.Structure is { } st) obj.Structure = st; if (d.MaxStructure is { } ms) obj.MaxStructure = ms; if (d.Workmanship is { } wm) obj.Workmanship = wm; diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index 3ce16e71..40d2629a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -1,3 +1,4 @@ +using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Combat; @@ -47,6 +48,8 @@ public sealed class AppraisalUiControllerTests Assert.True(interaction.ExamineSelectedOrEnterMode(ObjectId)); var properties = new PropertyBundle(); + properties.Ints[19u] = 1_250; + properties.Ints[5u] = 350; properties.Strings[16u] = "A finely balanced weapon."; properties.Strings[7u] = "Remember the fallen."; properties.Strings[8u] = "Tester"; @@ -62,6 +65,13 @@ public sealed class AppraisalUiControllerTests UiText itemText = Assert.IsType( layout.FindElement(AppraisalUiController.ItemTextId)); Assert.Equal(VJustify.Top, itemText.VerticalJustify); + Assert.Equal( + [ + new Vector4(1f, 1f, 1f, 1f), + new Vector4(0f, 1f, 0f, 1f), + new Vector4(1f, 0f, 0f, 1f), + ], + itemText.FontColorPalette); string report = string.Join('\n', itemText.LinesProvider().Select(line => line.Text)); Assert.Contains("Value: 1,250", report); Assert.Contains("Burden: 350", report); @@ -597,6 +607,8 @@ public sealed class AppraisalUiControllerTests ObjectId = ObjectId, Name = "Hooked Decoration", ContainerId = 0x50000002u, + HookItemTypes = (uint)ItemType.Misc, + HookType = 1u, }); var messages = new List(); using var interaction = NewInteraction(objects, []); diff --git a/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs b/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs index 8d607dfb..61d2a33f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs @@ -1,3 +1,5 @@ +using System.Numerics; +using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Items; using AcDream.Core.Net.Messages; @@ -22,6 +24,8 @@ public sealed class ItemAppraisalTextFormatterTests }; var properties = new PropertyBundle(); properties.Ints[353u] = 2; + properties.Ints[19u] = 12_500; + properties.Ints[5u] = 450; properties.Ints[204u] = 4; properties.Ints[106u] = 300; properties.Ints[107u] = 250; @@ -83,6 +87,232 @@ public sealed class ItemAppraisalTextFormatterTests Assert.EndsWith("A finely balanced enchanted blade.", report); } + [Fact] + public void HookWithHookProfile_UsesRetailUnknownsAndSuppressesBogusCapacity() + { + var obj = new ClientObject + { + ObjectId = 0x50000005u, + Name = "Black Phyntos Hive", + Type = ItemType.Container, + ItemsCapacity = 255, + ContainersCapacity = 255, + HookItemTypes = (uint)ItemType.Container, + HookType = 4u, + }; + var properties = new PropertyBundle(); + properties.Strings[16u] = + "A hollowed out tree trunk that has a Phyntos Wasp Hive in it."; + + ItemAppraisalReport report = ItemAppraisalTextFormatter.BuildReport( + obj, + Parsed( + properties, + hook: new AppraiseInfoParser.HookProfile( + Flags: 0u, + ValidLocations: 0u, + AmmoType: 0u)), + _ => null); + + Assert.Equal( + "Value: ???\nBurden: Unknown\n\n" + + "A hollowed out tree trunk that has a Phyntos Wasp Hive in it.", + report.ToString()); + Assert.DoesNotContain("Can hold", report.ToString()); + Assert.Collection( + report.Fragments, + value => + { + Assert.Equal(ItemAppraisalSeparator.None, value.Separator); + Assert.Equal("Value: ???", value.Text); + }, + burden => + { + Assert.Equal(ItemAppraisalSeparator.Line, burden.Separator); + Assert.Equal("Burden: Unknown", burden.Text); + }, + description => + { + Assert.Equal(ItemAppraisalSeparator.Paragraph, description.Separator); + Assert.Equal(properties.Strings[16u], description.Text); + }); + } + + [Fact] + public void OrdinaryContainer_PresentsCapacityAsRetailParagraph() + { + var obj = new ClientObject + { + ObjectId = 0x50000006u, + Name = "Backpack", + Type = ItemType.Container, + ItemsCapacity = 24, + ContainersCapacity = 1, + }; + + string report = ItemAppraisalTextFormatter.Build( + obj, + Parsed(new PropertyBundle()), + _ => null); + + Assert.Equal( + "Value: ???\nBurden: Unknown\n\n" + + "Can hold up to 24 items and 1 containers.", + report); + } + + [Fact] + public void BookCapacity_UsesRetailUsedThenTotalPropertyOrder() + { + var obj = new ClientObject + { + ObjectId = 0x5000000Bu, + Name = "Book", + Type = ItemType.Writable, + }; + var properties = new PropertyBundle(); + properties.Ints[174u] = 3; + properties.Ints[175u] = 10; + + string report = ItemAppraisalTextFormatter.Build( + obj, + Parsed(properties), + _ => null); + + Assert.EndsWith("3 of 10 pages full.", report); + } + + [Theory] + [InlineData((ushort)0x0000, ItemAppraisalFontStyle.Detrimental)] + [InlineData((ushort)0x0008, ItemAppraisalFontStyle.Beneficial)] + public void WeaponDamage_UsesRetailEnchantmentFontSelection( + ushort highHalf, + ItemAppraisalFontStyle expected) + { + var obj = new ClientObject + { + ObjectId = 0x50000007u, + Name = "Sword", + Type = ItemType.MeleeWeapon, + ValidLocations = EquipMask.MeleeWeapon, + }; + AppraiseInfoParser.Parsed parsed = Parsed( + new PropertyBundle(), + weapon: new AppraiseInfoParser.WeaponProfile( + DamageType: 1u, + WeaponTime: 30u, + WeaponSkill: 44u, + Damage: 40u, + DamageVariance: 0d, + DamageMod: 1d, + WeaponLength: 1d, + MaxVelocity: 0d, + WeaponOffense: 1d, + MaxVelocityEstimated: 0u), + weaponEnchantments: ((ushort)0x0008, highHalf)); + + ItemAppraisalReport report = ItemAppraisalTextFormatter.BuildReport( + obj, + parsed, + _ => null); + + ItemAppraisalFragment damage = Assert.Single( + report.Fragments, + fragment => fragment.Text.StartsWith( + "Damage:", + StringComparison.Ordinal)); + Assert.Equal(expected, damage.Style); + } + + [Fact] + public void TextLayout_PreservesRetailPaletteAcrossParagraphs() + { + Vector4 normal = Vector4.One; + var beneficial = new Vector4(0f, 1f, 0f, 1f); + var detrimental = new Vector4(1f, 0f, 0f, 1f); + var text = new UiText + { + Width = 1_000f, + FontColorPalette = [normal, beneficial, detrimental], + }; + var report = new ItemAppraisalReport( + [ + new ItemAppraisalFragment( + "Normal", + ItemAppraisalSeparator.None, + ItemAppraisalFontStyle.Normal), + new ItemAppraisalFragment( + "High", + ItemAppraisalSeparator.Line, + ItemAppraisalFontStyle.Beneficial), + new ItemAppraisalFragment( + "Low", + ItemAppraisalSeparator.Paragraph, + ItemAppraisalFontStyle.Detrimental), + ]); + + IReadOnlyList lines = ItemAppraisalTextLayout.Shape( + text, + report); + + Assert.Equal(["Normal", "High", "", "Low"], lines.Select(line => line.Text)); + Assert.Equal(normal, lines[0].Color); + Assert.Equal(beneficial, lines[1].Color); + Assert.Equal(detrimental, lines[3].Color); + } + + [Fact] + public void LockInformation_UsesRetailPresenceRulesWordingAndParagraphs() + { + var obj = new ClientObject + { + ObjectId = 0x50000009u, + Name = "Locked Chest", + Type = ItemType.Container, + }; + var properties = new PropertyBundle(); + properties.Bools[3u] = true; + properties.Ints[38u] = 212; + properties.Ints[173u] = 42; + + ItemAppraisalReport report = ItemAppraisalTextFormatter.BuildReport( + obj, + Parsed(properties), + _ => null); + + Assert.Contains( + report.Fragments, + fragment => fragment.Text == "Locked" + && fragment.Separator == ItemAppraisalSeparator.Paragraph); + Assert.Contains( + report.Fragments, + fragment => fragment.Text + == "The lock looks difficult to pick (Resistance 212)." + && fragment.Separator == ItemAppraisalSeparator.Paragraph); + } + + [Fact] + public void MissingLockedFlag_ReportsLockpickBonusInsteadOfInventingLockState() + { + var obj = new ClientObject + { + ObjectId = 0x5000000Au, + Name = "Lockpick", + Type = ItemType.Misc, + }; + var properties = new PropertyBundle(); + properties.Ints[38u] = -7; + + string report = ItemAppraisalTextFormatter.Build( + obj, + Parsed(properties), + _ => null); + + Assert.Contains("Bonus to Lockpick Skill: -7", report); + Assert.DoesNotContain("Locked", report); + Assert.DoesNotContain("Unlocked", report); + } + [Fact] public void ArmorRequirementsAndUses_UseRetailLabelsAndResistanceBands() { @@ -215,7 +445,9 @@ public sealed class ItemAppraisalTextFormatterTests PropertyBundle properties, uint[]? spells = null, AppraiseInfoParser.WeaponProfile? weapon = null, - AppraiseInfoParser.ArmorProfile? armor = null) + AppraiseInfoParser.ArmorProfile? armor = null, + AppraiseInfoParser.HookProfile? hook = null, + (ushort Highlight, ushort Color)? weaponEnchantments = null) => new( Guid: 0x50000001u, Flags: AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable, @@ -225,10 +457,10 @@ public sealed class ItemAppraisalTextFormatterTests ArmorProfile: armor, CreatureProfile: null, WeaponProfile: weapon, - HookProfile: null, + HookProfile: hook, ArmorLevels: null, ArmorEnchantments: null, - WeaponEnchantments: null, + WeaponEnchantments: weaponEnchantments, ResistEnchantments: null); private static SpellMetadata Spell( diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs index 1a61e14c..b5bdecdf 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs @@ -559,6 +559,43 @@ public sealed class CreateObjectTests Assert.Equal(7.5f, p.Workmanship); } + [Fact] + public void TryParse_HookIdentityFields_AreCapturedWithoutMisaligningOverlay() + { + const uint hookItemTypes = (uint)ItemType.Container; + const ushort hookType = 4; + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x50000027u, + name: "Black Phyntos Hive", + itemType: (uint)ItemType.Container, + weenieFlags: 0x20000000u | 0x10000000u | 0x40000000u, + hookItemTypes: hookItemTypes, + hookType: hookType, + iconOverlayId: 0x4567u); + + var parsed = CreateObject.TryParse(body); + + Assert.NotNull(parsed); + Assert.Equal(hookItemTypes, parsed.Value.HookItemTypes); + Assert.Equal((uint)hookType, parsed.Value.HookType); + Assert.Equal(0x06004567u, parsed.Value.IconOverlayId); + } + + [Fact] + public void TryParse_AbsentHookIdentityFields_RemainNull() + { + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x50000028u, + name: "Ordinary Chest", + itemType: (uint)ItemType.Container); + + var parsed = CreateObject.TryParse(body); + + Assert.NotNull(parsed); + Assert.Null(parsed.Value.HookItemTypes); + Assert.Null(parsed.Value.HookType); + } + [Fact] public void TryParse_MovementSequence_SurfacedFromTimestampBlock() { @@ -709,7 +746,9 @@ public sealed class CreateObjectTests uint cooldownId = 0, double cooldownDuration = 0, uint petOwnerId = 0, - ushort spellId = 0) + ushort spellId = 0, + uint hookItemTypes = 0, + ushort hookType = 0) { var bytes = new List(); WriteU32(bytes, CreateObject.Opcode); @@ -809,9 +848,9 @@ public sealed class CreateObjectTests WriteU16(bytes, 0); // count WriteU16(bytes, 768); // numBuckets (retail constant) } - if ((weenieFlags & 0x20000000u) != 0) WriteU32(bytes, 0); // HookItemTypes u32 + if ((weenieFlags & 0x20000000u) != 0) WriteU32(bytes, hookItemTypes); // HookItemTypes u32 if ((weenieFlags & 0x00000040u) != 0) WriteU32(bytes, 0); // Monarch u32 - if ((weenieFlags & 0x10000000u) != 0) WriteU16(bytes, 0); // HookType u16 + if ((weenieFlags & 0x10000000u) != 0) WriteU16(bytes, hookType); // HookType u16 if ((weenieFlags & 0x40000000u) != 0) WritePackedDword(bytes, iconOverlayId); // IconOverlay if ((weenieFlags2 & 0x00000001u) != 0) WritePackedDword(bytes, iconUnderlayId); // IconUnderlay if ((weenieFlags & 0x80000000u) != 0) WriteU32(bytes, materialType); // MaterialType diff --git a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs index d0707352..5e163d33 100644 --- a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs @@ -54,6 +54,8 @@ public sealed class ObjectTableWiringTests Priority = 0x00000005u, ItemsCapacity = 0, ContainersCapacity = 0, + HookItemTypes = (uint)ItemType.Container, + HookType = 4u, Structure = 80, MaxStructure = 100, Workmanship = 4.5f, @@ -102,6 +104,8 @@ public sealed class ObjectTableWiringTests // --- capacity --- Assert.Equal(0, d.ItemsCapacity); Assert.Equal(0, d.ContainersCapacity); + Assert.Equal((uint)ItemType.Container, d.HookItemTypes); + Assert.Equal(4u, d.HookType); // --- durability --- Assert.Equal(80, d.Structure); diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 2bc2113c..62700b22 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -300,12 +300,16 @@ public sealed class ClientObjectTableTests var o = new ClientObject { ObjectId = 1, WielderId = 0x42u, ItemsCapacity = 24, ContainersCapacity = 7, + HookItemTypes = (uint)ItemType.Container, HookType = 4u, ContainerTypeHint = 1u, Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f, }; o.WeenieClassId = 0xABCDu; // now settable Assert.Equal(0x42u, o.WielderId); Assert.Equal(24, o.ItemsCapacity); Assert.Equal(7, o.ContainersCapacity); + Assert.Equal((uint)ItemType.Container, o.HookItemTypes); + Assert.Equal(4u, o.HookType); + Assert.True(o.IsHook); Assert.Equal(1u, o.ContainerTypeHint); Assert.Equal(8u, o.Priority); Assert.Equal(5, o.Structure); @@ -314,6 +318,40 @@ public sealed class ClientObjectTableTests Assert.Equal(0xABCDu, o.WeenieClassId); } + [Fact] + public void IngestAndLiveProperties_PreserveRetailHookIdentity() + { + const uint guid = 0x500000AEu; + var table = new ClientObjectTable(); + ClientObject obj = table.Ingest( + FullWeenie(guid) with + { + HookItemTypes = (uint)ItemType.Container, + HookType = 4u, + }); + + Assert.True(obj.IsHook); + Assert.Equal((uint)ItemType.Container, obj.HookItemTypes); + Assert.Equal(4u, obj.HookType); + + Assert.True(table.UpdateIntProperty( + guid, + ClientObjectTable.HookTypePropertyId, + value: 0)); + Assert.False(obj.IsHook); + Assert.True(table.UpdateIntProperty( + guid, + ClientObjectTable.HookTypePropertyId, + value: 2)); + Assert.True(table.UpdateIntProperty( + guid, + ClientObjectTable.HookItemTypesPropertyId, + value: (int)ItemType.Misc)); + Assert.True(obj.IsHook); + Assert.Equal(2u, obj.HookType); + Assert.Equal((uint)ItemType.Misc, obj.HookItemTypes); + } + [Fact] public void WeenieData_Construct() {