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:
parent
d78d09cfbc
commit
6718ee45a0
19 changed files with 341 additions and 46 deletions
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: <grouped value>"
|
||||
|
|
@ -537,6 +557,7 @@ ShowTinkering:
|
|||
"Workmanship: <adjective of rounded average> (<average, two decimals>)"
|
||||
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: <displayedLevel> / <maxLevel>"
|
||||
"Item XP: <grouped total> / <grouped nextThreshold>"
|
||||
append an empty same-paragraph entry after Item XP
|
||||
|
||||
ItemLevelToTotalXP(level, base, max, style):
|
||||
clamp level to [0, max]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -87,6 +87,23 @@ internal sealed class ItemAppraisalReportBuilder
|
|||
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
|
||||
=> 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()
|
||||
=> _fragments.Count == 0
|
||||
? ItemAppraisalReport.Empty
|
||||
|
|
|
|||
|
|
@ -112,9 +112,8 @@ 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(105u, out int workmanship))
|
||||
{
|
||||
if (properties.Ints.TryGetValue(170u, out int salvagedItems)
|
||||
&& salvagedItems > 0)
|
||||
{
|
||||
|
|
@ -128,13 +127,19 @@ public static class ItemAppraisalTextFormatter
|
|||
+ $"({average.ToString("0.00", CultureInfo.CurrentCulture)})");
|
||||
report.Paragraph(
|
||||
$"Salvaged from {salvagedItems.ToString(CultureInfo.CurrentCulture)} items.");
|
||||
return;
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>Appraisal_ShowSet @ 0x004AE880</c> and
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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<string>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <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)
|
||||
=> value.Replace('_', ' ');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// The relevant subset of the server-sent <c>MovementData</c> /
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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);
|
||||
|
||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||
public event Action<EntitySpawn>? EntitySpawned;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
/// <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();
|
||||
|
||||
/// <summary>
|
||||
/// Ports <c>ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) @ 0x0058E6E0</c>:
|
||||
/// 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.
|
||||
/// Ports the singular/plural selection inside
|
||||
/// <c>ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) @ 0x0058E6E0</c>.
|
||||
/// 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>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
|
||||
|
|
|
|||
|
|
@ -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<uint>? changedContainers = RemoveFromOtherContainerIndexes(
|
||||
obj.ObjectId,
|
||||
|
|
|
|||
|
|
@ -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<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]
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue