Campaign FA slice FA1 (lane B field-order sections §3.1-§3.7, lane C §3.2, §1.4). Two latent acdream builder defects repaired, both cited in lane B §5.2: - BuildFellowshipCreate (0x00A2) invented a nonexistent "openness" byte and silently sent it as the low byte of shareXP -- ACE would read an INVERTED shareXP value. Corrected to retail's real shape: [str16L name][u32 shareXP]. shareXP is the FellowshipShareXP character option, not a dialog checkbox. - BuildFellowshipUpdate(open:) mislabeled 0x00A6 as fellowship openness; it is FellowshipUpdateRequest -- panel VISIBILITY. Renamed to BuildFellowshipUpdateRequest(panelOpen:); the wire bytes were already correct, only the name/doc were wrong. ACE gates the whole 0x02C0 member-vitals stream on this message (lane B §4.5) -- a prerequisite for live vitals once FA4 wires the panel. Two builders added that acdream never had at all: - BuildFellowshipAssignNewLeader (0x0290) -- retail's leader-Quit path sends this before 0x00A3 disband=0 (lane B §2.5). - BuildFellowshipChangeOpenness (0x0291) -- the REAL openness toggle. - AllegianceRequests.BuildKick -- wire-identical to BuildBreak (both are Event_BreakAllegiance 0x001E); named separately so FA2's panel command surface can distinguish "break from patron" from "kick a vassal" (lane C §1.4). AllegianceInfoRequest (0x027B) was already live via ClientCommandRequests.BuildAllegianceInfoRequest -- not duplicated. Wrong-shape tests at SocialActionsTests.cs:53-105 re-pinned with hand-computed golden byte vectors deriving each field from the cited lane-B sections (not generated by calling the builder under test, per the OP1 convention this file already follows for BuildSetCharacterOptions). AllegianceRequestsTests.cs gained golden vectors for the existing Swear/Break builders plus the new Kick alias. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
381 lines
16 KiB
C#
381 lines
16 KiB
C#
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)));
|
|
}
|
|
|
|
// FA1 (Campaign FA slice FA1, 2026-08-11): re-pinned per
|
|
// docs/research/2026-08-11-fa-fellowship-wire.md §3.1/§5.2(a). The
|
|
// PRIOR version of this test asserted a wrong shape — a fabricated
|
|
// "openness" byte at offset 20 that does not exist on the wire at all
|
|
// — pinning the SAME bug lane B found: ACE reads that byte as the low
|
|
// byte of a u32 shareXP, silently inverting it. The golden vector
|
|
// below is HAND-COMPUTED field-by-field from
|
|
// `CM_Fellowship::Event_Create @0x006A67A0` (`[u32 0xA2][str16L
|
|
// name][u32 shareXP]`, buffer size strPackSize+0x10), not generated by
|
|
// calling the builder under test — the OP1 convention
|
|
// (SocialActionsTests.cs:136-142 states the rule this file already
|
|
// follows for BuildSetCharacterOptions).
|
|
[Fact]
|
|
public void BuildFellowshipCreate_GoldenByteVector_StringThenShareXpU32()
|
|
{
|
|
byte[] body = SocialActions.BuildFellowshipCreate(
|
|
seq: 7, fellowshipName: "Team", shareXp: true);
|
|
|
|
byte[] expected =
|
|
[
|
|
0xB1, 0xF7, 0x00, 0x00, // envelope 0xF7B1
|
|
0x07, 0x00, 0x00, 0x00, // seq 7
|
|
0xA2, 0x00, 0x00, 0x00, // opcode 0x00A2
|
|
0x04, 0x00, 0x54, 0x65, 0x61, 0x6D, 0x00, 0x00, // str16L "Team": u16 len=4, "Team", pad 2
|
|
0x01, 0x00, 0x00, 0x00, // shareXP = 1 (true) — a full u32, NOT a byte
|
|
];
|
|
|
|
Assert.Equal(expected, body);
|
|
Assert.Equal(SocialActions.FellowshipCreateOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildFellowshipCreate_ShareXpFalse_EncodesZeroU32()
|
|
{
|
|
byte[] body = SocialActions.BuildFellowshipCreate(
|
|
seq: 1, fellowshipName: "X", shareXp: false);
|
|
|
|
// str16L "X": len=1, "X", pad to 4 => 4 bytes total (2+1+1 pad).
|
|
Assert.Equal(12 + 4 + 4, body.Length);
|
|
Assert.Equal(0u,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(body.Length - 4)));
|
|
}
|
|
|
|
[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)));
|
|
}
|
|
|
|
// FA1: renamed from BuildFellowshipUpdate/"open" — this opcode (0x00A6
|
|
// FellowshipUpdateRequest) is the panel-VISIBILITY declaration, not
|
|
// fellowship openness (lane B §3.5). The pinned byte shape is
|
|
// unchanged (SocialActionsTests.cs:98-105 pre-FA1 pinned the byte, not
|
|
// the meaning, so it survived the rename per lane B §5.2(b)) — this
|
|
// test just carries the corrected name.
|
|
[Fact]
|
|
public void BuildFellowshipUpdateRequest_GoldenByteVector_PanelOpenBool()
|
|
{
|
|
byte[] body = SocialActions.BuildFellowshipUpdateRequest(seq: 9, panelOpen: true);
|
|
|
|
byte[] expected =
|
|
[
|
|
0xB1, 0xF7, 0x00, 0x00, // envelope
|
|
0x09, 0x00, 0x00, 0x00, // seq 9
|
|
0xA6, 0x00, 0x00, 0x00, // opcode 0x00A6
|
|
0x01, 0x00, 0x00, 0x00, // panelOpen = 1
|
|
];
|
|
|
|
Assert.Equal(expected, body);
|
|
Assert.Equal(SocialActions.FellowshipUpdateRequestOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
}
|
|
|
|
// New builder — 0x0290 FellowshipAssignNewLeader (lane B §2.5/§3.6).
|
|
// Golden vector hand-computed from `Event_AssignNewLeader
|
|
// @0x006A5F70`: `[u32 0x290][u32 newLeaderGuid]`, total 0x10.
|
|
[Fact]
|
|
public void BuildFellowshipAssignNewLeader_GoldenByteVector()
|
|
{
|
|
byte[] body = SocialActions.BuildFellowshipAssignNewLeader(seq: 2, newLeaderGuid: 0x50001234u);
|
|
|
|
byte[] expected =
|
|
[
|
|
0xB1, 0xF7, 0x00, 0x00, // envelope
|
|
0x02, 0x00, 0x00, 0x00, // seq 2
|
|
0x90, 0x02, 0x00, 0x00, // opcode 0x0290
|
|
0x34, 0x12, 0x00, 0x50, // newLeaderGuid 0x50001234
|
|
];
|
|
|
|
Assert.Equal(expected, body);
|
|
Assert.Equal(SocialActions.FellowshipAssignNewLeaderOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
}
|
|
|
|
// New builder — 0x0291 FellowshipChangeOpenness, the REAL openness
|
|
// toggle (lane B §2.5/§3.7). Golden vector hand-computed from
|
|
// `Event_ChangeFellowOpeness @0x006A6040`: `[u32 0x291][u32 isOpen]`,
|
|
// total 0x10.
|
|
[Fact]
|
|
public void BuildFellowshipChangeOpenness_GoldenByteVector()
|
|
{
|
|
byte[] body = SocialActions.BuildFellowshipChangeOpenness(seq: 4, isOpen: false);
|
|
|
|
byte[] expected =
|
|
[
|
|
0xB1, 0xF7, 0x00, 0x00, // envelope
|
|
0x04, 0x00, 0x00, 0x00, // seq 4
|
|
0x91, 0x02, 0x00, 0x00, // opcode 0x0291
|
|
0x00, 0x00, 0x00, 0x00, // isOpen = 0 (false)
|
|
];
|
|
|
|
Assert.Equal(expected, body);
|
|
Assert.Equal(SocialActions.FellowshipChangeOpennessOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
}
|
|
|
|
[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<uint>[] favorites =
|
|
[
|
|
new uint[] { 1234u }, // tab 0
|
|
Array.Empty<uint>(),
|
|
Array.Empty<uint>(),
|
|
Array.Empty<uint>(),
|
|
Array.Empty<uint>(),
|
|
Array.Empty<uint>(),
|
|
Array.Empty<uint>(),
|
|
Array.Empty<uint>(),
|
|
];
|
|
var desiredComponents = new Dictionary<uint, uint> { [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<uint>[] favorites =
|
|
[
|
|
Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>(),
|
|
Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>(),
|
|
];
|
|
|
|
byte[] body = SocialActions.BuildSetCharacterOptions(
|
|
seq: 1u,
|
|
options1: 0u,
|
|
options2: 0u,
|
|
shortcuts: Array.Empty<ShortcutEntry>(),
|
|
favoriteSpells: favorites,
|
|
desiredComponents: new Dictionary<uint, uint>(),
|
|
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<uint>[] tooFew = [Array.Empty<uint>(), Array.Empty<uint>()];
|
|
|
|
Assert.Throws<ArgumentException>(() => SocialActions.BuildSetCharacterOptions(
|
|
seq: 1u,
|
|
options1: 0u,
|
|
options2: 0u,
|
|
shortcuts: Array.Empty<ShortcutEntry>(),
|
|
favoriteSpells: tooFew,
|
|
desiredComponents: new Dictionary<uint, uint>(),
|
|
spellbookFilters: 0u));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildSetCharacterOptions_RoundTripsThroughPlayerDescriptionParser()
|
|
{
|
|
ShortcutEntry[] shortcuts =
|
|
[
|
|
new ShortcutEntry(3, 0x70000010u, 0u),
|
|
new ShortcutEntry(4, 0x70000011u, 5u),
|
|
];
|
|
IReadOnlyList<uint>[] favorites = new IReadOnlyList<uint>[8];
|
|
favorites[0] = new uint[] { 111u, 222u };
|
|
for (int tab = 1; tab < 8; tab++)
|
|
favorites[tab] = Array.Empty<uint>();
|
|
var desiredComponents = new Dictionary<uint, uint>
|
|
{
|
|
[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);
|
|
}
|
|
}
|