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:
parent
7b7ffcd278
commit
07be994d97
84 changed files with 17822 additions and 1051 deletions
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
|
|
@ -77,13 +78,15 @@ public static class GameEventWiring
|
|||
FriendsState? friends = null,
|
||||
SquelchState? squelch = null,
|
||||
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
|
||||
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null)
|
||||
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
|
||||
Func<double>? clientTime = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
ArgumentNullException.ThrowIfNull(combat);
|
||||
ArgumentNullException.ThrowIfNull(spellbook);
|
||||
ArgumentNullException.ThrowIfNull(chat);
|
||||
clientTime ??= static () => 0d;
|
||||
|
||||
// ── Chat ──────────────────────────────────────────────────
|
||||
dispatcher.Register(GameEventType.ChannelBroadcast, e =>
|
||||
|
|
@ -283,21 +286,44 @@ public static class GameEventWiring
|
|||
dispatcher.Register(GameEventType.MagicUpdateEnchantment, e =>
|
||||
{
|
||||
var p = GameEvents.ParseMagicUpdateEnchantment(e.Payload.Span);
|
||||
if (p is not null) spellbook.OnEnchantmentAdded(
|
||||
p.Value.SpellId, p.Value.LayerId, p.Value.Duration, p.Value.CasterGuid);
|
||||
if (p is not null) spellbook.OnEnchantmentAdded(ToActiveEnchantment(p.Value, clientTime()));
|
||||
});
|
||||
dispatcher.Register(GameEventType.MagicUpdateMultipleEnchantments, e =>
|
||||
{
|
||||
var entries = GameEvents.ParseMagicUpdateMultipleEnchantments(e.Payload.Span);
|
||||
if (entries is not null)
|
||||
{
|
||||
double receivedAt = clientTime();
|
||||
spellbook.OnEnchantmentsAdded(entries.Select(entry =>
|
||||
ToActiveEnchantment(entry, receivedAt)));
|
||||
}
|
||||
});
|
||||
dispatcher.Register(GameEventType.MagicRemoveEnchantment, e =>
|
||||
{
|
||||
var p = GameEvents.ParseMagicRemoveEnchantment(e.Payload.Span);
|
||||
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
|
||||
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
|
||||
});
|
||||
dispatcher.Register(GameEventType.MagicRemoveMultipleEnchantments, e =>
|
||||
{
|
||||
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
|
||||
if (entries is not null)
|
||||
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
|
||||
});
|
||||
dispatcher.Register(GameEventType.MagicDispelEnchantment, e =>
|
||||
{
|
||||
var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span);
|
||||
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
|
||||
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
|
||||
});
|
||||
dispatcher.Register(GameEventType.MagicDispelMultipleEnchantments, e =>
|
||||
{
|
||||
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
|
||||
if (entries is not null)
|
||||
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
|
||||
});
|
||||
dispatcher.Register(GameEventType.MagicPurgeEnchantments,
|
||||
_ => spellbook.OnPurgeAll());
|
||||
dispatcher.Register(GameEventType.MagicPurgeBadEnchantments,
|
||||
_ => spellbook.OnPurgeBadEnchantments());
|
||||
|
||||
// ── Inventory ─────────────────────────────────────────────
|
||||
dispatcher.Register(GameEventType.WieldObject, e =>
|
||||
|
|
@ -407,8 +433,8 @@ public static class GameEventWiring
|
|||
// spellbook arrives via PlayerDescription (0x0013), which uses
|
||||
// a different wire format (see WorldSession + LocalPlayerState
|
||||
// — feeds vitals from PrivateUpdateVital instead).
|
||||
foreach (uint sid in p.Value.SpellBook)
|
||||
spellbook.OnSpellLearned(sid);
|
||||
// The appraised spellbook belongs to that item. The local player's
|
||||
// learned spell manifest arrives only in PlayerDescription.
|
||||
});
|
||||
|
||||
// ── Player ────────────────────────────────────────────────
|
||||
|
|
@ -441,6 +467,17 @@ public static class GameEventWiring
|
|||
onCharacterOptions?.Invoke(p.Value.Options1, p.Value.Options2);
|
||||
onDesiredComponents?.Invoke(p.Value.DesiredComps);
|
||||
|
||||
double receivedAt = clientTime();
|
||||
ActiveEnchantmentRecord[] enchantments = p.Value.Enchantments
|
||||
.Select(entry => ToActiveEnchantment(entry, receivedAt))
|
||||
.ToArray();
|
||||
spellbook.ReplaceManifest(
|
||||
p.Value.Spells,
|
||||
enchantments,
|
||||
p.Value.HotbarSpells,
|
||||
p.Value.DesiredComps,
|
||||
p.Value.SpellbookFilters);
|
||||
|
||||
// B-Wire: deliver the player's OWN properties to the player ClientObject.
|
||||
// (PD's "membership manifest" rule is about ITEMS, whose data comes from
|
||||
// CreateObject; the player's own stats legitimately come from PD.) Upsert
|
||||
|
|
@ -508,9 +545,6 @@ public static class GameEventWiring
|
|||
}
|
||||
}
|
||||
|
||||
foreach (uint sid in p.Value.Spells.Keys)
|
||||
spellbook.OnSpellLearned(sid);
|
||||
|
||||
// K-fix7 (2026-04-26): push Run + Jump skill values to the
|
||||
// PlayerMovementController so the runRate / jump-arc formulas
|
||||
// use the SERVER's authoritative skill instead of our
|
||||
|
|
@ -566,21 +600,6 @@ public static class GameEventWiring
|
|||
// Issue #7 — enchantment block: feed each entry into the
|
||||
// Spellbook with full StatMod data so EnchantmentMath can
|
||||
// aggregate buffs in vital-max calc (issue #6 lights up).
|
||||
foreach (var ench in p.Value.Enchantments)
|
||||
{
|
||||
spellbook.OnEnchantmentAdded(new AcDream.Core.Spells.ActiveEnchantmentRecord(
|
||||
SpellId: ench.SpellId,
|
||||
LayerId: ench.Layer,
|
||||
Duration: (float)ench.Duration,
|
||||
CasterGuid: ench.CasterGuid,
|
||||
StatModType: ench.StatModType,
|
||||
StatModKey: ench.StatModKey,
|
||||
StatModValue: ench.StatModValue,
|
||||
Bucket: (uint)ench.Bucket));
|
||||
if (dumpPd)
|
||||
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
|
||||
}
|
||||
|
||||
// D.5.4: PlayerDescription is a membership MANIFEST, not the data
|
||||
// source. Record existence (+ equip slot); CreateObject fills the
|
||||
// actual weenie data via ObjectTableWiring. (Previously this seeded
|
||||
|
|
@ -630,4 +649,46 @@ public static class GameEventWiring
|
|||
onShortcuts?.Invoke(p.Value.Shortcuts);
|
||||
});
|
||||
}
|
||||
|
||||
private static ActiveEnchantmentRecord ToActiveEnchantment(
|
||||
PlayerDescriptionParser.EnchantmentEntry enchantment,
|
||||
double receivedAt) => new(
|
||||
SpellId: enchantment.SpellId,
|
||||
LayerId: enchantment.Layer,
|
||||
Duration: enchantment.Duration,
|
||||
CasterGuid: enchantment.CasterGuid,
|
||||
StatModType: enchantment.StatModType,
|
||||
StatModKey: enchantment.StatModKey,
|
||||
StatModValue: enchantment.StatModValue,
|
||||
Bucket: enchantment.Bucket == 0
|
||||
? ClassifyLiveEnchantmentBucket(enchantment.StatModType)
|
||||
: (uint)enchantment.Bucket,
|
||||
// Retail Enchantment::UnPack (0x005CB040) converts both relative
|
||||
// wire timestamps to the monotonic client Timer domain at receipt.
|
||||
StartTime: receivedAt + enchantment.StartTime,
|
||||
SpellCategory: enchantment.SpellCategory,
|
||||
PowerLevel: enchantment.PowerLevel,
|
||||
DegradeModifier: enchantment.DegradeModifier,
|
||||
DegradeLimit: enchantment.DegradeLimit,
|
||||
LastTimeDegraded: receivedAt + enchantment.LastTimeDegraded,
|
||||
SpellSetId: enchantment.SpellSetId);
|
||||
|
||||
/// <summary>
|
||||
/// Live 0x02C2/0x02C4 records do not carry PlayerDescription's outer
|
||||
/// EnchantmentMask bucket. Retail reconstructs the registry list from the
|
||||
/// StatMod type flags; this is the same ordering used by ACE's
|
||||
/// EnchantmentRegistry.BuildCategories.
|
||||
/// </summary>
|
||||
private static uint ClassifyLiveEnchantmentBucket(uint statModType)
|
||||
{
|
||||
const uint Multiplicative = 0x00004000u;
|
||||
const uint Additive = 0x00008000u;
|
||||
const uint Vitae = 0x00800000u;
|
||||
const uint Cooldown = 0x01000000u;
|
||||
if ((statModType & Vitae) != 0) return 4u;
|
||||
if ((statModType & Cooldown) != 0) return 8u;
|
||||
if ((statModType & Multiplicative) != 0) return 1u;
|
||||
if ((statModType & Additive) != 0) return 2u;
|
||||
return 0u;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
115
src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs
Normal file
115
src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -138,5 +138,6 @@ public static class ObjectTableWiring
|
|||
CombatUse: s.CombatUse,
|
||||
PluralName: s.PluralName,
|
||||
PetOwnerId: s.PetOwnerId,
|
||||
AmmoType: s.AmmoType);
|
||||
AmmoType: s.AmmoType,
|
||||
SpellId: s.SpellId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ public sealed class WorldSession : IDisposable
|
|||
string? PluralName = null,
|
||||
uint? PetOwnerId = null,
|
||||
ushort? AmmoType = null,
|
||||
uint? SpellId = null,
|
||||
PhysicsSpawnData? Physics = null);
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -191,6 +192,7 @@ public sealed class WorldSession : IDisposable
|
|||
PluralName: parsed.PluralName,
|
||||
PetOwnerId: parsed.PetOwnerId,
|
||||
AmmoType: parsed.AmmoType,
|
||||
SpellId: parsed.SpellId,
|
||||
Physics: parsed.Physics);
|
||||
|
||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||
|
|
@ -1451,6 +1453,45 @@ public sealed class WorldSession : IDisposable
|
|||
seq, componentId: 0u, amount: uint.MaxValue));
|
||||
}
|
||||
|
||||
public void SendSetDesiredComponentLevel(uint componentId, uint amount)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSetDesiredComponentLevel(
|
||||
seq, componentId, amount));
|
||||
}
|
||||
|
||||
public void SendAddSpellFavorite(uint spellId, int position, int tabIndex)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildAddSpellFavorite(
|
||||
seq, spellId, position, tabIndex));
|
||||
}
|
||||
|
||||
public void SendRemoveSpellFavorite(uint spellId, int tabIndex)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildRemoveSpellFavorite(
|
||||
seq, spellId, tabIndex));
|
||||
}
|
||||
|
||||
public void SendSpellbookFilter(uint filters)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSpellbookFilter(seq, filters));
|
||||
}
|
||||
|
||||
public void SendCastUntargetedSpell(uint spellId)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(CastSpellRequest.BuildUntargeted(seq, spellId));
|
||||
}
|
||||
|
||||
public void SendCastTargetedSpell(uint targetGuid, uint spellId)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(CastSpellRequest.BuildTargeted(seq, targetGuid, spellId));
|
||||
}
|
||||
|
||||
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
|
||||
public void SendChangeCombatMode(CombatMode mode)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue