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

@ -33,6 +33,9 @@ public static class ClientCommandRequests
public const uint DisplayConsentOpcode = 0x0217u;
public const uint RemoveConsentOpcode = 0x0218u;
public const uint SetDesiredComponentLevelOpcode = 0x0224u;
public const uint AddSpellFavoriteOpcode = 0x01E3u;
public const uint RemoveSpellFavoriteOpcode = 0x01E4u;
public const uint SpellbookFilterOpcode = 0x0286u;
public const uint LegacyFriendsOpcode = 0xF7CDu;
// Named-retail anchors:
@ -177,6 +180,31 @@ public static class ClientCommandRequests
return body;
}
// CM_Character::Event_AddSpellFavorite @ 0x006A0F70.
public static byte[] BuildAddSpellFavorite(
uint sequence, uint spellId, int position, int tabIndex)
{
byte[] body = CreateBody(sequence, AddSpellFavoriteOpcode, 12);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), spellId);
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(16), position);
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(20), tabIndex);
return body;
}
// CM_Character::Event_RemoveSpellFavorite @ 0x006A1890.
public static byte[] BuildRemoveSpellFavorite(
uint sequence, uint spellId, int tabIndex)
{
byte[] body = CreateBody(sequence, RemoveSpellFavoriteOpcode, 8);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), spellId);
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(16), tabIndex);
return body;
}
// CM_Character::Event_SpellbookFilterEvent @ 0x006A1A30.
public static byte[] BuildSpellbookFilter(uint sequence, uint filters) =>
BuildUInt32(sequence, SpellbookFilterOpcode, filters);
private static byte[] BuildParameterless(uint sequence, uint opcode)
{
byte[] body = new byte[12];

View file

@ -207,6 +207,9 @@ public static class CreateObject
// PublicWeenieDesc._ammoType, gated by WeenieHeader flag 0x100.
// AMMO_NONE is the explicit wire value zero; null means absent.
ushort? AmmoType = null,
// PublicWeenieDesc._spellID, gated by PWD_Packed_SpellID. The packed
// wire field is u16 and widens into retail's u32 runtime member.
uint? SpellId = null,
// Complete immutable PhysicsDesc projection. Kept alongside the
// legacy convenience fields while live-entity ownership migrates to
// LiveEntityRuntime in Step 2.
@ -862,6 +865,7 @@ public static class CreateObject
byte? radarBehavior = null;
byte? combatUse = null;
ushort? ammoType = null;
uint? spellId = null;
uint iconOverlayId = 0;
uint iconUnderlayId = 0;
uint uiEffects = 0;
@ -1011,6 +1015,7 @@ public static class CreateObject
if ((weenieFlags & 0x00400000u) != 0) // Spell u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Spell");
spellId = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32
@ -1113,6 +1118,7 @@ public static class CreateObject
PluralName: pluralName,
PetOwnerId: petOwnerId,
AmmoType: ammoType,
SpellId: spellId,
Physics: physics);
}
catch

View file

@ -0,0 +1,115 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Retail <c>Enchantment::UnPack</c> reader shared by PlayerDescription and
/// the 0x02C2/0x02C4 magic update events. The record is 60 bytes, plus the
/// optional four-byte spell-set id.
/// </summary>
/// <remarks>
/// Retail reference: <c>CM_Magic::DispatchUI_UpdateEnchantment</c>
/// (0x006A32F0). Layout cross-checked against ACE
/// <c>Network/Structure/Enchantment.cs</c>.
/// </remarks>
public static class EnchantmentWireReader
{
public static PlayerDescriptionParser.EnchantmentEntry Read(
ReadOnlySpan<byte> source,
ref int position,
PlayerDescriptionParser.EnchantmentBucket bucket = 0)
{
if (source.Length - position < 60)
throw new FormatException("truncated enchantment record");
ushort spellId = ReadU16(source, ref position);
ushort layer = ReadU16(source, ref position);
ushort spellCategory = ReadU16(source, ref position);
ushort hasSpellSetId = ReadU16(source, ref position);
uint powerLevel = ReadU32(source, ref position);
double startTime = ReadF64(source, ref position);
double duration = ReadF64(source, ref position);
uint casterGuid = ReadU32(source, ref position);
float degradeModifier = ReadF32(source, ref position);
float degradeLimit = ReadF32(source, ref position);
double lastDegraded = ReadF64(source, ref position);
uint statModType = ReadU32(source, ref position);
uint statModKey = ReadU32(source, ref position);
float statModValue = ReadF32(source, ref position);
uint? spellSetId = hasSpellSetId != 0 ? ReadU32(source, ref position) : null;
if (!double.IsFinite(startTime)
|| !double.IsFinite(duration)
|| !float.IsFinite(degradeModifier)
|| !float.IsFinite(degradeLimit)
|| !double.IsFinite(lastDegraded)
|| !float.IsFinite(statModValue))
{
throw new FormatException("non-finite enchantment value");
}
return new PlayerDescriptionParser.EnchantmentEntry(
spellId, layer, spellCategory, hasSpellSetId, powerLevel,
startTime, duration, casterGuid, degradeModifier, degradeLimit,
lastDegraded, statModType, statModKey, statModValue, spellSetId,
bucket);
}
public static IReadOnlyList<PlayerDescriptionParser.EnchantmentEntry> ReadList(
ReadOnlySpan<byte> source,
ref int position,
PlayerDescriptionParser.EnchantmentBucket bucket = 0)
{
if (source.Length - position < 4)
throw new FormatException("truncated enchantment list count");
uint count = ReadU32(source, ref position);
if (count > 0x4000)
throw new FormatException("unreasonable enchantment list count");
var result = new List<PlayerDescriptionParser.EnchantmentEntry>((int)count);
for (uint i = 0; i < count; i++)
result.Add(Read(source, ref position, bucket));
return result;
}
private static ushort ReadU16(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 2);
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(position, 2));
position += 2;
return value;
}
private static uint ReadU32(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 4);
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(position, 4));
position += 4;
return value;
}
private static float ReadF32(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 4);
float value = BinaryPrimitives.ReadSingleLittleEndian(source.Slice(position, 4));
position += 4;
return value;
}
private static double ReadF64(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 8);
double value = BinaryPrimitives.ReadDoubleLittleEndian(source.Slice(position, 8));
position += 8;
return value;
}
private static void Require(ReadOnlySpan<byte> source, int position, int count)
{
if (position < 0 || source.Length - position < count)
throw new FormatException("truncated enchantment record");
}
}

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>

View file

@ -328,7 +328,9 @@ public static class PlayerDescriptionParser
CharacterOptionDataFlag optionFlags = CharacterOptionDataFlag.None;
uint options1 = 0;
uint options2 = 0;
uint spellbookFilters = 0;
// Retail CPlayerModule ctor (0x005D5245) enables every school and
// level filter before any optional PlayerDescription override.
uint spellbookFilters = 0x3FFFu;
List<ShortcutEntry> shortcuts = new();
List<IReadOnlyList<uint>> hotbarSpells = new();
List<(uint, uint)> desiredComps = new();
@ -687,10 +689,7 @@ public static class PlayerDescriptionParser
ReadOnlySpan<byte> src, ref int pos, List<EnchantmentEntry> dest,
EnchantmentBucket bucket)
{
uint count = ReadU32(src, ref pos);
if (count > 0x4000) throw new FormatException("unreasonable enchantment list count");
for (int i = 0; i < count; i++)
dest.Add(ReadEnchantment(src, ref pos, bucket));
dest.AddRange(EnchantmentWireReader.ReadList(src, ref pos, bucket));
}
private static EnchantmentEntry ReadEnchantment(
@ -703,29 +702,7 @@ public static class PlayerDescriptionParser
// f32 degrade_modifier, f32 degrade_limit, f64 last_time_degraded,
// u32 stat_mod_type, u32 stat_mod_key, f32 stat_mod_value, (28)
// if has_spell_set_id != 0: u32 spell_set_id (0 or 4)
if (src.Length - pos < 60) throw new FormatException("truncated enchantment record");
ushort spellId = ReadU16(src, ref pos);
ushort layer = ReadU16(src, ref pos);
ushort spellCategory = ReadU16(src, ref pos);
ushort hasSpellSetId = ReadU16(src, ref pos);
uint powerLevel = ReadU32(src, ref pos);
double startTime = ReadF64(src, ref pos);
double duration = ReadF64(src, ref pos);
uint casterGuid = ReadU32(src, ref pos);
float degradeModifier= ReadF32(src, ref pos);
float degradeLimit = ReadF32(src, ref pos);
double lastDegraded = ReadF64(src, ref pos);
uint statModType = ReadU32(src, ref pos);
uint statModKey = ReadU32(src, ref pos);
float statModValue = ReadF32(src, ref pos);
uint? spellSetId = null;
if (hasSpellSetId != 0)
spellSetId = ReadU32(src, ref pos);
return new EnchantmentEntry(
spellId, layer, spellCategory, hasSpellSetId, powerLevel,
startTime, duration, casterGuid, degradeModifier, degradeLimit,
lastDegraded, statModType, statModKey, statModValue, spellSetId,
bucket);
return EnchantmentWireReader.Read(src, ref pos, bucket);
}
/// <summary>Strict inventory + equipped block reader. Returns true if