feat(ui): port retail appraisal panel

Preserve retail's one-pending-appraisal busy lifetime, parse the complete gated response, and mount the authored examination layout in the shared main-panel host. Keep known 3D preview and inscription-write gaps explicit in AP-110.
This commit is contained in:
Erik 2026-07-23 11:34:08 +02:00
parent 6b1ae4fb76
commit 643cdfe66e
24 changed files with 17132 additions and 40 deletions

View file

@ -73,6 +73,7 @@ public static class GameEventWiring
// the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
Func<uint>? playerGuid = null,
Action<uint /*weenieError*/>? onUseDone = null,
Action<AppraiseInfoParser.Parsed>? onAppraisal = null,
ItemManaState? itemMana = null,
Action<GameEvents.CharacterConfirmationRequest>? onConfirmationRequest = null,
Action<GameEvents.CharacterConfirmationDone>? onConfirmationDone = null,
@ -432,10 +433,18 @@ public static class GameEventWiring
registrar.Register(GameEventType.IdentifyObjectResponse, e =>
{
var p = AppraiseInfoParser.TryParse(e.Payload.Span);
if (p is null || !p.Value.Success) return;
if (p is null) return;
// Merge parsed properties into the item if we know about it.
if (items.Get(p.Value.Guid) is not null)
if (p.Value.Success && items.Get(p.Value.Guid) is not null)
items.UpdateProperties(p.Value.Guid, p.Value.Properties);
if (p.Value.CreatureProfile is { HealthMax: > 0u } creature)
combat.OnUpdateHealth(
p.Value.Guid,
Math.Clamp(
(float)creature.Health / creature.HealthMax,
0f,
1f));
onAppraisal?.Invoke(p.Value);
// Spellbook from appraise: for caster items / scrolls this is
// the cast-on-use list. The local player's full learned
// spellbook arrives via PlayerDescription (0x0013), which uses

View file

@ -34,7 +34,7 @@ namespace AcDream.Core.Net.Messages;
/// if (flags &amp; StringStatsTable) packedTable&lt;u32 key, string16L value&gt;
/// if (flags &amp; DidStatsTable) packedTable&lt;u32 key, u32 value&gt;
/// if (flags &amp; SpellBook) u32 count, u32[count] spellIds
/// ...armor/creature/weapon/hook profile blobs follow (deferred)
/// ...armor/creature/weapon/hook profile blobs follow
/// </code>
/// </para>
///
@ -48,10 +48,9 @@ namespace AcDream.Core.Net.Messages;
/// </para>
///
/// <para>
/// Returns a fully-populated <see cref="PropertyBundle"/>. Profile
/// blobs (ArmorProfile / CreatureProfile / WeaponProfile / HookProfile)
/// are recognised via the flag bits but not yet deserialized — the
/// parser seeks past them to keep the flag walk aligned.
/// Returns the complete gated appraisal payload. A malformed gated field
/// rejects the packet rather than returning a partial profile whose cursor
/// could be misaligned for every later field.
/// </para>
/// </summary>
public static class AppraiseInfoParser
@ -107,6 +106,12 @@ public static class AppraiseInfoParser
double WeaponOffense,
uint MaxVelocityEstimated);
/// <summary>Hook-container item metadata serialized as three consecutive u32 values.</summary>
public readonly record struct HookProfile(
uint Flags,
uint ValidLocations,
uint AmmoType);
/// <summary>Creature vitals + (optionally) attributes from a CreatureProfile blob.</summary>
public readonly record struct CreatureProfile(
uint Flags,
@ -134,6 +139,7 @@ public static class AppraiseInfoParser
ArmorProfile? ArmorProfile,
CreatureProfile? CreatureProfile,
WeaponProfile? WeaponProfile,
HookProfile? HookProfile,
ArmorLevel? ArmorLevels,
(ushort Highlight, ushort Color)? ArmorEnchantments,
(ushort Highlight, ushort Color)? WeaponEnchantments,
@ -160,6 +166,7 @@ public static class AppraiseInfoParser
ArmorProfile? armor = null;
CreatureProfile? creature = null;
WeaponProfile? weapon = null;
HookProfile? hook = null;
ArmorLevel? levels = null;
(ushort H, ushort C)? armorEnc = null, weaponEnc = null, resistEnc = null;
@ -187,7 +194,8 @@ public static class AppraiseInfoParser
creature = ReadCreatureProfile(payload, ref pos);
if (flags.HasFlag(IdentifyResponseFlags.WeaponProfile))
weapon = ReadWeaponProfile(payload, ref pos);
// HookProfile (0x200) — not deserialized; would need HookProfile struct port.
if (flags.HasFlag(IdentifyResponseFlags.HookProfile))
hook = ReadHookProfile(payload, ref pos);
if (flags.HasFlag(IdentifyResponseFlags.ArmorEnchantmentBitfield))
armorEnc = ReadEnchantmentBitfield(payload, ref pos);
if (flags.HasFlag(IdentifyResponseFlags.WeaponEnchantmentBitfield))
@ -199,12 +207,12 @@ public static class AppraiseInfoParser
}
catch (FormatException)
{
// Malformed table — return what we got so far.
return null;
}
return new Parsed(
guid, flags, success != 0, bundle, spellBook,
armor, creature, weapon, levels,
armor, creature, weapon, hook, levels,
armorEnc, weaponEnc, resistEnc);
}
@ -252,6 +260,12 @@ public static class AppraiseInfoParser
MaxVelocityEstimated: ReadU32(src, ref pos));
}
private static HookProfile ReadHookProfile(ReadOnlySpan<byte> src, ref int pos)
=> new(
Flags: ReadU32(src, ref pos),
ValidLocations: ReadU32(src, ref pos),
AmmoType: ReadU32(src, ref pos));
private static CreatureProfile ReadCreatureProfile(ReadOnlySpan<byte> src, ref int pos)
{
uint flags = ReadU32(src, ref pos);