feat(net): FA1 -- S->C parsers for fellowship/allegiance, confirmation triple, allegiance version gates
Campaign FA slice FA1: pure parse functions + typed records only, UNWIRED (FA2 registers them against the new RuntimeFellowshipState/ RuntimeAllegianceState owners -- see docs/research/2026-08-11-fa-acdream-seams.md §2). Fellowship family (GameEvents.cs), field orders from lane B §3.8-§3.13, guid-first on 0x02C0 per the resolved Chorizite disagreement: FellowshipFullUpdate (0x02BE), FellowshipUpdateFellow (0x02C0), FellowshipQuitNotice/FellowshipDismissNotice (S->C 0x00A3/0x00A4), FellowshipDisband (0x02BF, empty body), and the dead FellowshipFellowUpdateDone/FellowshipFellowStatsDone (0x01C9/0x01CA, parse-and-ignore, must never fail per lane B §2.7). ShareLoot is modeled as a raw uint (D5) -- ACE encodes it two incompatible ways (0x10 in full updates, <<1 incremental), so `!= 0` is the only safe read, never `== 1`. Confirmation triple (D6): grepping the tree showed 0x0274/0x0276 already had typed parsers in Core.Net; 0x0275 (client-authored) already had a byte-correct builder but no typed representation. Added the ConfirmationType enum (1 SwearAllegiance, 4 Fellowship, matching retail's Handle_Character__ConfirmationRequest switch and ACE's enum verbatim) and ParseConfirmationResponse, completing Core.Net's typed coverage of all three legs and round-tripping against the existing ClientCommandRequests.BuildConfirmationResponse byte-for-byte. Allegiance small events (GameEvents.cs): AllegianceLoginNotification (0x027A), AllegianceUpdateDone (0x01C8), AllegianceUpdateAborted (0x0003, declared but never sent by ACE). The heavyweight AllegianceUpdate (0x0020) extends ClientCommandResponses.ParseAllegianceInfoResponse (0x027C) rather than a second parser, per lane C §7.2's explicit reuse verdict -- both messages now share ReadAllegianceProfileBody, which the discriminating leading u32 (targetGuid vs rank) is read around. That shared reader implements: - The ELEVEN AllegianceHierarchy::UnPack version gates (lane C §4.2) -- officers/spokesperson-skip, officer titles, the four broadcast counters, motd/motdSetBy, chatRoomId, bind point, allegianceName, isLocked, approvedVassal, each behind its own oldVersion threshold. AllegianceProfileVersionGateTests.cs pins all eleven with a boundary-crossing pair per gate (N-1 OFF vs N ON), including the negative proof that version 5 (BannedCharactersAdded) gates nothing in UnPack. - The §4.4 tree-assembly rules: a record whose treeParent is not already in the tree (orphan), equals its own id (self-parent), or duplicates an id already seen makes AllegianceHierarchy::Add fail, which the whole parse now mirrors by returning null for the ENTIRE message -- not a partial tree. Sibling order REVERSES on assembly (each new record is prepended to its parent's vassal list), so FindVassals now walks records in reverse wire order; both rules have dedicated tests. - AllegianceMemberRecord gained the panel-needed columns lane C §7.2 names (rank, level, loyalty, leadership, cpCached, cpTithed, gender, heritage, MayPassupExperience) with defaulted trailing parameters so existing 4-arg positional construction sites keep compiling. Officers/ officer titles/bind point are read (so every later field lands at the right offset) but deliberately left unsurfaced -- ACE always zeroes/ empties them anyway (lane C §5.1), and bind point is a 32-byte Position the retail chat renderer never uses either; a future panel slice can extend the record without re-deriving the parse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7be86f47f6
commit
6bedbc4772
6 changed files with 1570 additions and 107 deletions
|
|
@ -539,6 +539,324 @@ public static class GameEvents
|
|||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>ConfirmationType</c> discriminator carried by all three legs
|
||||
/// of the shared confirmation triple (<c>0x0274</c>/<c>0x0275</c>/
|
||||
/// <c>0x0276</c>'s leading <c>Type</c>/<c>confirmationType</c> field).
|
||||
/// Retail <c>Handle_Character__ConfirmationRequest @0x005640A0</c>'s
|
||||
/// switch and ACE's <c>ConfirmationType.cs:5-12</c> agree byte-for-byte
|
||||
/// (lane B §3.15, lane C §1.3). Campaign FA needs exactly
|
||||
/// <see cref="SwearAllegiance"/> (1) and <see cref="Fellowship"/> (4)
|
||||
/// — D6.
|
||||
/// </summary>
|
||||
public enum ConfirmationType : uint
|
||||
{
|
||||
SwearAllegiance = 1,
|
||||
AlterSkill = 2,
|
||||
AlterAttribute = 3,
|
||||
Fellowship = 4,
|
||||
CraftInteraction = 5,
|
||||
Augmentation = 6,
|
||||
YesNo = 7,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x0275 ConfirmationResponse</c> — the CLIENT→SERVER leg of the
|
||||
/// confirmation triple (<c>CM_Character::Event_ConfirmationResponse
|
||||
/// @0x006A1210</c>, lane B §3.15 / lane C §3.3). Unlike
|
||||
/// <see cref="CharacterConfirmationRequest"/>/<see cref="CharacterConfirmationDone"/>
|
||||
/// this direction is never received by a real client — acdream already
|
||||
/// builds it (<c>ClientCommandRequests.BuildConfirmationResponse</c>).
|
||||
/// This record + parser exist to give the triple a complete, TYPED
|
||||
/// representation in Core.Net (the <see cref="ConfirmationType"/>
|
||||
/// enum, not a bare <c>uint</c>) and a round-trip conformance check —
|
||||
/// see <c>ConfirmationResponseTests</c> for the golden-vector /
|
||||
/// round-trip pair against <c>BuildConfirmationResponse</c>.
|
||||
/// </summary>
|
||||
public readonly record struct ConfirmationResponse(
|
||||
ConfirmationType Type,
|
||||
uint ContextId,
|
||||
bool Accepted);
|
||||
|
||||
public static ConfirmationResponse? ParseConfirmationResponse(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 12) return null;
|
||||
return new ConfirmationResponse(
|
||||
(ConfirmationType)BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)) != 0);
|
||||
}
|
||||
|
||||
// ── Fellowship (Campaign FA slice FA1, 2026-08-11) ──────────────────────
|
||||
//
|
||||
// UNWIRED: these are pure parse functions + typed records only. FA2
|
||||
// registers them against a Runtime-owned RuntimeFellowshipState via
|
||||
// GameEventWiring.WireAll — see docs/research/2026-08-11-fa-acdream-seams.md
|
||||
// §2. Field orders are byte-verified against
|
||||
// docs/research/2026-08-11-fa-fellowship-wire.md (lane B) §3.8-§3.13,
|
||||
// three-way agreed with ACE + holtburger there.
|
||||
|
||||
/// <summary>
|
||||
/// One retail <c>Fellow</c> record — <c>Fellow::Pack @0x005B9A10</c> /
|
||||
/// <c>Fellow::UnPack @0x005B9AD0</c> (lane B §3.8). The member GUID is
|
||||
/// NOT part of this struct on the wire (it is the enclosing hash
|
||||
/// table's key in <c>0x02BE</c>, or a separate leading field in
|
||||
/// <c>0x02C0</c>) — carried here as <see cref="Guid"/> for convenience
|
||||
/// since both callers already have it in hand when they construct this
|
||||
/// record. <see cref="ShareLoot"/> is the RAW wire u32, never a bool —
|
||||
/// ACE encodes it two mutually-inconsistent ways (<c>0x10</c> in full
|
||||
/// updates, <c><<1</c> in incremental updates), so the only safe
|
||||
/// read is <c>ShareLoot != 0</c>, never <c>== 1</c> (lane B §4.1, D5).
|
||||
/// </summary>
|
||||
public readonly record struct FellowMember(
|
||||
uint Guid,
|
||||
uint CpCache,
|
||||
uint LumCache,
|
||||
uint Level,
|
||||
uint MaxHealth,
|
||||
uint MaxStamina,
|
||||
uint MaxMana,
|
||||
uint CurrentHealth,
|
||||
uint CurrentStamina,
|
||||
uint CurrentMana,
|
||||
uint ShareLoot,
|
||||
string Name);
|
||||
|
||||
/// <summary>One entry of the <c>_fellows_departed</c> hash table (lane B §2.11/§3.9 field 8).</summary>
|
||||
public readonly record struct FellowshipDepartedMember(uint Guid, int DepartedTimestamp);
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x02BE FellowshipFullUpdate</c> — <c>Fellowship::Pack</c>/
|
||||
/// <c>UnPack @0x005B94F0</c> (lane B §3.9). Field 9 (the lock-name
|
||||
/// table) is intentionally NOT parsed here: retail's own
|
||||
/// <c>Fellowship::UnPack</c> stops reading after field 8 and never
|
||||
/// consumes it (lane B §2.7 caution), and lane B's own U3 flags
|
||||
/// <c>FellowshipLockData</c>'s three unknown u32s AND a possible ACE/
|
||||
/// pcap struct-width mismatch as unverified — the BN-fold rule says
|
||||
/// stop and report rather than guess a shape for an unverified table,
|
||||
/// so this parser is deliberately silent on it rather than risk
|
||||
/// desynchronizing on a table nothing in this campaign needs yet.
|
||||
/// </summary>
|
||||
public readonly record struct FellowshipFullUpdate(
|
||||
IReadOnlyList<FellowMember> Members,
|
||||
string Name,
|
||||
uint LeaderGuid,
|
||||
bool ShareXp,
|
||||
bool EvenXpSplit,
|
||||
bool OpenFellow,
|
||||
bool Locked,
|
||||
IReadOnlyList<FellowshipDepartedMember> Departed);
|
||||
|
||||
public static FellowshipFullUpdate? ParseFellowshipFullUpdate(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
int pos = 0;
|
||||
ushort memberCount = FellowshipReadU16(payload, ref pos);
|
||||
_ = FellowshipReadU16(payload, ref pos); // numBuckets — server-chosen (16), not consulted (lane B §3.9)
|
||||
var members = new List<FellowMember>(memberCount);
|
||||
for (int i = 0; i < memberCount; i++)
|
||||
{
|
||||
uint guid = FellowshipReadU32(payload, ref pos);
|
||||
members.Add(ReadFellow(payload, ref pos, guid));
|
||||
}
|
||||
|
||||
string name = ReadString16L(payload, ref pos);
|
||||
uint leaderGuid = FellowshipReadU32(payload, ref pos);
|
||||
bool shareXp = FellowshipReadU32(payload, ref pos) != 0u;
|
||||
bool evenXpSplit = FellowshipReadU32(payload, ref pos) != 0u;
|
||||
bool openFellow = FellowshipReadU32(payload, ref pos) != 0u;
|
||||
bool locked = FellowshipReadU32(payload, ref pos) != 0u;
|
||||
|
||||
ushort departedCount = FellowshipReadU16(payload, ref pos);
|
||||
_ = FellowshipReadU16(payload, ref pos); // numBuckets (32) — not consulted
|
||||
var departed = new List<FellowshipDepartedMember>(departedCount);
|
||||
for (int i = 0; i < departedCount; i++)
|
||||
{
|
||||
uint guid = FellowshipReadU32(payload, ref pos);
|
||||
int timestamp = unchecked((int)FellowshipReadU32(payload, ref pos));
|
||||
departed.Add(new FellowshipDepartedMember(guid, timestamp));
|
||||
}
|
||||
|
||||
return new FellowshipFullUpdate(
|
||||
members, name, leaderGuid, shareXp, evenXpSplit, openFellow, locked, departed);
|
||||
}
|
||||
catch (FormatException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x02C0 FellowshipUpdateFellow</c> —
|
||||
/// <c>DispatchUI_UpdateFellow @0x006A6700</c> (lane B §3.10):
|
||||
/// <c>[u32 guid][Fellow][u32 updateType]</c>, guid FIRST (Chorizite's
|
||||
/// generated shape omits the guid — resolved retail-wins per lane B's
|
||||
/// "Reference disagreement, resolved" note). <c>updateType</c>: 0
|
||||
/// Undef, 1 Full, 2 UpdateStats (ACE never sends this), 3 UpdateVitals.
|
||||
/// </summary>
|
||||
public readonly record struct FellowshipUpdateFellow(
|
||||
uint MemberGuid,
|
||||
FellowMember Member,
|
||||
uint UpdateType);
|
||||
|
||||
public static FellowshipUpdateFellow? ParseFellowshipUpdateFellow(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
int pos = 0;
|
||||
uint guid = FellowshipReadU32(payload, ref pos);
|
||||
FellowMember member = ReadFellow(payload, ref pos, guid);
|
||||
uint updateType = FellowshipReadU32(payload, ref pos);
|
||||
return new FellowshipUpdateFellow(guid, member, updateType);
|
||||
}
|
||||
catch (FormatException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x00A3 FellowshipQuit</c>, the S→C direction —
|
||||
/// <c>DispatchUI_Quit @0x006A5F5F</c> (lane B §3.12):
|
||||
/// <c>[u32 quitterGuid]</c>. Sent both to the quitter and to every
|
||||
/// remaining member; the recipient distinguishes by comparing the guid
|
||||
/// to its own. (The C→S direction of the same opcode is
|
||||
/// <c>SocialActions.BuildFellowshipQuit</c> — a different payload
|
||||
/// shape entirely, disambiguated here by record name.)
|
||||
/// </summary>
|
||||
public readonly record struct FellowshipQuitNotice(uint QuitterGuid);
|
||||
|
||||
public static FellowshipQuitNotice? ParseFellowshipQuit(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return new FellowshipQuitNotice(BinaryPrimitives.ReadUInt32LittleEndian(payload));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x00A4 FellowshipDismiss</c>, the S→C direction —
|
||||
/// <c>DispatchUI_Dismiss @0x006A5EC9</c> (lane B §3.13):
|
||||
/// <c>[u32 dismissedGuid]</c>. ACE's own comment notes the same numeric
|
||||
/// opcode value is used in both directions with different envelopes.
|
||||
/// </summary>
|
||||
public readonly record struct FellowshipDismissNotice(uint DismissedGuid);
|
||||
|
||||
public static FellowshipDismissNotice? ParseFellowshipDismiss(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return new FellowshipDismissNotice(BinaryPrimitives.ReadUInt32LittleEndian(payload));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x02BF FellowshipDisband</c> — empty body
|
||||
/// (<c>DispatchUI_Disband @0x006A5E80</c> reads only the opcode; ACE
|
||||
/// writes no body — lane B §3.11).
|
||||
/// </summary>
|
||||
public static bool ParseFellowshipDisband(ReadOnlySpan<byte> payload) => payload.Length == 0;
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x01C9 FellowshipFellowUpdateDone</c> — dead in the Sept-2013
|
||||
/// client: both this and <see cref="FellowshipFellowStatsDone"/>
|
||||
/// COMDAT-fold onto the identical no-op body (lane B §2.7). The
|
||||
/// dispatcher DOES read a trailing u32 payload even though nothing
|
||||
/// consumes it, and ACE currently writes a zero-length body — so this
|
||||
/// parser must succeed on BOTH an empty payload and a trailing u32,
|
||||
/// and never fail the message (parse-and-ignore). <see cref="RawValue"/>
|
||||
/// is surfaced only for diagnostics.
|
||||
/// </summary>
|
||||
public readonly record struct FellowshipFellowUpdateDone(uint? RawValue);
|
||||
|
||||
public static FellowshipFellowUpdateDone ParseFellowshipFellowUpdateDone(ReadOnlySpan<byte> payload)
|
||||
=> new(payload.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(payload) : null);
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x01CA FellowshipFellowStatsDone</c> — same dead-COMDAT-fold
|
||||
/// class as <see cref="FellowshipFellowUpdateDone"/> (lane B §2.7);
|
||||
/// kept as a distinct record type to match the distinct
|
||||
/// <see cref="GameEventType"/> id even though the shape is identical.
|
||||
/// </summary>
|
||||
public readonly record struct FellowshipFellowStatsDone(uint? RawValue);
|
||||
|
||||
public static FellowshipFellowStatsDone ParseFellowshipFellowStatsDone(ReadOnlySpan<byte> payload)
|
||||
=> new(payload.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(payload) : null);
|
||||
|
||||
private static FellowMember ReadFellow(ReadOnlySpan<byte> payload, ref int pos, uint guid)
|
||||
{
|
||||
uint cpCache = FellowshipReadU32(payload, ref pos);
|
||||
uint lumCache = FellowshipReadU32(payload, ref pos);
|
||||
uint level = FellowshipReadU32(payload, ref pos);
|
||||
uint maxHealth = FellowshipReadU32(payload, ref pos);
|
||||
uint maxStamina = FellowshipReadU32(payload, ref pos);
|
||||
uint maxMana = FellowshipReadU32(payload, ref pos);
|
||||
uint currentHealth = FellowshipReadU32(payload, ref pos);
|
||||
uint currentStamina = FellowshipReadU32(payload, ref pos);
|
||||
uint currentMana = FellowshipReadU32(payload, ref pos);
|
||||
uint shareLoot = FellowshipReadU32(payload, ref pos); // RAW — D5/lane B §4.1: != 0, NEVER == 1
|
||||
string name = ReadString16L(payload, ref pos);
|
||||
return new FellowMember(
|
||||
guid, cpCache, lumCache, level, maxHealth, maxStamina, maxMana,
|
||||
currentHealth, currentStamina, currentMana, shareLoot, name);
|
||||
}
|
||||
|
||||
private static uint FellowshipReadU32(ReadOnlySpan<byte> source, ref int pos)
|
||||
{
|
||||
if (source.Length - pos < 4) throw new FormatException("truncated u32");
|
||||
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos));
|
||||
pos += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static ushort FellowshipReadU16(ReadOnlySpan<byte> source, ref int pos)
|
||||
{
|
||||
if (source.Length - pos < 2) throw new FormatException("truncated u16");
|
||||
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
|
||||
pos += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Allegiance small events (Campaign FA slice FA1, 2026-08-11) ─────────
|
||||
//
|
||||
// UNWIRED (FA2 connects them). The heavyweight 0x0020 AllegianceUpdate
|
||||
// (profile push) and its shared-parser reuse of
|
||||
// ClientCommandResponses.ParseAllegianceInfoResponse live in
|
||||
// ClientCommandResponses.cs (lane C §7.2's explicit reuse verdict), not
|
||||
// here — this section covers the small fixed-shape allegiance events.
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x027A AllegianceLoginNotification</c> —
|
||||
/// <c>DispatchUI_AllegianceLoginNotificationEvent @0x006A6920</c> (lane
|
||||
/// C §2 row 8, §4.5): <c>[u32 characterGuid][u32 isLoggedIn]</c>. Retail
|
||||
/// prints nothing if the guid is not already in the cached profile
|
||||
/// (lane C §1.6) — that filtering is a display-time concern for the
|
||||
/// consumer, not this parser.
|
||||
/// </summary>
|
||||
public readonly record struct AllegianceLoginNotification(uint CharacterGuid, bool IsLoggedIn);
|
||||
|
||||
public static AllegianceLoginNotification? ParseAllegianceLoginNotification(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 8) return null;
|
||||
return new AllegianceLoginNotification(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)) != 0u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x01C8 AllegianceUpdateDone</c> — the panel busy-cursor
|
||||
/// terminator (lane C §1.2, §2 row 6): a single <c>WeenieError</c>
|
||||
/// u32 (0 on success).
|
||||
/// </summary>
|
||||
public static uint? ParseAllegianceUpdateDone(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x0003 AllegianceUpdateAborted</c> — declared by retail
|
||||
/// (<c>DispatchUI_AllegianceUpdateAborted @0x006A6950</c>) but never
|
||||
/// actually sent by ACE (lane C §2 row 7, §5.4) — a single
|
||||
/// <c>WeenieError</c> u32, parsed for completeness/forward-compat.
|
||||
/// </summary>
|
||||
public static uint? ParseAllegianceUpdateAborted(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
|
||||
// ── House ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue