fix(net): FA1 -- repair fellowship builders, add 0x0290/0x0291, allegiance Kick
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>
This commit is contained in:
parent
bb48d2c89d
commit
7be86f47f6
4 changed files with 264 additions and 35 deletions
|
|
@ -3,25 +3,32 @@ using System.Buffers.Binary;
|
|||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound allegiance GameActions. Both Swear and Break carry a
|
||||
/// Outbound allegiance GameActions. Swear, Break, and Kick all carry a
|
||||
/// single <c>uint32</c> target-guid payload inside the standard
|
||||
/// <c>0xF7B1</c> GameAction envelope.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout (r11 §2.1 / §2.3):
|
||||
/// Wire layout — byte-verified against
|
||||
/// <c>docs/research/2026-08-11-fa-allegiance-wire.md</c> §3.2 (lane C),
|
||||
/// three-way agreed with ACE + Chorizite there (the retail anchor is the
|
||||
/// opcode literal store inside each <c>CM_Allegiance::Event_*</c> sender,
|
||||
/// the strongest possible provenance):
|
||||
/// <code>
|
||||
/// u32 0xF7B1
|
||||
/// u32 gameActionSequence
|
||||
/// u32 subOpcode (0x001D or 0x001E)
|
||||
/// u32 subOpcode (0x001D, 0x001E)
|
||||
/// u32 targetGuid
|
||||
/// </code>
|
||||
/// total 0x10 bytes for both. No change was needed here in Campaign FA
|
||||
/// slice FA1 (2026-08-11) — the pre-existing shape already matched.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Server replies with <c>GameEventAllegianceUpdate</c> (0x0020) and
|
||||
/// <c>GameEventAllegianceAllegianceUpdateDone</c> (0x01C8) on success,
|
||||
/// or a <c>WeenieError</c> on failure (already sworn, already maxed
|
||||
/// vassals, target not online, etc — see r11 §2.1 for the full list).
|
||||
/// vassals, target not online, etc — see lane C §2 master table for the
|
||||
/// full list).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AllegianceRequests
|
||||
|
|
@ -39,12 +46,32 @@ public static class AllegianceRequests
|
|||
/// <summary>
|
||||
/// Break your pledge to <paramref name="targetGuid"/>. Target can be
|
||||
/// your patron (breaking from) OR your vassal (breaking them away).
|
||||
/// Retail's Break button (element <c>0x10000264</c>) always targets
|
||||
/// your own patron — see <see cref="BuildKick"/> for the vassal-facing
|
||||
/// sibling button.
|
||||
/// </summary>
|
||||
public static byte[] BuildBreak(uint gameActionSequence, uint targetGuid)
|
||||
{
|
||||
return Build(gameActionSequence, BreakOpcode, targetGuid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kick a vassal out of your allegiance. Wire-identical to
|
||||
/// <see cref="BuildBreak"/> — both are <c>Event_BreakAllegiance(u32)
|
||||
/// @0x006A69DA</c> (opcode <c>0x001E</c>); retail's Kick button
|
||||
/// (<c>gmAllegianceUI::CloseKickConfirmationDialog @0x00490B00</c>)
|
||||
/// targets the selected vassal (<c>m_iidPossibleKickedVassal</c>)
|
||||
/// where Break targets <c>AllegianceProfile::GetPatron(self)</c> (lane
|
||||
/// C §1.4). Named separately purely so FA2's panel command surface can
|
||||
/// distinguish "break from patron" from "kick a vassal" without both
|
||||
/// call sites reading like the same action; the golden vector is
|
||||
/// identical to <see cref="BuildBreak"/>'s.
|
||||
/// </summary>
|
||||
public static byte[] BuildKick(uint gameActionSequence, uint vassalGuid)
|
||||
{
|
||||
return Build(gameActionSequence, BreakOpcode, vassalGuid);
|
||||
}
|
||||
|
||||
private static byte[] Build(uint seq, uint sub, uint targetGuid)
|
||||
{
|
||||
byte[] body = new byte[16];
|
||||
|
|
|
|||
|
|
@ -33,12 +33,24 @@ public static class SocialActions
|
|||
public const uint QueryItemManaOpcode = 0x0263u; // u32 itemGuid; zero cancels
|
||||
public const uint PingRequestOpcode = 0x01E9u; // no payload
|
||||
|
||||
// Fellowship
|
||||
public const uint FellowshipCreateOpcode = 0x00A2u; // string16L name, bool openness, bool shareXP
|
||||
public const uint FellowshipQuitOpcode = 0x00A3u; // bool disband
|
||||
// Fellowship — field orders byte-verified against
|
||||
// docs/research/2026-08-11-fa-fellowship-wire.md §3.1-§3.7 (lane B),
|
||||
// three-way agreed with ACE + holtburger there. Campaign FA slice FA1
|
||||
// (2026-08-11) repaired two latent defects here (lane B §5.2): (a)
|
||||
// FellowshipCreateOpcode's body never had an "openness" field — the
|
||||
// prior builder invented one and silently sent it as the low byte of
|
||||
// shareXP, which ACE would read as an INVERTED shareXP value; (b)
|
||||
// FellowshipUpdateOpcode (0x00A6) is FellowshipUpdateRequest — panel
|
||||
// VISIBILITY, not fellowship openness (that's the new 0x0291 below) —
|
||||
// the prior "open" parameter name/doc was wrong even though its single
|
||||
// bool byte happened to be in the right wire position.
|
||||
public const uint FellowshipCreateOpcode = 0x00A2u; // string16L name, u32 shareXP
|
||||
public const uint FellowshipQuitOpcode = 0x00A3u; // u32 disband (0/1)
|
||||
public const uint FellowshipDismissOpcode = 0x00A4u; // u32 guid
|
||||
public const uint FellowshipRecruitOpcode = 0x00A5u; // u32 guid
|
||||
public const uint FellowshipUpdateOpcode = 0x00A6u; // bool open
|
||||
public const uint FellowshipUpdateRequestOpcode = 0x00A6u; // u32 panelOpen (0/1) — panel visibility, NOT openness
|
||||
public const uint FellowshipAssignNewLeaderOpcode = 0x0290u; // u32 newLeaderGuid
|
||||
public const uint FellowshipChangeOpennessOpcode = 0x0291u; // u32 isOpen (0/1) — the REAL openness toggle
|
||||
|
||||
// Character options
|
||||
// CH3 (2026-08-09): the full-blob SetCharacterOptions (0x01A1) builder
|
||||
|
|
@ -119,21 +131,26 @@ public static class SocialActions
|
|||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Create a fellowship with a chosen name + options.</summary>
|
||||
public static byte[] BuildFellowshipCreate(
|
||||
uint seq, string fellowshipName, bool openness, bool shareXp)
|
||||
/// <summary>
|
||||
/// Create a fellowship with a chosen name. There is no "openness"
|
||||
/// field on this message — retail's <c>gmFellowshipUI::CreateFellowship
|
||||
/// @0x0048F730</c> writes exactly <c>[str16L name][u32 shareXP]</c>
|
||||
/// (lane B §2.2/§3.1). <paramref name="shareXp"/> is the
|
||||
/// <c>FellowshipShareXP</c> character option (<c>0x0F</c>), not a
|
||||
/// dialog checkbox — retail reads
|
||||
/// <c>PlayerModule::FellowshipShareXP</c> at create time and sends its
|
||||
/// current value.
|
||||
/// </summary>
|
||||
public static byte[] BuildFellowshipCreate(uint seq, string fellowshipName, bool shareXp)
|
||||
{
|
||||
byte[] name = PackString16L(fellowshipName);
|
||||
// 2 bools consume 2 bytes + alignment pad to 4.
|
||||
int boolBlock = 2;
|
||||
int pad = (4 - ((name.Length + boolBlock) & 3)) & 3;
|
||||
byte[] body = new byte[12 + name.Length + boolBlock + pad];
|
||||
byte[] body = new byte[12 + name.Length + 4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipCreateOpcode);
|
||||
Array.Copy(name, 0, body, 12, name.Length);
|
||||
body[12 + name.Length] = openness ? (byte)1 : (byte)0;
|
||||
body[12 + name.Length + 1] = shareXp ? (byte)1 : (byte)0;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
body.AsSpan(12 + name.Length), shareXp ? 1u : 0u);
|
||||
return body;
|
||||
}
|
||||
|
||||
|
|
@ -156,14 +173,59 @@ public static class SocialActions
|
|||
public static byte[] BuildFellowshipRecruit(uint seq, uint targetGuid)
|
||||
=> SingleGuid(seq, FellowshipRecruitOpcode, targetGuid);
|
||||
|
||||
/// <summary>Toggle fellowship open / closed recruiting.</summary>
|
||||
public static byte[] BuildFellowshipUpdate(uint seq, bool open)
|
||||
/// <summary>
|
||||
/// Declare the fellowship panel's visibility to the server
|
||||
/// (<c>0x00A6 FellowshipUpdateRequest</c>). This is NOT fellowship
|
||||
/// openness — retail sends it from
|
||||
/// <c>gmFellowshipUI::OnVisibilityChanged @0x0048E460</c> on panel
|
||||
/// show/hide, and ACE gates the whole <c>0x02C0</c> member-vitals
|
||||
/// stream on it (<c>Fellowship.cs:723</c>): a client that never sends
|
||||
/// this sees names/levels/max-vitals from <c>0x02BE</c> but a roster
|
||||
/// frozen at join time (lane B §4.5, §12). See
|
||||
/// <see cref="BuildFellowshipChangeOpenness"/> for the real openness
|
||||
/// toggle, which is a separate opcode this builder used to be
|
||||
/// (incorrectly) named for.
|
||||
/// </summary>
|
||||
public static byte[] BuildFellowshipUpdateRequest(uint seq, bool panelOpen)
|
||||
{
|
||||
byte[] body = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipUpdateOpcode);
|
||||
body[12] = open ? (byte)1 : (byte)0;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipUpdateRequestOpcode);
|
||||
body[12] = panelOpen ? (byte)1 : (byte)0;
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transfer fellowship leadership to another member
|
||||
/// (<c>0x0290 FellowshipAssignNewLeader</c>). Retail's Quit button, when
|
||||
/// pressed by the current leader, sends this FIRST (targeting
|
||||
/// <c>Fellowship::GetNonLeaderFellowID</c>) and only then sends
|
||||
/// <c>0x00A3</c> disband=0 —
|
||||
/// <c>gmFellowshipUI</c> Quit button case 8 @0x0049034B (lane B
|
||||
/// §2.5/§3.6). <c>AssignLeadershipToFellow @0x0048F1E0</c> is the
|
||||
/// direct (non-quit) caller.
|
||||
/// </summary>
|
||||
public static byte[] BuildFellowshipAssignNewLeader(uint seq, uint newLeaderGuid)
|
||||
=> SingleGuid(seq, FellowshipAssignNewLeaderOpcode, newLeaderGuid);
|
||||
|
||||
/// <summary>
|
||||
/// Toggle fellowship open / closed recruiting — the REAL openness
|
||||
/// action (<c>0x0291 FellowshipChangeOpenness</c>). Retail's Open
|
||||
/// button (case 9) calls
|
||||
/// <c>Event_ChangeFellowOpeness @0x006A6040</c> after pre-toggling its
|
||||
/// own <c>_open_fellow</c> optimistically (lane B §2.5/§3.7) — the
|
||||
/// bytes are identical in shape to the old (misnamed)
|
||||
/// <see cref="BuildFellowshipUpdateRequest"/>, but this is a distinct
|
||||
/// opcode acdream did not previously send at all.
|
||||
/// </summary>
|
||||
public static byte[] BuildFellowshipChangeOpenness(uint seq, bool isOpen)
|
||||
{
|
||||
byte[] body = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipChangeOpennessOpcode);
|
||||
body[12] = isOpen ? (byte)1 : (byte)0;
|
||||
return body;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue