fix(net): FA1 review round -- zero-id tree rejection, monarch clear, 0x001F builder
Applies both MUST-FIX items and the code-facing SHOULD-FIX items from the dual-lens FA1 review (docs/research/2026-08-12-fa1-review-mechanism.md, docs/research/2026-08-12-fa1-review-blast.md): Mechanism MF-1 / blast SF-2: AllegianceHierarchy::Add @0x005B6E90 wraps its entire body in `if (_id != 0)` -- a record whose own id is zero discards the WHOLE message, for both the monarch and a child record, and this is also what makes treeParent == 0 unconditionally fatal for a non-monarch record. ReadAllegianceProfileBody now rejects CharacterId == 0 on both paths; four new boundary tests in AllegianceProfileVersionGateTests.cs (zero-id monarch, zero-id child, zero treeParent, plus the existing orphan/self-parent/duplicate trio). Mechanism MF-2: added the missing 0x001F AllegianceUpdateRequest builder -- the structural twin of the fellowship 0x00A6 this slice already repaired -- with golden-vector tests for both on/off. Mechanism SF-1 / blast SF-3: UnPack's last act before returning success forces the monarch's MayPassupExperience to false regardless of the wire bit or the HasPackedLevel-absent legacy-compat fallback. Ported at the end of the record loop; the pre-existing HasPackedLevel-absent test moved off the monarch record (which the new clear makes indistinguishable from "the fallback never fired") onto a vassal record, and a new test proves the monarch clear fires even when the wire bit explicitly asks for true. Mechanism SF-2: removed ParseFellowshipDisband's invented body-length validation -- retail's DispatchUI_Disband reads only the opcode and never inspects a trailing body. The parser now always succeeds; the matching test flips from asserting rejection to asserting acceptance. Mechanism SF-3: added the D5 `<<1` shareLoot-shape test at the 0x02C0 FellowshipUpdateFellow site -- previously only pinned at 0x02BE, so a future split of the shared ReadFellow helper could silently reintroduce a bool read on this leg undetected. Mechanism SF-5: renumbered the version-gate comments in ReadAllegianceProfileBody to the true AllegianceVersion enum values (1-11, matching acclient.h's SpokespersonAdded..ApprovedVassal) instead of wire-appearance order, which only reached 10 and silently dropped gate 5 (BannedCharactersAdded, which is real but gates nothing in UnPack -- now called out explicitly). Fixed the stale "lane B §12" citation in SocialActions.cs to the actual master-table row. Blast SF-1: pinned the two retail-faithful but user-visible behavior changes FA1 made to the ALREADY-LIVE `@allegiance info` command -- reversed vassal print order (3-vassal test through FormatAllegianceInfoLines) and malformed-tree silent-drop (test at the GameEventWiring registration layer, which is `if (info is null) return;`). Blast SF-4: fixed a doc comment citing a nonexistent `ConfirmationResponseTests` class; the actual class is `ConfirmationTripleTests`. Blast SF-5: cross-referenced the confirmation-triple discriminator's split representation (ConfirmationType on the response leg only; bare uint on the two inbound legs production actually reads) at both sites, so FA4 inherits a stated decision rather than an unexplained inconsistency. Full Release suite: 13,158 passed / 4 skipped / 0 failed (13,162 total), up from the pre-fix-round 13,149/4/0 (+9 tests this round). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
bc693728a6
commit
ed30808720
8 changed files with 385 additions and 34 deletions
|
|
@ -408,11 +408,24 @@ public static class ClientCommandResponses
|
|||
ushort recordCount = ReadU16(payload, ref pos);
|
||||
ushort oldVersion = ReadU16(payload, ref pos);
|
||||
|
||||
// §4.2 gates 1/2: officers (oldVersion >= 6,
|
||||
// MultipleAllegianceOfficersAdded) vs the legacy single
|
||||
// spokesperson-id 4-byte skip (1 <= oldVersion < 6). Entries are
|
||||
// consumed but not surfaced (ACE always sends officers empty —
|
||||
// lane C §5.1) so every later field still lands correctly.
|
||||
// §4.2's ELEVEN gates are the eleven non-zero AllegianceVersion
|
||||
// enum values (acclient.h:2979-2994, SpokespersonAdded=1 through
|
||||
// ApprovedVassal=11), numbered below by THAT version number — not
|
||||
// by wire-appearance order, which is a different sequence (the
|
||||
// officers table at version 6 is read before officer titles at
|
||||
// version 9 but after the version-1/2/3/4 fields). Gate 5
|
||||
// (BannedCharactersAdded) is real — it is one of the eleven
|
||||
// AllegianceVersion values — but gates NOTHING in UnPack: the ban
|
||||
// list never rides this blob, so there is no field/read for it
|
||||
// here (pinned by the negative test
|
||||
// VersionGate_4to5_BannedCharactersAddedGatesNothing).
|
||||
|
||||
// Gate 1 (SpokespersonAdded, 1 <= oldVersion < 6) vs gate 6
|
||||
// (MultipleAllegianceOfficersAdded, oldVersion >= 6): the officers
|
||||
// PHashTable REPLACES the legacy single spokesperson-id 4-byte
|
||||
// skip at version 6. Entries are consumed but not surfaced (ACE
|
||||
// always sends officers empty — lane C §5.1) so every later field
|
||||
// still lands correctly.
|
||||
if (oldVersion >= 6)
|
||||
{
|
||||
ushort officerCount = ReadU16(payload, ref pos);
|
||||
|
|
@ -428,9 +441,8 @@ public static class ClientCommandResponses
|
|||
_ = ReadU32(payload, ref pos); // old single spokesperson id
|
||||
}
|
||||
|
||||
// §4.2 gate 3: officer titles (oldVersion >= 9,
|
||||
// OfficersTitlesAdded) — PSmartArray<PString>: a bare i32 count,
|
||||
// NOT the PackableHashTable u16/u16 header.
|
||||
// Gate 9 (OfficersTitlesAdded, oldVersion >= 9) — PSmartArray<PString>:
|
||||
// a bare i32 count, NOT the PackableHashTable u16/u16 header.
|
||||
if (oldVersion >= 9)
|
||||
{
|
||||
int titleCount = unchecked((int)ReadU32(payload, ref pos));
|
||||
|
|
@ -438,7 +450,7 @@ public static class ClientCommandResponses
|
|||
_ = StringReader.ReadString16L(payload, ref pos);
|
||||
}
|
||||
|
||||
// §4.2 gate 4 (PoolsAdded, oldVersion >= 2): four broadcast counters.
|
||||
// Gate 2 (PoolsAdded, oldVersion >= 2): four broadcast counters.
|
||||
if (oldVersion >= 2)
|
||||
{
|
||||
_ = ReadU32(payload, ref pos); // monarchBroadcastTime
|
||||
|
|
@ -447,7 +459,7 @@ public static class ClientCommandResponses
|
|||
_ = ReadU32(payload, ref pos); // spokesBroadcastsToday
|
||||
}
|
||||
|
||||
// §4.2 gate 5 (MotdAdded, oldVersion >= 3).
|
||||
// Gate 3 (MotdAdded, oldVersion >= 3).
|
||||
string motd = "";
|
||||
string motdSetBy = "";
|
||||
if (oldVersion >= 3)
|
||||
|
|
@ -456,12 +468,12 @@ public static class ClientCommandResponses
|
|||
motdSetBy = StringReader.ReadString16L(payload, ref pos);
|
||||
}
|
||||
|
||||
// §4.2 gate 6 (ChatRoomIDAdded, oldVersion >= 4).
|
||||
// Gate 4 (ChatRoomIDAdded, oldVersion >= 4).
|
||||
uint chatRoomId = 0;
|
||||
if (oldVersion >= 4)
|
||||
chatRoomId = ReadU32(payload, ref pos);
|
||||
|
||||
// §4.2 gate 7 (Bindstones, oldVersion >= 7): Position =
|
||||
// Gate 7 (Bindstones, oldVersion >= 7): Position =
|
||||
// cell(u32) + pos(3xfloat) + rotation(4xfloat, W/X/Y/Z) = 32
|
||||
// bytes. Skipped, not surfaced — see the class doc on
|
||||
// AllegianceProfileBody for why.
|
||||
|
|
@ -471,7 +483,7 @@ public static class ClientCommandResponses
|
|||
_ = ReadU32(payload, ref pos);
|
||||
}
|
||||
|
||||
// §4.2 gate 8 (AllegianceName, oldVersion >= 8).
|
||||
// Gate 8 (AllegianceName, oldVersion >= 8).
|
||||
string allegianceName = "";
|
||||
uint nameLastSetTime = 0;
|
||||
if (oldVersion >= 8)
|
||||
|
|
@ -480,12 +492,12 @@ public static class ClientCommandResponses
|
|||
nameLastSetTime = ReadU32(payload, ref pos);
|
||||
}
|
||||
|
||||
// §4.2 gate 9 (LockedState, oldVersion >= 10).
|
||||
// Gate 10 (LockedState, oldVersion >= 10).
|
||||
bool isLocked = false;
|
||||
if (oldVersion >= 10)
|
||||
isLocked = ReadU32(payload, ref pos) != 0u;
|
||||
|
||||
// §4.2 gate 10 (ApprovedVassal, oldVersion >= 11).
|
||||
// Gate 11 (ApprovedVassal, oldVersion >= 11).
|
||||
uint approvedVassal = 0;
|
||||
if (oldVersion >= 11)
|
||||
approvedVassal = ReadU32(payload, ref pos);
|
||||
|
|
@ -494,15 +506,22 @@ public static class ClientCommandResponses
|
|||
// wire, never version-gated) followed by (recordCount-1) records
|
||||
// each carrying an explicit treeParent.
|
||||
// AllegianceHierarchy::Add @0x005B6E90 discards the WHOLE message
|
||||
// if a treeParent is not already in the tree (orphan), equals the
|
||||
// record's own id (self-parent), or duplicates an id already
|
||||
// seen — modeled here as a running knownIds set; any failure
|
||||
// returns null rather than a partial/corrupted tree.
|
||||
// if: the record's own id is zero (MF-1 — retail's ENTIRE Add
|
||||
// body is wrapped in `if (_id != 0)`, so a zero id falls straight
|
||||
// out to `return 0` for BOTH the monarch and a child record — this
|
||||
// is also what makes treeParent == 0 unconditionally fatal for
|
||||
// every non-monarch record, since 0 can never be a knownId); a
|
||||
// treeParent is not already in the tree (orphan); the treeParent
|
||||
// equals the record's own id (self-parent); or the id duplicates
|
||||
// one already seen — modeled here as a running knownIds set; any
|
||||
// failure returns null rather than a partial/corrupted tree.
|
||||
AllegianceMemberRecord? monarch = null;
|
||||
var records = new List<AllegianceMemberRecord>();
|
||||
if (recordCount > 0)
|
||||
{
|
||||
AllegianceMemberRecord monarchRecord = ReadAllegianceData(payload, ref pos, parentGuid: 0u);
|
||||
if (monarchRecord.CharacterId == 0u)
|
||||
return null;
|
||||
monarch = monarchRecord;
|
||||
var knownIds = new HashSet<uint> { monarchRecord.CharacterId };
|
||||
|
||||
|
|
@ -511,7 +530,8 @@ public static class ClientCommandResponses
|
|||
uint parentGuid = ReadU32(payload, ref pos);
|
||||
AllegianceMemberRecord record = ReadAllegianceData(payload, ref pos, parentGuid);
|
||||
|
||||
if (!knownIds.Contains(parentGuid)
|
||||
if (record.CharacterId == 0u
|
||||
|| !knownIds.Contains(parentGuid)
|
||||
|| parentGuid == record.CharacterId
|
||||
|| knownIds.Contains(record.CharacterId))
|
||||
{
|
||||
|
|
@ -521,6 +541,16 @@ public static class ClientCommandResponses
|
|||
knownIds.Add(record.CharacterId);
|
||||
records.Add(record);
|
||||
}
|
||||
|
||||
// Retail's LAST act before UnPack returns success
|
||||
// (@0x005B77A7-0x005B77B3): the monarch can never pass up,
|
||||
// whatever the wire bit said — force it false regardless of
|
||||
// ReadAllegianceData's HasPackedLevel-absent legacy-compat
|
||||
// fallback (SF-1/SF-3). Inert against ACE (AllegianceData.cs
|
||||
// never sets the bit for a monarch record) but load-bearing
|
||||
// the moment a record is hand-built or read from a non-ACE
|
||||
// server.
|
||||
monarch = monarch.Value with { MayPassupExperience = false };
|
||||
}
|
||||
|
||||
return new AllegianceProfileBody(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue