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

@ -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;

View file

@ -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

View file

@ -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();
}
/// <summary>
@ -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();
}
}

View file

@ -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('_', ' ');
}

View file

@ -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
{

View file

@ -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);
}

View file

@ -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;

View file

@ -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).

View file

@ -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,