using System; using System.Buffers.Binary; using System.Collections.Generic; using System.Text; using AcDream.Core.Items; using AcDream.Core.Net.Messages; using Xunit; namespace AcDream.Core.Net.Tests.Messages; public sealed class SocialActionsTests { [Fact] public void BuildQueryHealth_HasOpcode0x01BFAndGuid() { byte[] body = SocialActions.BuildQueryHealth(seq: 3, targetGuid: 0xCAFE); Assert.Equal(SocialActions.QueryHealthOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); Assert.Equal(0xCAFEu, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); } [Fact] public void BuildQueryItemMana_HasOpcode0x0263AndGuid() { // CM_Item::Event_QueryItemMana @ 0x006A8610: F7B1, seq, 0x0263, guid. byte[] body = SocialActions.BuildQueryItemMana(seq: 4, itemGuid: 0x50000A01u); Assert.Equal(16, body.Length); Assert.Equal(SocialActions.GameActionEnvelope, BinaryPrimitives.ReadUInt32LittleEndian(body)); Assert.Equal(4u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); Assert.Equal(SocialActions.QueryItemManaOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); } [Fact] public void BuildPingRequest_HasOpcode0x01E9() { byte[] body = SocialActions.BuildPingRequest(seq: 1); Assert.Equal(12, body.Length); Assert.Equal(SocialActions.GameActionEnvelope, BinaryPrimitives.ReadUInt32LittleEndian(body)); Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); Assert.Equal(SocialActions.PingRequestOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); } [Fact] public void BuildFellowshipCreate_StringThenBools() { byte[] body = SocialActions.BuildFellowshipCreate( seq: 1, fellowshipName: "Team", openness: true, shareXp: false); Assert.Equal(SocialActions.FellowshipCreateOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); // String at offset 12: u16 length = 4 ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(12)); Assert.Equal(4, len); Assert.Equal("Team", Encoding.ASCII.GetString(body.AsSpan(14, 4))); // string16L record = 2+4=6, pad 2 → advance by 8. // Then 2 bools at offset 20,21. Assert.Equal(1, body[20]); // openness true Assert.Equal(0, body[21]); // shareXp false } [Fact] public void BuildFellowshipQuit_CarriesDisbandFlag() { byte[] body = SocialActions.BuildFellowshipQuit(seq: 1, disband: true); Assert.Equal(SocialActions.FellowshipQuitOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); Assert.Equal(1, body[12]); } [Fact] public void BuildFellowshipDismiss_HasGuid() { byte[] body = SocialActions.BuildFellowshipDismiss(seq: 1, targetGuid: 0xDEAD); Assert.Equal(SocialActions.FellowshipDismissOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); Assert.Equal(0xDEADu, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); } [Fact] public void BuildFellowshipRecruit_HasGuid() { byte[] body = SocialActions.BuildFellowshipRecruit(seq: 1, targetGuid: 0xBEEF); Assert.Equal(SocialActions.FellowshipRecruitOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); } [Fact] public void BuildFellowshipUpdate_HasOpenBool() { byte[] body = SocialActions.BuildFellowshipUpdate(seq: 1, open: true); Assert.Equal(SocialActions.FellowshipUpdateOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); Assert.Equal(1, body[12]); } [Fact] public void BuildSetSingleCharacterOption_HasOptionIdThenValue() { // ACE GameActionSetSingleCharacterOption.cs:11-12 and holtburger // SetSingleCharacterOptionActionData::pack: u32 option, u32 value. byte[] body = SocialActions.BuildSetSingleCharacterOption( seq: 1, optionId: 0x26u, value: true); Assert.Equal(20, body.Length); Assert.Equal(SocialActions.GameActionEnvelope, BinaryPrimitives.ReadUInt32LittleEndian(body)); Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); Assert.Equal(SocialActions.SetSingleCharacterOptionOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); Assert.Equal(0x26u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); } [Fact] public void BuildSetSingleCharacterOption_FalseValueEncodesZero() { byte[] body = SocialActions.BuildSetSingleCharacterOption( seq: 2, optionId: 0x23u, value: false); Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); } // ── OP1 (Campaign OP, 2026-08-10): BuildSetCharacterOptions (0x01A1) ── // docs/research/2026-08-10-set-character-options-wire.md §2.3-§2.7. The // golden vector below is HAND-COMPUTED, field by field, from that // layout — not generated by calling the builder under test. The CH3 // builder (deleted 2026-08-09) died of ten green tests pinning a wrong // shape; a golden vector this way is the only test that can catch the // SAME class of mistake (wire doc §6.2). [Fact] public void BuildSetCharacterOptions_GoldenByteVector_MatchesHandComputedLayout() { ShortcutEntry[] shortcuts = [new ShortcutEntry(0, 0x80000001u, 0u)]; IReadOnlyList[] favorites = [ new uint[] { 1234u }, // tab 0 Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), ]; var desiredComponents = new Dictionary { [0x68000001u] = 12u }; byte[] body = SocialActions.BuildSetCharacterOptions( seq: 5u, options1: 0x50C4A54Au, options2: 0x00948700u, shortcuts: shortcuts, favoriteSpells: favorites, desiredComponents: desiredComponents, spellbookFilters: 0x3FFFu); byte[] expected = [ 0xB1, 0xF7, 0x00, 0x00, // envelope 0xF7B1 0x05, 0x00, 0x00, 0x00, // seq 5 0xA1, 0x01, 0x00, 0x00, // opcode 0x01A1 0x69, 0x04, 0x00, 0x00, // header 0x469 (base 0x460 | shortcuts 0x001 | desiredComps 0x008) 0x4A, 0xA5, 0xC4, 0x50, // options1 0x50C4A54A 0x01, 0x00, 0x00, 0x00, // shortcuts count = 1 0x00, 0x00, 0x00, 0x00, // index 0 0x01, 0x00, 0x00, 0x80, // objectId 0x80000001 0x00, 0x00, 0x00, 0x00, // spellId 0 0x01, 0x00, 0x00, 0x00, // tab0 count = 1 0xD2, 0x04, 0x00, 0x00, // spellId 1234 (0x4D2) 0x00, 0x00, 0x00, 0x00, // tab1 count = 0 0x00, 0x00, 0x00, 0x00, // tab2 count = 0 0x00, 0x00, 0x00, 0x00, // tab3 count = 0 0x00, 0x00, 0x00, 0x00, // tab4 count = 0 0x00, 0x00, 0x00, 0x00, // tab5 count = 0 0x00, 0x00, 0x00, 0x00, // tab6 count = 0 0x00, 0x00, 0x00, 0x00, // tab7 count = 0 0x01, 0x00, 0x00, 0x00, // desiredComps sizeInfo = 1 0x01, 0x00, 0x00, 0x68, // key 0x68000001 0x0C, 0x00, 0x00, 0x00, // value 12 0xFF, 0x3F, 0x00, 0x00, // spellbookFilters 0x3FFF 0x00, 0x87, 0x94, 0x00, // options2 0x00948700 ]; Assert.Equal(expected, body); Assert.Equal(92, body.Length); } [Fact] public void BuildSetCharacterOptions_OmitsOptionalHeaderBitsWhenSectionsEmpty() { IReadOnlyList[] favorites = [ Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), ]; byte[] body = SocialActions.BuildSetCharacterOptions( seq: 1u, options1: 0u, options2: 0u, shortcuts: Array.Empty(), favoriteSpells: favorites, desiredComponents: new Dictionary(), spellbookFilters: 0u); // Base header only: PM_Packed_8_SpellLists | SpellbookFilters | 2ndCharacterOptions. Assert.Equal(0x460u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); // envelope+seq+opcode(12) + header+options1(8) + 8 empty lists(32) // + spellbookFilters+options2(8). Assert.Equal(12 + 8 + 32 + 8, body.Length); } [Fact] public void BuildSetCharacterOptions_RequiresExactlyEightFavoriteSpellLists() { IReadOnlyList[] tooFew = [Array.Empty(), Array.Empty()]; Assert.Throws(() => SocialActions.BuildSetCharacterOptions( seq: 1u, options1: 0u, options2: 0u, shortcuts: Array.Empty(), favoriteSpells: tooFew, desiredComponents: new Dictionary(), spellbookFilters: 0u)); } [Fact] public void BuildSetCharacterOptions_RoundTripsThroughPlayerDescriptionParser() { ShortcutEntry[] shortcuts = [ new ShortcutEntry(3, 0x70000010u, 0u), new ShortcutEntry(4, 0x70000011u, 5u), ]; IReadOnlyList[] favorites = new IReadOnlyList[8]; favorites[0] = new uint[] { 111u, 222u }; for (int tab = 1; tab < 8; tab++) favorites[tab] = Array.Empty(); var desiredComponents = new Dictionary { [0x68000002u] = 3u, [0x68000003u] = 7u, }; byte[] body = SocialActions.BuildSetCharacterOptions( seq: 9u, options1: 0x12345678u, options2: 0x0000ABCDu, shortcuts: shortcuts, favoriteSpells: favorites, desiredComponents: desiredComponents, spellbookFilters: 0x1234u); // Strip the 12-byte envelope/seq/opcode — PlayerModule::Pack's own // payload starts at `header`, which is exactly where // PlayerDescriptionParser's trailer starts reading too (wire // research §2.6: ACE's PlayerDescription trailer reader and // PlayerModule::Pack agree field-for-field). Prefix a minimal empty // PlayerDescription header (propertyFlags=0, weenieType=0, // vectorFlags=0, hasHealth=0) so the parser walks straight into it. byte[] packPayload = body[12..]; byte[] syntheticPlayerDescription = new byte[16 + packPayload.Length]; packPayload.CopyTo(syntheticPlayerDescription, 16); PlayerDescriptionParser.Parsed? parsed = PlayerDescriptionParser.TryParse(syntheticPlayerDescription); Assert.NotNull(parsed); Assert.False(parsed!.Value.TrailerTruncated); Assert.Equal(0x12345678u, parsed.Value.Options1); Assert.Equal(0x0000ABCDu, parsed.Value.Options2); Assert.Equal(0x1234u, parsed.Value.SpellbookFilters); Assert.Equal(shortcuts, parsed.Value.Shortcuts); Assert.Equal(8, parsed.Value.HotbarSpells.Count); Assert.Equal(new uint[] { 111u, 222u }, parsed.Value.HotbarSpells[0]); for (int tab = 1; tab < 8; tab++) Assert.Empty(parsed.Value.HotbarSpells[tab]); Assert.Equal(2, parsed.Value.DesiredComps.Count); Assert.Contains((0x68000002u, 3u), parsed.Value.DesiredComps); Assert.Contains((0x68000003u, 7u), parsed.Value.DesiredComps); } }