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 <codex@openai.com>
This commit is contained in:
Erik 2026-07-24 05:21:01 +02:00
parent d78d09cfbc
commit 6718ee45a0
19 changed files with 341 additions and 46 deletions

View file

@ -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 character detail, and creature FontInfo state; the connected item visual is
the remaining acceptance gate. 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`; **Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`;
`src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`;
`src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`;

View file

@ -205,7 +205,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | ~~AP-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-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-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-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-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` | | AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |

View file

@ -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 3 skips, and the complete Release solution passes 7,952 tests / 5 skips. The
connected item-report comparison is the remaining Slice 3 gate. 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 ## Slice 1 — spell-bar overflow arrows
### Retail oracle ### Retail oracle

View file

@ -181,6 +181,23 @@ SetAppraiseInfo(guid, profile):
show examination window 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. Only the first response to an explicit request forces the window visible.
Background refreshes update the current object without reopening a closed Background refreshes update the current object without reopening a closed
window. window.
@ -348,6 +365,9 @@ AddItemInfo(text, fontColorIndex, sameParagraph):
append sameParagraph ? "\n" : "\n\n" append sameParagraph ? "\n" : "\n\n"
append text with font-list index 0 and the requested color-list index 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): ShowValueAndBurden(profile):
if profile contains int 19: if profile contains int 19:
append "Value: <grouped value>" append "Value: <grouped value>"
@ -537,6 +557,7 @@ ShowTinkering:
"Workmanship: <adjective of rounded average> (<average, two decimals>)" "Workmanship: <adjective of rounded average> (<average, two decimals>)"
blank line blank line
"Salvaged from %d items." "Salvaged from %d items."
append an empty same-paragraph entry unconditionally
ShowSet: ShowSet:
EquipmentSetId (265) selects the literal EoR set-name table. 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) render as "Ratings: Dam 3, Crit 2" (there is no plus sign)
positive Vitality (379) is a separate positive Vitality (379) is a separate
"This item adds %d Vitality." "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: ShowWeaponAndArmorData:
only interpret WeaponProfile inside weapon/shield valid-location bits only interpret WeaponProfile inside weapon/shield valid-location bits
@ -559,6 +583,16 @@ ShowWeaponAndArmorData:
Upper Legs, Lower Legs, Feet" Upper Legs, Lower Legs, Feet"
selecting the names from ClothingPriority bits in that exact order 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: ShowShortMagic:
list only ordinary spell ids (high bit clear) list only ordinary spell ids (high bit clear)
a failed appraisal prints "Spells: unknown." a failed appraisal prints "Spells: unknown."
@ -583,6 +617,7 @@ ShowItemLevel:
nextThreshold = ItemLevelToTotalXP(min(currentLevel + 1, maxLevel), ...) nextThreshold = ItemLevelToTotalXP(min(currentLevel + 1, maxLevel), ...)
"Item Level: <displayedLevel> / <maxLevel>" "Item Level: <displayedLevel> / <maxLevel>"
"Item XP: <grouped total> / <grouped nextThreshold>" "Item XP: <grouped total> / <grouped nextThreshold>"
append an empty same-paragraph entry after Item XP
ItemLevelToTotalXP(level, base, max, style): ItemLevelToTotalXP(level, base, max, style):
clamp level to [0, max] clamp level to [0, max]

View file

@ -649,11 +649,11 @@ public sealed class AppraisalUiController : IRetainedPanelController
: AppraisalView.Creature; : AppraisalView.Creature;
} }
private static string BuildTitle(ClientObject obj, PropertyBundle properties) private string BuildTitle(ClientObject obj, PropertyBundle properties)
{ {
string name = GetString(properties, DisplayedNameStringProperty); string name = GetString(properties, DisplayedNameStringProperty);
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
name = obj.GetAppropriateName(); name = _itemNames.ResolveAppropriateName(obj);
return obj.StackSize > 1 return obj.StackSize > 1
? $"{obj.StackSize.ToString(CultureInfo.CurrentCulture)} {name}" ? $"{obj.StackSize.ToString(CultureInfo.CurrentCulture)} {name}"
: name; : name;

View file

@ -87,6 +87,23 @@ internal sealed class ItemAppraisalReportBuilder
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal) ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
=> Add(value, sameParagraph: false, style); => Add(value, sameParagraph: false, style);
/// <summary>
/// Preserve an empty retail <c>AddItemInfo("", ..., true)</c> append.
/// Once text exists this contributes one physical empty row; on an empty
/// glyph list retail has no preceding separator to append.
/// </summary>
public void BlankLine(
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
{
if (_fragments.Count == 0)
return;
_fragments.Add(new ItemAppraisalFragment(
string.Empty,
ItemAppraisalSeparator.Line,
style));
}
public ItemAppraisalReport Build() public ItemAppraisalReport Build()
=> _fragments.Count == 0 => _fragments.Count == 0
? ItemAppraisalReport.Empty ? ItemAppraisalReport.Empty

View file

@ -112,9 +112,8 @@ public static class ItemAppraisalTextFormatter
if (!string.IsNullOrWhiteSpace(imbuedBy)) if (!string.IsNullOrWhiteSpace(imbuedBy))
report.Line($"Imbued by {imbuedBy}."); report.Line($"Imbued by {imbuedBy}.");
if (!properties.Ints.TryGetValue(105u, out int workmanship)) if (properties.Ints.TryGetValue(105u, out int workmanship))
return; {
if (properties.Ints.TryGetValue(170u, out int salvagedItems) if (properties.Ints.TryGetValue(170u, out int salvagedItems)
&& salvagedItems > 0) && salvagedItems > 0)
{ {
@ -128,13 +127,19 @@ public static class ItemAppraisalTextFormatter
+ $"({average.ToString("0.00", CultureInfo.CurrentCulture)})"); + $"({average.ToString("0.00", CultureInfo.CurrentCulture)})");
report.Paragraph( report.Paragraph(
$"Salvaged from {salvagedItems.ToString(CultureInfo.CurrentCulture)} items."); $"Salvaged from {salvagedItems.ToString(CultureInfo.CurrentCulture)} items.");
return;
} }
else
{
report.Line( report.Line(
$"Workmanship: {WorkmanshipAdjective(Math.Clamp(workmanship, 0, 10))} " $"Workmanship: {WorkmanshipAdjective(Math.Clamp(workmanship, 0, 10))} "
+ $"({workmanship.ToString(CultureInfo.CurrentCulture)})"); + $"({workmanship.ToString(CultureInfo.CurrentCulture)})");
} }
}
// Appraisal_ShowTinkeringInfo @ 0x004B1090 appends an empty
// AddItemInfo entry unconditionally, establishing the next section.
report.BlankLine();
}
/// <summary> /// <summary>
/// <c>Appraisal_ShowSet @ 0x004AE880</c> and /// <c>Appraisal_ShowSet @ 0x004AE880</c> and
@ -144,11 +149,12 @@ public static class ItemAppraisalTextFormatter
RetailReportBuilder report, RetailReportBuilder report,
PropertyBundle properties) PropertyBundle properties)
{ {
if (properties.Ints.TryGetValue(265u, out int setId) string? setName = properties.Ints.TryGetValue(265u, out int setId)
&& EquipmentSetName(setId) is { } setName) ? EquipmentSetName(setId)
{ : null;
bool setShown = setName is not null;
if (setShown)
report.Line($"Set: {setName}"); report.Line($"Set: {setName}");
}
(uint Property, string Label)[] ratingProperties = (uint Property, string Label)[] ratingProperties =
[ [
@ -168,11 +174,23 @@ public static class ItemAppraisalTextFormatter
.Select(pair => .Select(pair =>
$"{pair.Label} {properties.GetInt(pair.Property).ToString(CultureInfo.CurrentCulture)}") $"{pair.Label} {properties.GetInt(pair.Property).ToString(CultureInfo.CurrentCulture)}")
.ToArray(); .ToArray();
if (ratings.Length != 0) bool ratingsShown = ratings.Length != 0;
if (ratingsShown)
report.Line($"Ratings: {string.Join(", ", ratings)}"); report.Line($"Ratings: {string.Join(", ", ratings)}");
if (properties.GetInt(379u) is int vitality && vitality > 0) if (properties.GetInt(379u) is int vitality && vitality > 0)
{
report.Line( report.Line(
$"This item adds {vitality.ToString(CultureInfo.CurrentCulture)} Vitality."); $"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();
} }
/// <summary> /// <summary>
@ -341,7 +359,9 @@ public static class ItemAppraisalTextFormatter
return; 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)}", $"Armor Level: {armorLevel.ToString(CultureInfo.CurrentCulture)}",
EnchantmentStyle(appraisal.ArmorEnchantments, 0x0001u)); EnchantmentStyle(appraisal.ArmorEnchantments, 0x0001u));
ShowProtection( ShowProtection(
@ -492,23 +512,34 @@ public static class ItemAppraisalTextFormatter
PropertyBundle properties, PropertyBundle properties,
RetailAppraisalNameResolver names) RetailAppraisalNameResolver names)
{ {
// Appraisal_ShowSpecialProperties @ 0x004B0159.
report.BlankLine();
int carryLimit = properties.GetInt(279u); int carryLimit = properties.GetInt(279u);
if (carryLimit > 0) if (carryLimit > 0)
{
report.BlankLine();
report.Line( report.Line(
$"You can only carry " $"You can only carry "
+ $"{carryLimit.ToString("N0", CultureInfo.CurrentCulture)} " + $"{carryLimit.ToString("N0", CultureInfo.CurrentCulture)} "
+ "of these items."); + "of these items.");
}
if (properties.Floats.TryGetValue(167u, out double cooldown) if (properties.Floats.TryGetValue(167u, out double cooldown)
&& cooldown > 0d) && cooldown > 0d)
{ {
report.Line($"Cooldown When Used: {FormatDeltaTime(cooldown)}"); report.Line($"Cooldown When Used: {FormatDeltaTime(cooldown)}");
if (properties.Ints.ContainsKey(280u))
report.BlankLine();
} }
int cleave = properties.GetInt(292u); int cleave = properties.GetInt(292u);
if (cleave > 1) if (cleave > 1)
{
report.Line( report.Line(
$"Cleave: {cleave.ToString(CultureInfo.CurrentCulture)} enemies in front arc."); $"Cleave: {cleave.ToString(CultureInfo.CurrentCulture)} enemies in front arc.");
report.BlankLine();
}
var special = new List<string>(); var special = new List<string>();
int slayer = properties.GetInt(166u); int slayer = properties.GetInt(166u);
@ -745,16 +776,23 @@ public static class ItemAppraisalTextFormatter
PropertyBundle properties) PropertyBundle properties)
{ {
int level = properties.GetInt(369u); 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) if (level > 0)
report.Line( report.Line(
$"Use requires level {level.ToString(CultureInfo.CurrentCulture)}."); $"Use requires level {level.ToString(CultureInfo.CurrentCulture)}.");
int skill = properties.GetInt(366u);
int difficulty = properties.GetInt(367u);
if (skill > 0 && difficulty > 0) if (skill > 0 && difficulty > 0)
report.Line( report.Line(
$"Use requires {UsageSkillName(skill)} of at least " $"Use requires {UsageSkillName(skill)} of at least "
+ $"{difficulty.ToString(CultureInfo.CurrentCulture)}."); + $"{difficulty.ToString(CultureInfo.CurrentCulture)}.");
int specializedSkill = properties.GetInt(368u);
if (specializedSkill > 0) if (specializedSkill > 0)
report.Line( report.Line(
$"Use requires specialized {UsageSkillName(specializedSkill)}."); $"Use requires specialized {UsageSkillName(specializedSkill)}.");
@ -794,11 +832,13 @@ public static class ItemAppraisalTextFormatter
report.Line( report.Line(
$"Item XP: {experience.ToString("N0", CultureInfo.CurrentCulture)} / " $"Item XP: {experience.ToString("N0", CultureInfo.CurrentCulture)} / "
+ $"{nextExperience.ToString("N0", CultureInfo.CurrentCulture)}"); + $"{nextExperience.ToString("N0", CultureInfo.CurrentCulture)}");
report.BlankLine();
if (properties.GetInt(352u) == 2) if (properties.GetInt(352u) == 2)
{ {
report.Paragraph( report.Line(
"This cloak has a chance to reduce an incoming attack by 200 damage."); "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) ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
=> _report.Paragraph(value, style); => _report.Paragraph(value, style);
public void BlankLine(
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
=> _report.BlankLine(style);
public ItemAppraisalReport Build() => _report.Build(); public ItemAppraisalReport Build() => _report.Build();
} }
} }

View file

@ -1,4 +1,5 @@
using AcDream.Content; using AcDream.Content;
using AcDream.Core.Items;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Types; using DatReaderWriter.Types;
@ -86,6 +87,33 @@ public sealed class RetailAppraisalNameResolver
? name ? name
: string.Empty; : string.Empty;
/// <summary>
/// Port of the material-decoration tail in
/// <c>ACCWeenieObject::GetObjectName @ 0x0058E6E0</c>. 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.
/// </summary>
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) private static string Normalize(string value)
=> value.Replace('_', ' '); => value.Replace('_', ' ');
} }

View file

@ -223,7 +223,11 @@ public static class CreateObject
// Complete immutable PhysicsDesc projection. Kept alongside the // Complete immutable PhysicsDesc projection. Kept alongside the
// legacy convenience fields while live-entity ownership migrates to // legacy convenience fields while live-entity ownership migrates to
// LiveEntityRuntime in Step 2. // 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);
/// <summary> /// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> / /// The relevant subset of the server-sent <c>MovementData</c> /
@ -865,6 +869,13 @@ public static class CreateObject
// 0x40000000 IconOverlay PackedDwordKnownType(0x06000000) CAPTURE // 0x40000000 IconOverlay PackedDwordKnownType(0x06000000) CAPTURE
// weenieFlags2 bit 0x01: // weenieFlags2 bit 0x01:
// IconUnderlay PackedDwordKnownType(0x06000000) CAPTURE // 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 // The entire walk is inside try/catch. A truncated packet degrades
// gracefully: whatever was parsed before the throw is kept, and // gracefully: whatever was parsed before the throw is kept, and
@ -885,6 +896,7 @@ public static class CreateObject
uint? petOwnerId = null; uint? petOwnerId = null;
uint? cooldownId = null; uint? cooldownId = null;
double? cooldownDuration = null; double? cooldownDuration = null;
uint? materialType = null;
try try
{ {
// BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458 // BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458
@ -1092,7 +1104,7 @@ public static class CreateObject
if ((weenieFlags & 0x80000000u) != 0) // MaterialType u32 if ((weenieFlags & 0x80000000u) != 0) // MaterialType u32
{ {
if (body.Length - pos < 4) throw new FormatException("trunc MaterialType"); if (body.Length - pos < 4) throw new FormatException("trunc MaterialType");
pos += 4; materialType = ReadU32(body, ref pos);
} }
if ((weenieFlags2 & 0x00000002u) != 0) // CooldownId u32 if ((weenieFlags2 & 0x00000002u) != 0) // CooldownId u32
{ {
@ -1145,7 +1157,8 @@ public static class CreateObject
SpellId: spellId, SpellId: spellId,
CooldownId: cooldownId, CooldownId: cooldownId,
CooldownDuration: cooldownDuration, CooldownDuration: cooldownDuration,
Physics: physics); Physics: physics,
MaterialType: materialType);
} }
catch catch
{ {

View file

@ -188,5 +188,6 @@ public static class ObjectTableWiring
AmmoType: s.AmmoType, AmmoType: s.AmmoType,
SpellId: s.SpellId, SpellId: s.SpellId,
CooldownId: s.CooldownId, CooldownId: s.CooldownId,
CooldownDuration: s.CooldownDuration); CooldownDuration: s.CooldownDuration,
MaterialType: s.MaterialType);
} }

View file

@ -158,7 +158,8 @@ public sealed class WorldSession : IDisposable
double? CooldownDuration = null, double? CooldownDuration = null,
PhysicsSpawnData? Physics = null, PhysicsSpawnData? Physics = null,
uint? HookItemTypes = null, uint? HookItemTypes = null,
uint? HookType = null); uint? HookType = null,
uint? MaterialType = null);
/// <summary> /// <summary>
/// Projects the wire-level CreateObject result into the stable session /// Projects the wire-level CreateObject result into the stable session
@ -223,7 +224,8 @@ public sealed class WorldSession : IDisposable
CooldownDuration: parsed.CooldownDuration, CooldownDuration: parsed.CooldownDuration,
Physics: parsed.Physics, Physics: parsed.Physics,
HookItemTypes: parsed.HookItemTypes, HookItemTypes: parsed.HookItemTypes,
HookType: parsed.HookType); HookType: parsed.HookType,
MaterialType: parsed.MaterialType);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary> /// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned; public event Action<EntitySpawn>? EntitySpawned;

View file

@ -224,12 +224,21 @@ public sealed class ClientObject
public int Structure { get; set; } // charges/uses remaining public int Structure { get; set; } // charges/uses remaining
public int MaxStructure { get; set; } public int MaxStructure { get; set; }
public float Workmanship { get; set; } // 0..10 (fractional on the wire) public float Workmanship { get; set; } // 0..10 (fractional on the wire)
/// <summary>
/// Retail <c>PublicWeenieDesc._material_type</c>. The appropriate-name
/// path resolves this through the DAT material enum and prefixes it to
/// the base object name.
/// </summary>
public uint? MaterialType { get; set; }
public PropertyBundle Properties { get; } = new(); public PropertyBundle Properties { get; } = new();
/// <summary> /// <summary>
/// Ports <c>ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) @ 0x0058E6E0</c>: /// Ports the singular/plural selection inside
/// stacked objects prefer <see cref="PluralName"/>; when the wire omitted it, /// <c>ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) @ 0x0058E6E0</c>.
/// retail appends <c>s</c>, or <c>es</c> when the singular already ends in lowercase s. /// Stacked objects prefer <see cref="PluralName"/>; when the wire omitted it,
/// retail appends <c>s</c>, or <c>es</c> 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.
/// </summary> /// </summary>
public string GetAppropriateName() public string GetAppropriateName()
{ {
@ -285,7 +294,8 @@ public readonly record struct WeenieData(
uint? CooldownId = null, uint? CooldownId = null,
double? CooldownDuration = null, double? CooldownDuration = null,
uint? HookItemTypes = null, uint? HookItemTypes = null,
uint? HookType = null); uint? HookType = null,
uint? MaterialType = null);
/// <summary> /// <summary>
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0). /// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).

View file

@ -773,6 +773,7 @@ public sealed class ClientObjectTable
if (d.Structure is { } st) obj.Structure = st; if (d.Structure is { } st) obj.Structure = st;
if (d.MaxStructure is { } ms) obj.MaxStructure = ms; if (d.MaxStructure is { } ms) obj.MaxStructure = ms;
if (d.Workmanship is { } wm) obj.Workmanship = wm; if (d.Workmanship is { } wm) obj.Workmanship = wm;
if (d.MaterialType is { } materialType) obj.MaterialType = materialType;
List<uint>? changedContainers = RemoveFromOtherContainerIndexes( List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
obj.ObjectId, obj.ObjectId,

View file

@ -93,6 +93,43 @@ public sealed class AppraisalUiControllerTests
Assert.Equal(1, closed); 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<uint, string> { [77u] = "Reed Shark Hide" },
new CreatureDisplayNameResolver(new Dictionary<uint, string>()));
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<UiText>(
layout.FindElement(AppraisalUiController.TitleId));
Assert.Equal(
"Reed Shark Hide Steel Toed Boots",
Assert.Single(title.LinesProvider()).Text);
}
[Fact] [Fact]
public void CreatureResponse_SelectsCreatureSubviewAndRefreshesInCombatWithoutBusy() public void CreatureResponse_SelectsCreatureSubviewAndRefreshesInCombatWithoutBusy()
{ {
@ -662,7 +699,8 @@ public sealed class AppraisalUiControllerTests
Action close, Action close,
CreatureAppraisalRowTemplateFactory? creatureRows = null, CreatureAppraisalRowTemplateFactory? creatureRows = null,
CreatureDisplayNameResolver? creatureNames = null, CreatureDisplayNameResolver? creatureNames = null,
SelectionState? selection = null) SelectionState? selection = null,
RetailAppraisalNameResolver? itemNames = null)
=> AppraisalUiController.Bind( => AppraisalUiController.Bind(
layout, layout,
objects, objects,
@ -676,7 +714,8 @@ public sealed class AppraisalUiControllerTests
show, show,
close, close,
creatureRows, creatureRows,
creatureNames); creatureNames,
itemNames);
private static ItemInteractionController NewInteraction( private static ItemInteractionController NewInteraction(
ClientObjectTable objects, ClientObjectTable objects,

View file

@ -112,7 +112,7 @@ public sealed class ItemAppraisalTextFormatterTests
_ => null); _ => null);
Assert.Equal( 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.", + "A hollowed out tree trunk that has a Phyntos Wasp Hive in it.",
report.ToString()); report.ToString());
Assert.DoesNotContain("Can hold", report.ToString()); Assert.DoesNotContain("Can hold", report.ToString());
@ -128,6 +128,16 @@ public sealed class ItemAppraisalTextFormatterTests
Assert.Equal(ItemAppraisalSeparator.Line, burden.Separator); Assert.Equal(ItemAppraisalSeparator.Line, burden.Separator);
Assert.Equal("Burden: Unknown", burden.Text); 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 => description =>
{ {
Assert.Equal(ItemAppraisalSeparator.Paragraph, description.Separator); Assert.Equal(ItemAppraisalSeparator.Paragraph, description.Separator);
@ -180,11 +190,59 @@ public sealed class ItemAppraisalTextFormatterTests
_ => null); _ => null);
Assert.Equal( Assert.Equal(
"Value: ???\nBurden: Unknown\n\n" "Value: ???\nBurden: Unknown\n\n\n\n"
+ "Can hold up to 24 items and 1 containers.", + "Can hold up to 24 items and 1 containers.",
report); 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] [Fact]
public void BookCapacity_UsesRetailUsedThenTotalPropertyOrder() public void BookCapacity_UsesRetailUsedThenTotalPropertyOrder()
{ {

View file

@ -681,6 +681,7 @@ public sealed class CreateObjectTests
var parsed = CreateObject.TryParse(body); var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed); Assert.NotNull(parsed);
Assert.Equal(7u, parsed.Value.MaterialType);
Assert.Equal(11u, parsed.Value.CooldownId); Assert.Equal(11u, parsed.Value.CooldownId);
Assert.Equal(12.5, parsed.Value.CooldownDuration); Assert.Equal(12.5, parsed.Value.CooldownDuration);
Assert.Equal(0x50000001u, parsed.Value.PetOwnerId); Assert.Equal(0x50000001u, parsed.Value.PetOwnerId);

View file

@ -70,6 +70,7 @@ public sealed class ObjectTableWiringTests
PetOwnerId = 0x50000001u, PetOwnerId = 0x50000001u,
CooldownId = 42u, CooldownId = 42u,
CooldownDuration = 30d, CooldownDuration = 30d,
MaterialType = 77u,
}; };
var d = ObjectTableWiring.ToWeenieData(spawn); var d = ObjectTableWiring.ToWeenieData(spawn);
@ -122,6 +123,7 @@ public sealed class ObjectTableWiringTests
Assert.Equal(0x50000001u, d.PetOwnerId); Assert.Equal(0x50000001u, d.PetOwnerId);
Assert.Equal(42u, d.CooldownId); Assert.Equal(42u, d.CooldownId);
Assert.Equal(30d, d.CooldownDuration); Assert.Equal(30d, d.CooldownDuration);
Assert.Equal(77u, d.MaterialType);
} }
[Fact] [Fact]

View file

@ -50,6 +50,17 @@ public sealed class WorldSessionRadarTests
Assert.Equal(0x50000002u, spawn.PetOwnerId); 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( private static CreateObject.Parsed MinimalParsed() => new(
Guid: 0x50000001u, Guid: 0x50000001u,
Position: null, Position: null,

View file

@ -471,6 +471,16 @@ public sealed class ClientObjectTableTests
Assert.Equal("Pyreal Scarabs", table.Get(0x500000B9u)!.GetAppropriateName()); 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] [Fact]
public void Ingest_AmmoType_PreservesWireValueForAutoWieldCompatibility() public void Ingest_AmmoType_PreservesWireValueForAutoWieldCompatibility()
{ {