feat: port retail magic lifecycle and retained spell UI

Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 10:55:22 +02:00
parent 7b7ffcd278
commit 07be994d97
84 changed files with 17822 additions and 1051 deletions

View file

@ -1,5 +1,6 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
namespace AcDream.Core.Net.Messages;
@ -271,14 +272,19 @@ public static class GameEvents
/// <summary>
/// 0x02C3 MagicRemoveEnchantment — (layerId, spellId).
/// </summary>
public readonly record struct MagicRemoveEnchantment(uint LayerId, uint SpellId);
public readonly record struct LayeredSpellId(ushort SpellId, ushort Layer)
{
public uint Packed => SpellId | ((uint)Layer << 16);
}
public readonly record struct MagicRemoveEnchantment(ushort SpellId, ushort Layer);
public static MagicRemoveEnchantment? ParseMagicRemoveEnchantment(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
if (payload.Length < 4) return null;
return new MagicRemoveEnchantment(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
BinaryPrimitives.ReadUInt16LittleEndian(payload),
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(2)));
}
/// <summary>0x01A8 MagicRemoveSpell — spell id removed from spellbook.</summary>
@ -294,26 +300,20 @@ public static class GameEvents
/// stat mods. We expose the first few fields that drive the enchant
/// bar UI; the rest is available via the raw payload view.
/// </summary>
public readonly record struct EnchantmentSummary(
uint SpellId,
uint LayerId,
float Duration,
uint CasterGuid);
public static EnchantmentSummary? ParseMagicUpdateEnchantment(ReadOnlySpan<byte> payload)
public static PlayerDescriptionParser.EnchantmentEntry? ParseMagicUpdateEnchantment(
ReadOnlySpan<byte> payload)
{
// Layout (ACE Enchantment.Pack):
// u32 spellId
// u32 layerId
// f32 duration
// u32 casterGuid
// ... (stat mods, category, power, etc.)
if (payload.Length < 16) return null;
return new EnchantmentSummary(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(8)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12)));
int position = 0;
try { return EnchantmentWireReader.Read(payload, ref position); }
catch (FormatException) { return null; }
}
public static IReadOnlyList<PlayerDescriptionParser.EnchantmentEntry>?
ParseMagicUpdateMultipleEnchantments(ReadOnlySpan<byte> payload)
{
int position = 0;
try { return EnchantmentWireReader.ReadList(payload, ref position); }
catch (FormatException) { return null; }
}
/// <summary>
@ -323,6 +323,23 @@ public static class GameEvents
public static MagicRemoveEnchantment? ParseMagicDispelEnchantment(ReadOnlySpan<byte> payload)
=> ParseMagicRemoveEnchantment(payload);
public static IReadOnlyList<LayeredSpellId>? ParseMagicLayeredSpellList(
ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload);
if (count > 0x4000 || payload.Length - 4 < checked((int)count * 4)) return null;
var result = new LayeredSpellId[count];
for (int i = 0; i < result.Length; i++)
{
int offset = 4 + i * 4;
result[i] = new LayeredSpellId(
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(offset, 2)),
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(offset + 2, 2)));
}
return result;
}
// ── Appraise / identify ─────────────────────────────────────────────────
/// <summary>0x00C9 IdentifyObjectResponse header.</summary>