acdream/src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs
Erik ff5ed9ec0b feat(net): #18 holtburger inbound chat parity - EmoteText, SoulEmote, ServerMessage, PlayerKilled, WeenieError + Windows-1252 codec
Five sub-changes:

1. Windows-1252 codec switch (global). Every Encoding.ASCII call site
   in src/AcDream.Core.Net/Messages/ -> Encoding.GetEncoding(1252).
   Touched HearSpeech, ChatRequests, GameEvents, AppraiseInfoParser,
   CharacterList, CreateObject, PlayerDescriptionParser, SocialActions.
   New Encodings.cs module-init registers CodePagesEncodingProvider
   (System.Text.Encoding.CodePages ships with .NET 10 SDK but isn't
   auto-registered). Matches retail + holtburger; accented names
   no longer round-trip-broken.

2. New parsers (opcodes confirmed against holtburger opcodes.rs):
   - EmoteText (0x01E0)     { u32 senderGuid, string16 senderName, string16 text }
   - SoulEmote (0x01E2)     same wire layout as EmoteText
   - ServerMessage (0xF7E0) { string16 message, u32 chatType }
   - PlayerKilled (0x019E)  { string16 deathMessage, u32 victimGuid, u32 killerGuid }
   Shared StringReader.cs has the CP1252 String16L primitive.

3. WorldSession dispatch. ProcessDatagram adds branches for the four
   new top-level opcodes + fires session-level events (EmoteHeard,
   SoulEmoteHeard, ServerMessageReceived, PlayerKilledReceived).
   0x0295 SetTurbineChatChannels stubbed with TODO for parallel I.6.

4. GameEventWiring routes WeenieError + WeenieErrorWithString
   (parsers existed but were unrouted) -> chat.OnWeenieError.

5. ChatLog adapters: Emote / SoulEmote ChatKind values, OnEmote,
   OnSoulEmote, OnPlayerKilled, OnWeenieError. OnLocalSpeech now
   substitutes empty sender -> "You" per holtburger client/messages.rs.
   ChatVM.FormatEntry handles new kinds (asterisk + sender + text).

22 new tests covering parser round-trips + reject-bad-opcode +
ChatLog adapter coverage + Win-1252 round-trip with non-ASCII chars.
Solution total: 881 green (210->225 in Core.Net.Tests, 606->613 in Core.Tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:06:01 +02:00

440 lines
17 KiB
C#

using System;
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Items;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Parser for the full <c>AppraiseInfo</c> blob carried by
/// <c>GameEventType.IdentifyObjectResponse</c> (0x00C9). Format source:
/// ACE <c>AppraiseInfo.Write</c> (Structure/AppraiseInfo.cs:735)
/// + <c>PackableHashTable.WriteHeader</c>.
///
/// <para>
/// <b>Not</b> usable for <c>PlayerDescription (0x0013)</c> — that opcode
/// has its own wire format (DescriptionPropertyFlag-driven property
/// hashtables, DescriptionVectorFlag block, hand-written attribute /
/// skill / spell layout). See ACE
/// <c>GameEventPlayerDescription.WriteEventBody</c>. Player vitals come
/// in instead via <c>PrivateUpdateVital (0x02E7)</c> +
/// <c>PrivateUpdateVitalCurrent (0x02E9)</c>, which are top-level
/// GameMessage opcodes (not 0xF7B0 sub-opcodes).
/// </para>
///
/// <para>
/// Wire shape:
/// <code>
/// u32 flags // IdentifyResponseFlags bitfield
/// u32 success // 0 or 1
/// if (flags &amp; IntStatsTable) packedTable&lt;u32 key, i32 value&gt;
/// if (flags &amp; Int64StatsTable) packedTable&lt;u32 key, i64 value&gt;
/// if (flags &amp; BoolStatsTable) packedTable&lt;u32 key, u32 value&gt;
/// if (flags &amp; FloatStatsTable) packedTable&lt;u32 key, f64 value&gt;
/// 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)
/// </code>
/// </para>
///
/// <para>
/// Each packed table starts with a 4-byte header:
/// <code>
/// u16 count // how many entries follow
/// u16 numBuckets // hashtable bucket hint (ignored for parse)
/// </code>
/// Then <c>count</c> (key, value) pairs. No padding between pairs.
/// </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.
/// </para>
/// </summary>
public static class AppraiseInfoParser
{
[Flags]
public enum IdentifyResponseFlags : uint
{
None = 0x0000_0000,
IntStatsTable = 0x0000_0001,
Int64StatsTable = 0x0000_0002,
BoolStatsTable = 0x0000_0004,
FloatStatsTable = 0x0000_0008,
StringStatsTable = 0x0000_0010,
DidStatsTable = 0x0000_0020,
SpellBook = 0x0000_0040,
ArmorProfile = 0x0000_0080,
WeaponProfile = 0x0000_0100,
HookProfile = 0x0000_0200,
ArmorEnchantmentBitfield = 0x0000_0400,
WeaponEnchantmentBitfield= 0x0000_0800,
ResistEnchantmentBitfield= 0x0000_1000,
CreatureProfile = 0x0000_2000,
ArmorLevels = 0x0000_4000,
}
/// <summary>Per-damage-type protections from an ArmorProfile blob.</summary>
public readonly record struct ArmorProfile(
float SlashingProtection,
float PiercingProtection,
float BludgeoningProtection,
float ColdProtection,
float FireProtection,
float AcidProtection,
float NetherProtection,
float LightningProtection);
/// <summary>Per-slot AL (armor level) from an ArmorLevels blob.</summary>
public readonly record struct ArmorLevel(
int Head, int Chest, int Abdomen,
int UpperArm, int LowerArm, int Hand,
int UpperLeg, int LowerLeg, int Foot);
/// <summary>Melee/missile weapon statistics from a WeaponProfile blob.</summary>
public readonly record struct WeaponProfile(
uint DamageType,
uint WeaponTime,
uint WeaponSkill,
uint Damage,
double DamageVariance,
double DamageMod,
double WeaponLength,
double MaxVelocity,
double WeaponOffense,
uint MaxVelocityEstimated);
/// <summary>Creature vitals + (optionally) attributes from a CreatureProfile blob.</summary>
public readonly record struct CreatureProfile(
uint Flags,
uint Health,
uint HealthMax,
uint? Strength,
uint? Endurance,
uint? Quickness,
uint? Coordination,
uint? Focus,
uint? Self,
uint? Stamina,
uint? Mana,
uint? StaminaMax,
uint? ManaMax,
ushort? AttributeHighlights,
ushort? AttributeColors);
public readonly record struct Parsed(
uint Guid,
IdentifyResponseFlags Flags,
bool Success,
PropertyBundle Properties,
uint[] SpellBook,
ArmorProfile? ArmorProfile,
CreatureProfile? CreatureProfile,
WeaponProfile? WeaponProfile,
ArmorLevel? ArmorLevels,
(ushort Highlight, ushort Color)? ArmorEnchantments,
(ushort Highlight, ushort Color)? WeaponEnchantments,
(ushort Highlight, ushort Color)? ResistEnchantments);
/// <summary>
/// Parse a full IdentifyObjectResponse payload.
/// The envelope has already been stripped by
/// <see cref="GameEventEnvelope.TryParse"/>, so
/// <paramref name="payload"/> begins with <c>u32 guid</c>.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> payload)
{
if (payload.Length < 12) return null;
int pos = 0;
uint guid = ReadU32(payload, ref pos);
uint rawFlags= ReadU32(payload, ref pos);
uint success = ReadU32(payload, ref pos);
var flags = (IdentifyResponseFlags)rawFlags;
var bundle = new PropertyBundle();
uint[] spellBook = Array.Empty<uint>();
ArmorProfile? armor = null;
CreatureProfile? creature = null;
WeaponProfile? weapon = null;
ArmorLevel? levels = null;
(ushort H, ushort C)? armorEnc = null, weaponEnc = null, resistEnc = null;
try
{
if (flags.HasFlag(IdentifyResponseFlags.IntStatsTable))
ReadIntTable(payload, ref pos, bundle);
if (flags.HasFlag(IdentifyResponseFlags.Int64StatsTable))
ReadInt64Table(payload, ref pos, bundle);
if (flags.HasFlag(IdentifyResponseFlags.BoolStatsTable))
ReadBoolTable(payload, ref pos, bundle);
if (flags.HasFlag(IdentifyResponseFlags.FloatStatsTable))
ReadFloatTable(payload, ref pos, bundle);
if (flags.HasFlag(IdentifyResponseFlags.StringStatsTable))
ReadStringTable(payload, ref pos, bundle);
if (flags.HasFlag(IdentifyResponseFlags.DidStatsTable))
ReadDataIdTable(payload, ref pos, bundle);
if (flags.HasFlag(IdentifyResponseFlags.SpellBook))
spellBook = ReadSpellBook(payload, ref pos);
// Profile blobs (AppraiseInfo.Write line 751 onward, in flag order).
if (flags.HasFlag(IdentifyResponseFlags.ArmorProfile))
armor = ReadArmorProfile(payload, ref pos);
if (flags.HasFlag(IdentifyResponseFlags.CreatureProfile))
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.ArmorEnchantmentBitfield))
armorEnc = ReadEnchantmentBitfield(payload, ref pos);
if (flags.HasFlag(IdentifyResponseFlags.WeaponEnchantmentBitfield))
weaponEnc = ReadEnchantmentBitfield(payload, ref pos);
if (flags.HasFlag(IdentifyResponseFlags.ResistEnchantmentBitfield))
resistEnc = ReadEnchantmentBitfield(payload, ref pos);
if (flags.HasFlag(IdentifyResponseFlags.ArmorLevels))
levels = ReadArmorLevel(payload, ref pos);
}
catch (FormatException)
{
// Malformed table — return what we got so far.
}
return new Parsed(
guid, flags, success != 0, bundle, spellBook,
armor, creature, weapon, levels,
armorEnc, weaponEnc, resistEnc);
}
// ── Profile readers ──────────────────────────────────────────────────────
private static ArmorProfile ReadArmorProfile(ReadOnlySpan<byte> src, ref int pos)
{
return new ArmorProfile(
ReadF32(src, ref pos),
ReadF32(src, ref pos),
ReadF32(src, ref pos),
ReadF32(src, ref pos),
ReadF32(src, ref pos),
ReadF32(src, ref pos),
ReadF32(src, ref pos),
ReadF32(src, ref pos));
}
private static ArmorLevel ReadArmorLevel(ReadOnlySpan<byte> src, ref int pos)
{
return new ArmorLevel(
(int)ReadU32(src, ref pos),
(int)ReadU32(src, ref pos),
(int)ReadU32(src, ref pos),
(int)ReadU32(src, ref pos),
(int)ReadU32(src, ref pos),
(int)ReadU32(src, ref pos),
(int)ReadU32(src, ref pos),
(int)ReadU32(src, ref pos),
(int)ReadU32(src, ref pos));
}
private static WeaponProfile ReadWeaponProfile(ReadOnlySpan<byte> src, ref int pos)
{
return new WeaponProfile(
DamageType: ReadU32(src, ref pos),
WeaponTime: ReadU32(src, ref pos),
WeaponSkill: ReadU32(src, ref pos),
Damage: ReadU32(src, ref pos),
DamageVariance: ReadF64(src, ref pos),
DamageMod: ReadF64(src, ref pos),
WeaponLength: ReadF64(src, ref pos),
MaxVelocity: ReadF64(src, ref pos),
WeaponOffense: ReadF64(src, ref pos),
MaxVelocityEstimated: ReadU32(src, ref pos));
}
private static CreatureProfile ReadCreatureProfile(ReadOnlySpan<byte> src, ref int pos)
{
uint flags = ReadU32(src, ref pos);
uint health = ReadU32(src, ref pos);
uint healthMax = ReadU32(src, ref pos);
uint? str = null, end = null, quic = null, coord = null, focus = null, self = null;
uint? sta = null, mana = null, staMax = null, manaMax = null;
ushort? attrHighlights = null, attrColors = null;
// Flag 0x08 = ShowAttributes
if ((flags & 0x08u) != 0)
{
str = ReadU32(src, ref pos);
end = ReadU32(src, ref pos);
quic = ReadU32(src, ref pos);
coord = ReadU32(src, ref pos);
focus = ReadU32(src, ref pos);
self = ReadU32(src, ref pos);
sta = ReadU32(src, ref pos);
mana = ReadU32(src, ref pos);
staMax = ReadU32(src, ref pos);
manaMax= ReadU32(src, ref pos);
}
// Flag 0x01 = HasBuffsDebuffs. ACE writes two u16 fields, 4 bytes total.
if ((flags & 0x01u) != 0)
{
if (src.Length - pos < 4) throw new FormatException("truncated creature buffs");
attrHighlights = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); pos += 2;
attrColors = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); pos += 2;
}
return new CreatureProfile(
flags, health, healthMax,
str, end, quic, coord, focus, self,
sta, mana, staMax, manaMax,
attrHighlights, attrColors);
}
private static (ushort Highlight, ushort Color) ReadEnchantmentBitfield(
ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 4) throw new FormatException("truncated enchantment bitfield");
ushort highlight = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); pos += 2;
ushort color = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); pos += 2;
return (highlight, color);
}
private static float ReadF32(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 4) throw new FormatException("truncated f32");
float v = BinaryPrimitives.ReadSingleLittleEndian(src.Slice(pos));
pos += 4;
return v;
}
// ── Table readers ────────────────────────────────────────────────────────
private static (ushort count, ushort buckets) ReadHeader(
ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 4) throw new FormatException("truncated table header");
ushort count = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
ushort buckets = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos + 2));
pos += 4;
return (count, buckets);
}
private static void ReadIntTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
int val = (int)ReadU32(src, ref pos);
bundle.Ints[key] = val;
}
}
private static void ReadInt64Table(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
long val = ReadI64(src, ref pos);
bundle.Int64s[key] = val;
}
}
private static void ReadBoolTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
uint val = ReadU32(src, ref pos);
bundle.Bools[key] = val != 0;
}
}
private static void ReadFloatTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
double val = ReadF64(src, ref pos);
bundle.Floats[key] = val;
}
}
private static void ReadStringTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
string val = ReadString16L(src, ref pos);
bundle.Strings[key] = val;
}
}
private static void ReadDataIdTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
uint val = ReadU32(src, ref pos);
bundle.DataIds[key] = val;
}
}
private static uint[] ReadSpellBook(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 4) throw new FormatException("truncated spellbook count");
uint count = ReadU32(src, ref pos);
if (count > 4096) throw new FormatException("unreasonable spellbook count");
if (src.Length - pos < count * 4) throw new FormatException("truncated spellbook body");
uint[] result = new uint[count];
for (int i = 0; i < count; i++) result[i] = ReadU32(src, ref pos);
return result;
}
// ── Primitive readers ───────────────────────────────────────────────────
private static uint ReadU32(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 4) throw new FormatException("truncated u32");
uint v = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
pos += 4;
return v;
}
private static long ReadI64(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 8) throw new FormatException("truncated i64");
long v = BinaryPrimitives.ReadInt64LittleEndian(src.Slice(pos));
pos += 8;
return v;
}
private static double ReadF64(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 8) throw new FormatException("truncated f64");
double v = BinaryPrimitives.ReadDoubleLittleEndian(src.Slice(pos));
pos += 8;
return v;
}
private static string ReadString16L(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 2) throw new FormatException("truncated string length");
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
pos += 2;
if (src.Length - pos < len) throw new FormatException("truncated string body");
// Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252).
string v = Encoding.GetEncoding(1252).GetString(src.Slice(pos, len));
pos += len;
int record = 2 + len;
int pad = (4 - (record & 3)) & 3;
pos += pad;
return v;
}
}