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
|
|
@ -460,8 +460,8 @@ public sealed class GameEventWiringTests
|
|||
public void WireAll_MagicPurgeEnchantments_CallsOnPurgeAll()
|
||||
{
|
||||
var (d, _, _, book, _) = MakeAll();
|
||||
book.OnEnchantmentAdded(1, 1, 100f, 0);
|
||||
book.OnEnchantmentAdded(2, 2, 100f, 0);
|
||||
book.OnEnchantmentAdded(new(1, 1, 100, 0, Bucket: 1));
|
||||
book.OnEnchantmentAdded(new(2, 2, 100, 0, Bucket: 2));
|
||||
Assert.Equal(2, book.ActiveCount);
|
||||
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.MagicPurgeEnchantments, Array.Empty<byte>()));
|
||||
|
|
@ -470,6 +470,32 @@ public sealed class GameEventWiringTests
|
|||
Assert.Equal(0, book.ActiveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_MagicUpdateEnchantment_ConvertsRelativeTimesAndClassifiesBucket()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var book = new Spellbook();
|
||||
GameEventWiring.WireAll(
|
||||
dispatcher,
|
||||
new ClientObjectTable(),
|
||||
new CombatState(),
|
||||
book,
|
||||
new ChatLog(),
|
||||
clientTime: () => 100d);
|
||||
|
||||
byte[] payload = BuildEnchantment(
|
||||
spellId: 42, layer: 3, duration: 60, caster: 0xBEEF,
|
||||
startTime: 10, lastDegraded: 2, statModType: 0x00008000u);
|
||||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||||
WrapEnvelope(GameEventType.MagicUpdateEnchantment, payload))!.Value);
|
||||
|
||||
ActiveEnchantmentRecord record = Assert.Single(book.ActiveEnchantmentSnapshot);
|
||||
Assert.Equal(110d, record.StartTime);
|
||||
Assert.Equal(102d, record.LastTimeDegraded);
|
||||
Assert.Equal(2u, record.Bucket);
|
||||
Assert.Equal(60d, record.Duration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_WeenieError_RoutesToChatLog()
|
||||
{
|
||||
|
|
@ -1064,4 +1090,27 @@ public sealed class GameEventWiringTests
|
|||
Assert.Equal((0x50C4A54Au, 0x948700u), observed);
|
||||
}
|
||||
|
||||
private static byte[] BuildEnchantment(
|
||||
ushort spellId,
|
||||
ushort layer,
|
||||
double duration,
|
||||
uint caster,
|
||||
double startTime,
|
||||
double lastDegraded,
|
||||
uint statModType)
|
||||
{
|
||||
byte[] payload = new byte[60];
|
||||
int offset = 0;
|
||||
WriteU16(spellId); WriteU16(layer); WriteU16(3); WriteU16(0);
|
||||
WriteU32(8); WriteF64(startTime); WriteF64(duration); WriteU32(caster);
|
||||
WriteF32(0.1f); WriteF32(-1f); WriteF64(lastDegraded);
|
||||
WriteU32(statModType); WriteU32(7); WriteF32(1.25f);
|
||||
return payload;
|
||||
|
||||
void WriteU16(ushort value) { BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(offset), value); offset += 2; }
|
||||
void WriteU32(uint value) { BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(offset), value); offset += 4; }
|
||||
void WriteF32(float value) { BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(offset), value); offset += 4; }
|
||||
void WriteF64(double value) { BinaryPrimitives.WriteDoubleLittleEndian(payload.AsSpan(offset), value); offset += 8; }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,37 +42,92 @@ public sealed class CastSpellTests
|
|||
public void ParseMagicUpdateSpell_RoundTrip()
|
||||
{
|
||||
byte[] payload = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1u);
|
||||
Assert.Equal(0x3E1u, GameEvents.ParseMagicUpdateSpell(payload));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x123403E1u);
|
||||
var parsed = GameEvents.ParseMagicUpdateSpell(payload);
|
||||
Assert.Equal(0x123403E1u, parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMagicUpdateEnchantment_RoundTrip()
|
||||
{
|
||||
byte[] payload = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u); // spellId
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 7u); // layerId
|
||||
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(8), 300.0f); // duration
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0xBEEFu); // caster
|
||||
byte[] payload = BuildEnchantment(spellId: 42, layer: 7, duration: 300, caster: 0xBEEF);
|
||||
|
||||
var parsed = GameEvents.ParseMagicUpdateEnchantment(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(42u, parsed!.Value.SpellId);
|
||||
Assert.Equal(7u, parsed.Value.LayerId);
|
||||
Assert.Equal(300f, parsed.Value.Duration, 4);
|
||||
Assert.Equal((ushort)42, parsed!.Value.SpellId);
|
||||
Assert.Equal((ushort)7, parsed.Value.Layer);
|
||||
Assert.Equal(300d, parsed.Value.Duration, 4);
|
||||
Assert.Equal(0xBEEFu, parsed.Value.CasterGuid);
|
||||
Assert.Equal(0x20u, parsed.Value.StatModType);
|
||||
Assert.Equal(7u, parsed.Value.StatModKey);
|
||||
Assert.Equal(1.25f, parsed.Value.StatModValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMagicRemoveEnchantment_RoundTrip()
|
||||
{
|
||||
byte[] payload = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 7u); // layerId
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 42u); // spellId
|
||||
byte[] payload = new byte[4];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(payload, 42);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(2), 7);
|
||||
|
||||
var parsed = GameEvents.ParseMagicRemoveEnchantment(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(7u, parsed!.Value.LayerId);
|
||||
Assert.Equal(42u, parsed.Value.SpellId);
|
||||
Assert.Equal((ushort)7, parsed!.Value.Layer);
|
||||
Assert.Equal((ushort)42, parsed.Value.SpellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMagicUpdateMultipleEnchantments_ReadsEveryRecord()
|
||||
{
|
||||
byte[] first = BuildEnchantment(42, 1, 60, 0xAA);
|
||||
byte[] second = BuildEnchantment(43, 2, 120, 0xBB);
|
||||
byte[] payload = new byte[4 + first.Length + second.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 2);
|
||||
first.CopyTo(payload, 4);
|
||||
second.CopyTo(payload, 4 + first.Length);
|
||||
|
||||
var parsed = GameEvents.ParseMagicUpdateMultipleEnchantments(payload);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(2, parsed!.Count);
|
||||
Assert.Equal((ushort)42, parsed[0].SpellId);
|
||||
Assert.Equal((ushort)43, parsed[1].SpellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMagicUpdateEnchantment_RejectsTruncatedOptionalSpellSetId()
|
||||
{
|
||||
byte[] payload = BuildEnchantment(42, 1, 60, 0xAA);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(6), 1);
|
||||
|
||||
Assert.Null(GameEvents.ParseMagicUpdateEnchantment(payload));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMagicLayeredSpellList_RejectsTruncation()
|
||||
{
|
||||
byte[] payload = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 2);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(4), 42);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(6), 1);
|
||||
|
||||
Assert.Null(GameEvents.ParseMagicLayeredSpellList(payload));
|
||||
}
|
||||
|
||||
private static byte[] BuildEnchantment(
|
||||
ushort spellId, ushort layer, double duration, uint caster)
|
||||
{
|
||||
byte[] payload = new byte[60];
|
||||
int offset = 0;
|
||||
WriteU16(spellId); WriteU16(layer); WriteU16(3); WriteU16(0);
|
||||
WriteU32(8); WriteF64(10); WriteF64(duration); WriteU32(caster);
|
||||
WriteF32(0.1f); WriteF32(-1f); WriteF64(0);
|
||||
WriteU32(0x20); WriteU32(7); WriteF32(1.25f);
|
||||
return payload;
|
||||
|
||||
void WriteU16(ushort value) { BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(offset), value); offset += 2; }
|
||||
void WriteU32(uint value) { BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(offset), value); offset += 4; }
|
||||
void WriteF32(float value) { BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(offset), value); offset += 4; }
|
||||
void WriteF64(double value) { BinaryPrimitives.WriteDoubleLittleEndian(payload.AsSpan(offset), value); offset += 8; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,6 +147,31 @@ public sealed class ClientCommandRequestsTests
|
|||
Assert.Equal(25u, Read(body, 16));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSpellFavorite_MatchesRetailThreeIntegerPayload()
|
||||
{
|
||||
byte[] body = ClientCommandRequests.BuildAddSpellFavorite(9u, 42u, 3, 7);
|
||||
|
||||
Assert.Equal(24, body.Length);
|
||||
Assert.Equal(ClientCommandRequests.AddSpellFavoriteOpcode, Read(body, 8));
|
||||
Assert.Equal(42u, Read(body, 12));
|
||||
Assert.Equal(3u, Read(body, 16));
|
||||
Assert.Equal(7u, Read(body, 20));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveFavoriteAndFilter_MatchRetailPayloads()
|
||||
{
|
||||
byte[] remove = ClientCommandRequests.BuildRemoveSpellFavorite(2u, 42u, 6);
|
||||
byte[] filter = ClientCommandRequests.BuildSpellbookFilter(3u, 0xA5u);
|
||||
|
||||
Assert.Equal(ClientCommandRequests.RemoveSpellFavoriteOpcode, Read(remove, 8));
|
||||
Assert.Equal(42u, Read(remove, 12));
|
||||
Assert.Equal(6u, Read(remove, 16));
|
||||
Assert.Equal(ClientCommandRequests.SpellbookFilterOpcode, Read(filter, 8));
|
||||
Assert.Equal(0xA5u, Read(filter, 12));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyFriendsCommand_IsAControlMessageWithoutGameActionEnvelope()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -628,6 +628,22 @@ public sealed class CreateObjectTests
|
|||
Assert.Equal(0x50000001u, parsed.Value.PetOwnerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeenieHeader_spellId_isPreservedForCasterEndowment()
|
||||
{
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x50000025u,
|
||||
name: "Orb",
|
||||
itemType: (uint)ItemType.Caster,
|
||||
weenieFlags: 0x00400000u,
|
||||
spellId: 0x0A6E);
|
||||
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0x0A6Eu, parsed.Value.SpellId);
|
||||
}
|
||||
|
||||
private static byte[] BuildMinimalCreateObjectWithWeenieHeader(
|
||||
uint guid,
|
||||
string name,
|
||||
|
|
@ -673,7 +689,8 @@ public sealed class CreateObjectTests
|
|||
uint materialType = 0,
|
||||
uint cooldownId = 0,
|
||||
double cooldownDuration = 0,
|
||||
uint petOwnerId = 0)
|
||||
uint petOwnerId = 0,
|
||||
ushort spellId = 0)
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
WriteU32(bytes, CreateObject.Opcode);
|
||||
|
|
@ -760,7 +777,7 @@ public sealed class CreateObjectTests
|
|||
bytes.AddRange(tmp.ToArray());
|
||||
}
|
||||
if ((weenieFlags & 0x00200000u) != 0) WriteU16(bytes, burden ?? 0); // Burden u16
|
||||
if ((weenieFlags & 0x00400000u) != 0) WriteU16(bytes, 0); // Spell u16
|
||||
if ((weenieFlags & 0x00400000u) != 0) WriteU16(bytes, spellId); // Spell u16
|
||||
if ((weenieFlags & 0x02000000u) != 0) WriteU32(bytes, 0); // HouseOwner u32
|
||||
// HouseRestrictions (0x04000000): not parameterized (zero entries).
|
||||
// Wire: Version(u32) + OpenStatus(u32) + MonarchId(u32) + count(u16) + numBuckets(u16) + entries.
|
||||
|
|
|
|||
|
|
@ -237,7 +237,8 @@ public sealed class GameEventDispatcherTests
|
|||
public void ParseMagicUpdateSpell_RoundTrip()
|
||||
{
|
||||
byte[] payload = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1u); // Flame Bolt I
|
||||
Assert.Equal(0x3E1u, GameEvents.ParseMagicUpdateSpell(payload));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x123403E1u);
|
||||
var parsed = GameEvents.ParseMagicUpdateSpell(payload);
|
||||
Assert.Equal(0x123403E1u, parsed);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -379,7 +379,7 @@ public sealed class PlayerDescriptionParserTests
|
|||
writer.Write(0u); // option_flags = None — no further sections
|
||||
writer.Write(0xDEADBEEFu); // options1 sentinel
|
||||
|
||||
// No more bytes — spellbook_filters is optional (defaults to 0).
|
||||
// No more bytes — spellbook_filters is optional (retail defaults all on).
|
||||
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
|
|
@ -392,7 +392,7 @@ public sealed class PlayerDescriptionParserTests
|
|||
// pre-existing regression guard if they accidentally consume into
|
||||
// the wrong field's wire bytes.
|
||||
Assert.Equal(0u, parsed.Value.Options2);
|
||||
Assert.Equal(0u, parsed.Value.SpellbookFilters);
|
||||
Assert.Equal(0x3FFFu, parsed.Value.SpellbookFilters);
|
||||
Assert.Empty(parsed.Value.HotbarSpells);
|
||||
Assert.Empty(parsed.Value.DesiredComps);
|
||||
Assert.True(parsed.Value.GameplayOptions.IsEmpty);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue