From 6718ee45a003f3b2cd4e0d8fd36b3dee9c67799f Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Jul 2026 05:21:01 +0200 Subject: [PATCH] fix(ui): preserve retail item titles and spacing Carry PublicWeenieDesc material type into the live object model so examination titles use the DAT-authored material prefix. Preserve retail AddItemInfo empty appends and embedded armor separator, restoring the deliberate blank rows between appraisal sections. Co-authored-by: Codex --- docs/ISSUES.md | 10 ++ .../retail-divergence-register.md | 2 +- ...2026-07-23-world-interaction-completion.md | 13 +++ ...26-07-23-retail-appraisal-ui-pseudocode.md | 35 ++++++ .../UI/Layout/AppraisalUiController.cs | 4 +- .../UI/Layout/ItemAppraisalReport.cs | 17 +++ .../UI/Layout/ItemAppraisalTextFormatter.cs | 102 +++++++++++++----- .../UI/Layout/RetailAppraisalNameResolver.cs | 28 +++++ src/AcDream.Core.Net/Messages/CreateObject.cs | 19 +++- src/AcDream.Core.Net/ObjectTableWiring.cs | 3 +- src/AcDream.Core.Net/WorldSession.cs | 6 +- src/AcDream.Core/Items/ClientObject.cs | 18 +++- src/AcDream.Core/Items/ClientObjectTable.cs | 1 + .../UI/Layout/AppraisalUiControllerTests.cs | 43 +++++++- .../Layout/ItemAppraisalTextFormatterTests.cs | 62 ++++++++++- .../Messages/CreateObjectTests.cs | 1 + .../ObjectTableWiringTests.cs | 2 + .../WorldSessionRadarTests.cs | 11 ++ .../Items/ClientObjectTableTests.cs | 10 ++ 19 files changed, 341 insertions(+), 46 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index a2771944..3f87ee69 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -132,6 +132,16 @@ 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. +The next connected boots comparison exposed two final shared-path omissions. +CreateObject walked past `PublicWeenieDesc.MaterialType`, so +`ACCWeenieObject::GetObjectName(NAME_APPROPRIATE)` could not decorate the +authored base name, and the retained report builder discarded empty strings +even though retail `AddItemInfo` still appends their separator. Material type +now survives CreateObject -> EntitySpawn -> WeenieData -> ClientObject and the +DAT resolver prefixes it exactly once. Empty append fragments now preserve +retail's intentional blank section rows, including the blank after +Workmanship and the literal leading newline before Armor Level. + **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 2d6aa8aa..728a96dd 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 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-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 and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus 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`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `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-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index cf111309..adfa2754 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -213,6 +213,19 @@ 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. +The material-title and section-boundary correction carries +`PublicWeenieDesc.MaterialType` from CreateObject through the canonical object +table and resolves `ACCWeenieObject::GetObjectName(NAME_APPROPRIATE)` through +the installed DAT material map. Examination titles now produce names such as +`Reed Shark Hide Steel Toed Boots` without duplicating a material already +present in the authored base name. Empty `AddItemInfo` calls are retained as +real report fragments, restoring retail's intentional blank rows after +workmanship, before armor level, around rating/special-property blocks, and at +the later use/item-level boundaries. Focused wire, projection, object-table, +title, and boots-layout fixtures protect the full path. The Release solution +build and 3,533 App tests / 3 skips plus 7,956 complete-solution tests / +5 skips pass; the connected boots comparison is the remaining visual 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 57aea162..4e55acd7 100644 --- a/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md +++ b/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md @@ -181,6 +181,23 @@ SetAppraiseInfo(guid, profile): show examination window ``` +`ACCWeenieObject::GetObjectName @ 0x0058E6E0` does more than pluralize: + +```text +baseName = singular or plural name selected for the live stack size +if PublicWeenieDesc.MaterialType > 0: + material = AppraisalSystem.InqMaterialName(MaterialType) + remove material from baseName when it is already authored there + trim the remaining baseName + baseName = material + " " + remaining baseName +return baseName +``` + +The title therefore uses the live CreateObject material field. For example, +an authored `Steel Toed Boots` object whose material is `Reed Shark Hide` +is titled `Reed Shark Hide Steel Toed Boots`; appraisal property 131 is for +description decoration and is not a substitute for this live name field. + Only the first response to an explicit request forces the window visible. Background refreshes update the current object without reopening a closed window. @@ -348,6 +365,9 @@ AddItemInfo(text, fontColorIndex, sameParagraph): append sameParagraph ? "\n" : "\n\n" append text with font-list index 0 and the requested color-list index + # Empty text is meaningful. The separator is still appended, so + # AddItemInfo("", ..., true) preserves an intentional blank row. + ShowValueAndBurden(profile): if profile contains int 19: append "Value: " @@ -537,6 +557,7 @@ ShowTinkering: "Workmanship: ()" blank line "Salvaged from %d items." + append an empty same-paragraph entry unconditionally ShowSet: EquipmentSetId (265) selects the literal EoR set-name table. @@ -550,6 +571,9 @@ ShowRatings: render as "Ratings: Dam 3, Crit 2" (there is no plus sign) positive Vitality (379) is a separate "This item adds %d Vitality." + if any rating or Vitality was emitted: + append an empty same-paragraph entry + SetAppraiseInfo appends one more empty entry when Set or Ratings emitted ShowWeaponAndArmorData: only interpret WeaponProfile inside weapon/shield valid-location bits @@ -559,6 +583,16 @@ ShowWeaponAndArmorData: Upper Legs, Lower Legs, Feet" selecting the names from ClothingPriority bits in that exact order +ShowArmorModsData: + the first "Armor Level" string itself begins with "\n" + AddItemInfo also contributes its ordinary separator, leaving one blank + row between clothing coverage and Armor Level + +ShowSpecialProperties: + begin with an empty same-paragraph entry + carry-limit text has another empty entry before it + shared cooldown metadata and cleave have their retail trailing empty entry + ShowShortMagic: list only ordinary spell ids (high bit clear) a failed appraisal prints "Spells: unknown." @@ -583,6 +617,7 @@ ShowItemLevel: nextThreshold = ItemLevelToTotalXP(min(currentLevel + 1, maxLevel), ...) "Item Level: / " "Item XP: / " + append an empty same-paragraph entry after Item XP ItemLevelToTotalXP(level, base, max, style): clamp level to [0, max] diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index 7e5f4dc6..178b094b 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -649,11 +649,11 @@ public sealed class AppraisalUiController : IRetainedPanelController : AppraisalView.Creature; } - private static string BuildTitle(ClientObject obj, PropertyBundle properties) + private string BuildTitle(ClientObject obj, PropertyBundle properties) { string name = GetString(properties, DisplayedNameStringProperty); if (string.IsNullOrWhiteSpace(name)) - name = obj.GetAppropriateName(); + name = _itemNames.ResolveAppropriateName(obj); return obj.StackSize > 1 ? $"{obj.StackSize.ToString(CultureInfo.CurrentCulture)} {name}" : name; diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs b/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs index 71a4ee3f..3997e6ee 100644 --- a/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs +++ b/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs @@ -87,6 +87,23 @@ internal sealed class ItemAppraisalReportBuilder ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal) => Add(value, sameParagraph: false, style); + /// + /// Preserve an empty retail AddItemInfo("", ..., true) append. + /// Once text exists this contributes one physical empty row; on an empty + /// glyph list retail has no preceding separator to append. + /// + public void BlankLine( + ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal) + { + if (_fragments.Count == 0) + return; + + _fragments.Add(new ItemAppraisalFragment( + string.Empty, + ItemAppraisalSeparator.Line, + style)); + } + public ItemAppraisalReport Build() => _fragments.Count == 0 ? ItemAppraisalReport.Empty diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs index 31f59f06..8aaa60fc 100644 --- a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs +++ b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs @@ -112,28 +112,33 @@ public static class ItemAppraisalTextFormatter 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) + if (properties.Ints.TryGetValue(105u, out int workmanship)) { - 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; + if (properties.Ints.TryGetValue(170u, out int salvagedItems) + && salvagedItems > 0) + { + double average = (double)workmanship / salvagedItems; + int workmanshipBand = Math.Clamp( + (int)Math.Round(average), + 0, + 10); + report.Line( + $"Workmanship: {WorkmanshipAdjective(workmanshipBand)} " + + $"({average.ToString("0.00", CultureInfo.CurrentCulture)})"); + report.Paragraph( + $"Salvaged from {salvagedItems.ToString(CultureInfo.CurrentCulture)} items."); + } + else + { + report.Line( + $"Workmanship: {WorkmanshipAdjective(Math.Clamp(workmanship, 0, 10))} " + + $"({workmanship.ToString(CultureInfo.CurrentCulture)})"); + } } - report.Line( - $"Workmanship: {WorkmanshipAdjective(Math.Clamp(workmanship, 0, 10))} " - + $"({workmanship.ToString(CultureInfo.CurrentCulture)})"); + // Appraisal_ShowTinkeringInfo @ 0x004B1090 appends an empty + // AddItemInfo entry unconditionally, establishing the next section. + report.BlankLine(); } /// @@ -144,11 +149,12 @@ public static class ItemAppraisalTextFormatter RetailReportBuilder report, PropertyBundle properties) { - if (properties.Ints.TryGetValue(265u, out int setId) - && EquipmentSetName(setId) is { } setName) - { + string? setName = properties.Ints.TryGetValue(265u, out int setId) + ? EquipmentSetName(setId) + : null; + bool setShown = setName is not null; + if (setShown) report.Line($"Set: {setName}"); - } (uint Property, string Label)[] ratingProperties = [ @@ -168,11 +174,23 @@ public static class ItemAppraisalTextFormatter .Select(pair => $"{pair.Label} {properties.GetInt(pair.Property).ToString(CultureInfo.CurrentCulture)}") .ToArray(); - if (ratings.Length != 0) + bool ratingsShown = ratings.Length != 0; + if (ratingsShown) report.Line($"Ratings: {string.Join(", ", ratings)}"); if (properties.GetInt(379u) is int vitality && vitality > 0) + { report.Line( $"This item adds {vitality.ToString(CultureInfo.CurrentCulture)} Vitality."); + ratingsShown = true; + } + + // Appraisal_ShowRatings terminates its emitted block with an empty + // append; SetAppraiseInfo adds another when either Set or Ratings + // contributed content. + if (ratingsShown) + report.BlankLine(); + if (setShown || ratingsShown) + report.BlankLine(); } /// @@ -341,7 +359,9 @@ public static class ItemAppraisalTextFormatter return; } - report.Line( + // Retail's first armor string begins with a literal '\n' before + // AddItemInfo adds its ordinary separator. + report.Paragraph( $"Armor Level: {armorLevel.ToString(CultureInfo.CurrentCulture)}", EnchantmentStyle(appraisal.ArmorEnchantments, 0x0001u)); ShowProtection( @@ -492,23 +512,34 @@ public static class ItemAppraisalTextFormatter PropertyBundle properties, RetailAppraisalNameResolver names) { + // Appraisal_ShowSpecialProperties @ 0x004B0159. + report.BlankLine(); + int carryLimit = properties.GetInt(279u); if (carryLimit > 0) + { + report.BlankLine(); report.Line( $"You can only carry " + $"{carryLimit.ToString("N0", CultureInfo.CurrentCulture)} " + "of these items."); + } if (properties.Floats.TryGetValue(167u, out double cooldown) && cooldown > 0d) { report.Line($"Cooldown When Used: {FormatDeltaTime(cooldown)}"); + if (properties.Ints.ContainsKey(280u)) + report.BlankLine(); } int cleave = properties.GetInt(292u); if (cleave > 1) + { report.Line( $"Cleave: {cleave.ToString(CultureInfo.CurrentCulture)} enemies in front arc."); + report.BlankLine(); + } var special = new List(); int slayer = properties.GetInt(166u); @@ -745,16 +776,23 @@ public static class ItemAppraisalTextFormatter PropertyBundle properties) { int level = properties.GetInt(369u); + int skill = properties.GetInt(366u); + int difficulty = properties.GetInt(367u); + int specializedSkill = properties.GetInt(368u); + if (level > 0 + || (skill > 0 && difficulty > 0) + || specializedSkill > 0) + { + report.BlankLine(); + } + if (level > 0) report.Line( $"Use requires level {level.ToString(CultureInfo.CurrentCulture)}."); - int skill = properties.GetInt(366u); - int difficulty = properties.GetInt(367u); if (skill > 0 && difficulty > 0) report.Line( $"Use requires {UsageSkillName(skill)} of at least " + $"{difficulty.ToString(CultureInfo.CurrentCulture)}."); - int specializedSkill = properties.GetInt(368u); if (specializedSkill > 0) report.Line( $"Use requires specialized {UsageSkillName(specializedSkill)}."); @@ -794,11 +832,13 @@ public static class ItemAppraisalTextFormatter report.Line( $"Item XP: {experience.ToString("N0", CultureInfo.CurrentCulture)} / " + $"{nextExperience.ToString("N0", CultureInfo.CurrentCulture)}"); + report.BlankLine(); if (properties.GetInt(352u) == 2) { - report.Paragraph( + report.Line( "This cloak has a chance to reduce an incoming attack by 200 damage."); + report.BlankLine(); } } @@ -1776,6 +1816,10 @@ public static class ItemAppraisalTextFormatter ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal) => _report.Paragraph(value, style); + public void BlankLine( + ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal) + => _report.BlankLine(style); + public ItemAppraisalReport Build() => _report.Build(); } } diff --git a/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs b/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs index 653711dd..7546525f 100644 --- a/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs +++ b/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs @@ -1,4 +1,5 @@ using AcDream.Content; +using AcDream.Core.Items; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -86,6 +87,33 @@ public sealed class RetailAppraisalNameResolver ? name : string.Empty; + /// + /// Port of the material-decoration tail in + /// ACCWeenieObject::GetObjectName @ 0x0058E6E0. Retail first + /// chooses the singular/plural base name, removes an already-authored + /// occurrence of the resolved material, trims it, then prefixes the + /// material exactly once. + /// + public string ResolveAppropriateName(ClientObject obj) + { + ArgumentNullException.ThrowIfNull(obj); + + string baseName = obj.GetAppropriateName(); + if (obj.MaterialType is not { } materialType) + return baseName; + + string material = ResolveMaterial(unchecked((int)materialType)); + if (string.IsNullOrEmpty(material)) + return baseName; + + string remainder = baseName + .Replace(material, string.Empty, StringComparison.Ordinal) + .Trim(); + return string.IsNullOrEmpty(remainder) + ? material + : $"{material} {remainder}"; + } + private static string Normalize(string value) => value.Replace('_', ' '); } diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index f36dd25b..630f5575 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -223,7 +223,11 @@ public static class CreateObject // Complete immutable PhysicsDesc projection. Kept alongside the // legacy convenience fields while live-entity ownership migrates to // LiveEntityRuntime in Step 2. - PhysicsSpawnData? Physics = null); + PhysicsSpawnData? Physics = null, + // PublicWeenieDesc._material_type, gated by PWD_Packed_MaterialType. + // ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) uses this live + // spawn field to prefix the authored material name. + uint? MaterialType = null); /// /// The relevant subset of the server-sent MovementData / @@ -865,6 +869,13 @@ public static class CreateObject // 0x40000000 IconOverlay PackedDwordKnownType(0x06000000) CAPTURE // weenieFlags2 bit 0x01: // IconUnderlay PackedDwordKnownType(0x06000000) CAPTURE + // 0x80000000 MaterialType u32 CAPTURE + // weenieFlags2 bit 0x02: + // CooldownId u32 CAPTURE + // weenieFlags2 bit 0x04: + // CooldownDuration f64 CAPTURE + // weenieFlags2 bit 0x08: + // PetOwner u32 CAPTURE // // The entire walk is inside try/catch. A truncated packet degrades // gracefully: whatever was parsed before the throw is kept, and @@ -885,6 +896,7 @@ public static class CreateObject uint? petOwnerId = null; uint? cooldownId = null; double? cooldownDuration = null; + uint? materialType = null; try { // BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458 @@ -1092,7 +1104,7 @@ public static class CreateObject if ((weenieFlags & 0x80000000u) != 0) // MaterialType u32 { if (body.Length - pos < 4) throw new FormatException("trunc MaterialType"); - pos += 4; + materialType = ReadU32(body, ref pos); } if ((weenieFlags2 & 0x00000002u) != 0) // CooldownId u32 { @@ -1145,7 +1157,8 @@ public static class CreateObject SpellId: spellId, CooldownId: cooldownId, CooldownDuration: cooldownDuration, - Physics: physics); + Physics: physics, + MaterialType: materialType); } catch { diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs index 306d7aeb..b1522c93 100644 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -188,5 +188,6 @@ public static class ObjectTableWiring AmmoType: s.AmmoType, SpellId: s.SpellId, CooldownId: s.CooldownId, - CooldownDuration: s.CooldownDuration); + CooldownDuration: s.CooldownDuration, + MaterialType: s.MaterialType); } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 0f3f0d06..cbc2ca6e 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -158,7 +158,8 @@ public sealed class WorldSession : IDisposable double? CooldownDuration = null, PhysicsSpawnData? Physics = null, uint? HookItemTypes = null, - uint? HookType = null); + uint? HookType = null, + uint? MaterialType = null); /// /// Projects the wire-level CreateObject result into the stable session @@ -223,7 +224,8 @@ public sealed class WorldSession : IDisposable CooldownDuration: parsed.CooldownDuration, Physics: parsed.Physics, HookItemTypes: parsed.HookItemTypes, - HookType: parsed.HookType); + HookType: parsed.HookType, + MaterialType: parsed.MaterialType); /// 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 8c399282..8cb5f0d4 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -224,12 +224,21 @@ public sealed class ClientObject public int Structure { get; set; } // charges/uses remaining public int MaxStructure { get; set; } public float Workmanship { get; set; } // 0..10 (fractional on the wire) + /// + /// Retail PublicWeenieDesc._material_type. The appropriate-name + /// path resolves this through the DAT material enum and prefixes it to + /// the base object name. + /// + public uint? MaterialType { get; set; } public PropertyBundle Properties { get; } = new(); /// - /// Ports ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) @ 0x0058E6E0: - /// stacked objects prefer ; when the wire omitted it, - /// retail appends s, or es when the singular already ends in lowercase s. + /// Ports the singular/plural selection inside + /// ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) @ 0x0058E6E0. + /// Stacked objects prefer ; when the wire omitted it, + /// retail appends s, or es when the singular already ends in + /// lowercase s. DAT-backed material decoration belongs to the presentation + /// resolver because Core intentionally has no content-reader dependency. /// public string GetAppropriateName() { @@ -285,7 +294,8 @@ public readonly record struct WeenieData( uint? CooldownId = null, double? CooldownDuration = null, uint? HookItemTypes = null, - uint? HookType = null); + uint? HookType = null, + uint? MaterialType = 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 bf7df10b..0fdb1dcb 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -773,6 +773,7 @@ public sealed class ClientObjectTable if (d.Structure is { } st) obj.Structure = st; if (d.MaxStructure is { } ms) obj.MaxStructure = ms; if (d.Workmanship is { } wm) obj.Workmanship = wm; + if (d.MaterialType is { } materialType) obj.MaterialType = materialType; List? changedContainers = RemoveFromOtherContainerIndexes( obj.ObjectId, diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index 40d2629a..93a1bc73 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -93,6 +93,43 @@ public sealed class AppraisalUiControllerTests Assert.Equal(1, closed); } + [Fact] + public void ItemResponse_TitleUsesRetailMaterialDecoratedAppropriateName() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Steel Toed Boots", + Type = ItemType.Clothing, + MaterialType = 77u, + }); + using var interaction = NewInteraction(objects, []); + var names = new RetailAppraisalNameResolver( + new Dictionary { [77u] = "Reed Shark Hide" }, + new CreatureDisplayNameResolver(new Dictionary())); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + itemNames: names)!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + Assert.True(controller.Apply(Parsed(new PropertyBundle()))); + + UiText title = Assert.IsType( + layout.FindElement(AppraisalUiController.TitleId)); + Assert.Equal( + "Reed Shark Hide Steel Toed Boots", + Assert.Single(title.LinesProvider()).Text); + } + [Fact] public void CreatureResponse_SelectsCreatureSubviewAndRefreshesInCombatWithoutBusy() { @@ -662,7 +699,8 @@ public sealed class AppraisalUiControllerTests Action close, CreatureAppraisalRowTemplateFactory? creatureRows = null, CreatureDisplayNameResolver? creatureNames = null, - SelectionState? selection = null) + SelectionState? selection = null, + RetailAppraisalNameResolver? itemNames = null) => AppraisalUiController.Bind( layout, objects, @@ -676,7 +714,8 @@ public sealed class AppraisalUiControllerTests show, close, creatureRows, - creatureNames); + creatureNames, + itemNames); private static ItemInteractionController NewInteraction( ClientObjectTable objects, diff --git a/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs b/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs index 33fa589b..24e4fe75 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs @@ -112,7 +112,7 @@ public sealed class ItemAppraisalTextFormatterTests _ => null); Assert.Equal( - "Value: ???\nBurden: Unknown\n\n" + "Value: ???\nBurden: Unknown\n\n\n\n" + "A hollowed out tree trunk that has a Phyntos Wasp Hive in it.", report.ToString()); Assert.DoesNotContain("Can hold", report.ToString()); @@ -128,6 +128,16 @@ public sealed class ItemAppraisalTextFormatterTests Assert.Equal(ItemAppraisalSeparator.Line, burden.Separator); Assert.Equal("Burden: Unknown", burden.Text); }, + tinkeringBoundary => + { + Assert.Equal(ItemAppraisalSeparator.Line, tinkeringBoundary.Separator); + Assert.Equal(string.Empty, tinkeringBoundary.Text); + }, + specialPropertiesBoundary => + { + Assert.Equal(ItemAppraisalSeparator.Line, specialPropertiesBoundary.Separator); + Assert.Equal(string.Empty, specialPropertiesBoundary.Text); + }, description => { Assert.Equal(ItemAppraisalSeparator.Paragraph, description.Separator); @@ -180,11 +190,59 @@ public sealed class ItemAppraisalTextFormatterTests _ => null); Assert.Equal( - "Value: ???\nBurden: Unknown\n\n" + "Value: ???\nBurden: Unknown\n\n\n\n" + "Can hold up to 24 items and 1 containers.", report); } + [Fact] + public void Boots_PreserveRetailBlankSectionBoundaries() + { + var obj = new ClientObject + { + ObjectId = 0x50000016u, + Name = "Steel Toed Boots", + Type = ItemType.Clothing, + ValidLocations = EquipMask.FootWear, + Priority = 0x0001_0000u, + }; + var properties = new PropertyBundle(); + properties.Ints[19u] = 1_250; + properties.Ints[5u] = 180; + properties.Ints[105u] = 7; + properties.Ints[28u] = 80; + AppraiseInfoParser.Parsed appraisal = Parsed( + properties, + armor: new AppraiseInfoParser.ArmorProfile( + SlashingProtection: 1f, + PiercingProtection: 1f, + BludgeoningProtection: 1f, + ColdProtection: 1f, + FireProtection: 1f, + AcidProtection: 1f, + NetherProtection: 1f, + LightningProtection: 1f)); + + ItemAppraisalReport report = ItemAppraisalTextFormatter.BuildReport( + obj, + appraisal, + _ => null); + + Assert.Contains( + "Workmanship: Flawless (7)\n\nCovers Feet\n\nArmor Level: 80", + report.ToString()); + var text = new UiText { Width = 1_000f }; + string[] lines = ItemAppraisalTextLayout.Shape(text, report) + .Select(line => line.Text) + .ToArray(); + int workmanship = Array.IndexOf(lines, "Workmanship: Flawless (7)"); + Assert.True(workmanship >= 0); + Assert.Equal(string.Empty, lines[workmanship + 1]); + Assert.Equal("Covers Feet", lines[workmanship + 2]); + Assert.Equal(string.Empty, lines[workmanship + 3]); + Assert.Equal("Armor Level: 80", lines[workmanship + 4]); + } + [Fact] public void BookCapacity_UsesRetailUsedThenTotalPropertyOrder() { diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs index c72836d9..73af2534 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs @@ -681,6 +681,7 @@ public sealed class CreateObjectTests var parsed = CreateObject.TryParse(body); Assert.NotNull(parsed); + Assert.Equal(7u, parsed.Value.MaterialType); Assert.Equal(11u, parsed.Value.CooldownId); Assert.Equal(12.5, parsed.Value.CooldownDuration); Assert.Equal(0x50000001u, parsed.Value.PetOwnerId); diff --git a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs index 5e163d33..ffc4f0b2 100644 --- a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs @@ -70,6 +70,7 @@ public sealed class ObjectTableWiringTests PetOwnerId = 0x50000001u, CooldownId = 42u, CooldownDuration = 30d, + MaterialType = 77u, }; var d = ObjectTableWiring.ToWeenieData(spawn); @@ -122,6 +123,7 @@ public sealed class ObjectTableWiringTests Assert.Equal(0x50000001u, d.PetOwnerId); Assert.Equal(42u, d.CooldownId); Assert.Equal(30d, d.CooldownDuration); + Assert.Equal(77u, d.MaterialType); } [Fact] diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs index 2aab86d8..dcdc8898 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs @@ -50,6 +50,17 @@ public sealed class WorldSessionRadarTests Assert.Equal(0x50000002u, spawn.PetOwnerId); } + [Fact] + public void ToEntitySpawn_PreservesMaterialType() + { + var spawn = WorldSession.ToEntitySpawn(MinimalParsed() with + { + MaterialType = 77u, + }); + + Assert.Equal(77u, spawn.MaterialType); + } + private static CreateObject.Parsed MinimalParsed() => new( Guid: 0x50000001u, Position: null, diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 62700b22..0fc6daba 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -471,6 +471,16 @@ public sealed class ClientObjectTableTests Assert.Equal("Pyreal Scarabs", table.Get(0x500000B9u)!.GetAppropriateName()); } + [Fact] + public void Ingest_MaterialType_PreservesWireValueWithoutClobberingWhenAbsent() + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x500000BCu) with { MaterialType = 77u }); + table.Ingest(FullWeenie(0x500000BCu) with { MaterialType = null }); + + Assert.Equal(77u, table.Get(0x500000BCu)!.MaterialType); + } + [Fact] public void Ingest_AmmoType_PreservesWireValueForAutoWieldCompatibility() {