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:
Erik 2026-08-12 00:46:42 +02:00
parent bc693728a6
commit ed30808720
8 changed files with 385 additions and 34 deletions

View file

@ -352,6 +352,58 @@ public sealed class AllegianceProfileVersionGateTests
Assert.Null(ClientCommandResponses.ParseAllegianceInfoResponse(wire));
}
// MF-1 (mechanism review): AllegianceHierarchy::Add @0x005B6E90 wraps
// its ENTIRE body in `if (_id != 0)` — a record whose own id is zero
// falls straight out to `return 0`, which UnPack turns into "discard
// the whole message", for BOTH the monarch and a child record. This
// is a FOURTH rejection rule distinct from orphan/self-parent/
// duplicate above, and it is also what makes `treeParent == 0`
// unconditionally fatal for a non-monarch record — 0 can never be a
// knownId once the id==0 monarch case is rejected.
[Fact]
public void TreeAssembly_ZeroIdMonarch_DiscardsWholeMessage()
{
var records = new List<(uint, uint, bool, string)>
{
(0u /* zero id */, 0u, true, "ZeroIdMonarch"),
};
byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
Assert.Null(ClientCommandResponses.ParseAllegianceInfoResponse(wire));
}
[Fact]
public void TreeAssembly_ZeroIdChildRecord_DiscardsWholeMessage()
{
var records = new List<(uint, uint, bool, string)>
{
(MonarchGuid, 0u, true, "Monarch"),
(0u /* zero id */, MonarchGuid, true, "ZeroIdChild"),
};
byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
Assert.Null(ClientCommandResponses.ParseAllegianceInfoResponse(wire));
}
[Fact]
public void TreeAssembly_ZeroTreeParent_OnNonMonarchRecord_DiscardsWholeMessage()
{
// treeParent == 0 for anything but the (implicit) monarch slot can
// never resolve — 0 is never added to knownIds because a zero-id
// record is itself rejected (the two tests above), so this is the
// orphan rule specialized to the zero case retail's own Search
// (arg2 != 0) guard names explicitly.
var records = new List<(uint, uint, bool, string)>
{
(MonarchGuid, 0u, true, "Monarch"),
(0x50000008u, 0u /* treeParent == 0, not the monarch */, true, "ZeroParent"),
};
byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
Assert.Null(ClientCommandResponses.ParseAllegianceInfoResponse(wire));
}
[Fact]
public void TreeAssembly_ValidChain_ParentBeforeChild_Succeeds()
{
@ -451,10 +503,21 @@ public sealed class AllegianceProfileVersionGateTests
{
// Lane C §4.1 point 1: when HasPackedLevel (0x8) is absent, retail
// sets MayPassupExperience itself regardless of the wire bit.
//
// FA1 review round (SF-1/SF-3): the record under test here MUST be
// a non-monarch record. UnPack's LAST act unconditionally forces
// the monarch's MayPassupExperience back to false regardless of
// how it was computed (see
// ReadAllegianceData_MonarchMayPassupExperience_ForcedFalseRegardlessOfWireBit),
// so putting the legacy-compat fixture on the monarch (as this
// test originally did) could never distinguish "the fallback
// fired" from "the monarch clear fired" — both produce the same
// observed value on that record. A vassal record is untouched by
// the monarch-only clear and isolates the fallback.
var w = new AceWireWriter()
.Write(MonarchGuid)
.Write((uint)1).Write((uint)0)
.Write((ushort)1).Write((ushort)11)
.Write((uint)2).Write((uint)0)
.Write((ushort)2).Write((ushort)11)
.Write((ushort)0).Write((ushort)256)
.Write((uint)0)
.Write((uint)0).Write((uint)0).Write((uint)0).Write((uint)0)
@ -465,21 +528,76 @@ public sealed class AllegianceProfileVersionGateTests
.Write((uint)0)
.Write((uint)0)
.Write((uint)0)
// monarch — ordinary HasPackedLevel-set record, not under test.
.Write(MonarchGuid)
.Write((uint)0).Write((uint)0)
.Write((uint)(0x4u | 0x8u))
.Write((byte)0).Write((byte)0)
.Write((ushort)0)
.Write((uint)0) // level (HasPackedLevel set)
.Write((ushort)0).Write((ushort)0)
.Write((uint)0).Write((uint)0)
.WriteString16L("Monarch")
// vassal — the record under test: HasPackedLevel absent.
.Write(MonarchGuid) // treeParent
.Write(0x50000005u)
.Write((uint)0).Write((uint)0)
.Write((uint)0x4u) // HasAllegianceAge only — NO HasPackedLevel, NO MayPassupExperience bit
.Write((byte)0).Write((byte)0)
.Write((ushort)0)
// no level field (HasPackedLevel unset)
.Write((ushort)0).Write((ushort)0)
.Write((uint)0).Write((uint)0)
.WriteString16L("Vassal");
var parsed = ClientCommandResponses.ParseAllegianceInfoResponse(w.ToArray());
Assert.NotNull(parsed);
Assert.False(parsed!.Value.Monarch!.Value.MayPassupExperience); // always forced false
Assert.Single(parsed.Value.Records);
Assert.True(parsed.Value.Records[0].MayPassupExperience); // legacy-compat fallback fired
Assert.Equal(0u, parsed.Value.Records[0].Level); // never read — HasPackedLevel unset
}
// SF-1/SF-3 (review): AllegianceHierarchy::UnPack's LAST act before
// returning success is SetMayPassupExperience(&monarch->_data, 0) —
// the monarch can never pass up, whatever the wire bit says. Build a
// monarch record with the wire bit explicitly SET (0x10) and prove
// the parsed record still comes back false.
[Fact]
public void ReadAllegianceData_MonarchMayPassupExperience_ForcedFalseRegardlessOfWireBit()
{
var w = new AceWireWriter()
.Write(MonarchGuid) // targetGuid
.Write((uint)1).Write((uint)0) // totalMembers, totalVassals
.Write((ushort)1).Write((ushort)11) // recordCount, oldVersion=11 (newest)
.Write((ushort)0).Write((ushort)256) // officers: empty
.Write((uint)0) // officerTitles: empty
.Write((uint)0).Write((uint)0).Write((uint)0).Write((uint)0) // broadcast counters
.WriteString16L("").WriteString16L("") // motd, motdSetBy
.Write((uint)0) // chatRoomID
.Write((uint)0).Write(0f).Write(0f).Write(0f).Write(1f).Write(0f).Write(0f).Write(0f) // bindPoint
.WriteString16L("Alle") // allegianceName
.Write((uint)0) // nameLastSetTime
.Write((uint)0) // isLocked
.Write((uint)0) // approvedVassal
// monarch AllegianceData — HasAllegianceAge | HasPackedLevel |
// MayPassupExperience (0x4|0x8|0x10 = 0x1C), i.e. the wire bit
// asks for MayPassupExperience = true.
.Write(MonarchGuid)
.Write((uint)0).Write((uint)0)
.Write((uint)0x1Cu)
.Write((byte)0).Write((byte)0)
.Write((ushort)1)
.Write((uint)5) // level (HasPackedLevel set)
.Write((ushort)0).Write((ushort)0)
.Write((uint)0).Write((uint)0)
.WriteString16L("Monarch");
var parsed = ClientCommandResponses.ParseAllegianceInfoResponse(w.ToArray());
Assert.NotNull(parsed);
Assert.True(parsed!.Value.Monarch!.Value.MayPassupExperience);
Assert.Equal(0u, parsed.Value.Monarch!.Value.Level); // never read — HasPackedLevel unset
Assert.False(parsed!.Value.Monarch!.Value.MayPassupExperience);
}
// ── 0x0020 AllegianceUpdate shares the same profile reader ─────────────

View file

@ -88,4 +88,41 @@ public sealed class AllegianceRequestsTests
Assert.Equal(AllegianceRequests.BreakOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
}
// FA1 review round (mechanism MF-2): the missing 0x001F
// AllegianceUpdateRequest builder — the structural twin of the
// fellowship 0x00A6 this slice already repaired. Golden vector
// hand-derived from lane C §3.2: [u32 0xF7B1][u32 seq][u32 0x1F]
// [u32 on], total 0x10.
[Fact]
public void BuildAllegianceUpdateRequest_GoldenByteVector_On()
{
byte[] body = AllegianceRequests.BuildAllegianceUpdateRequest(gameActionSequence: 4, on: true);
byte[] expected =
[
0xB1, 0xF7, 0x00, 0x00, // envelope 0xF7B1
0x04, 0x00, 0x00, 0x00, // seq 4
0x1F, 0x00, 0x00, 0x00, // opcode 0x001F
0x01, 0x00, 0x00, 0x00, // on = 1
];
Assert.Equal(expected, body);
}
[Fact]
public void BuildAllegianceUpdateRequest_GoldenByteVector_Off()
{
byte[] body = AllegianceRequests.BuildAllegianceUpdateRequest(gameActionSequence: 7, on: false);
byte[] expected =
[
0xB1, 0xF7, 0x00, 0x00, // envelope 0xF7B1
0x07, 0x00, 0x00, 0x00, // seq 7
0x1F, 0x00, 0x00, 0x00, // opcode 0x001F
0x00, 0x00, 0x00, 0x00, // on = 0
];
Assert.Equal(expected, body);
}
}

View file

@ -314,6 +314,82 @@ public sealed class ClientCommandResponsesTests
lines);
}
// FA1 review round (blast SF-1): FA1's tree-assembly rules flipped
// vassal print order (FindVassals now walks in REVERSE wire order —
// see AllegianceProfileVersionGateTests.TreeAssembly_SiblingOrder_
// ReversesOnAssembly) at the LIVE @allegiance info surface. The
// pre-existing full-tree test above has exactly ONE vassal, which
// cannot distinguish forward from reverse order — this pins the
// reversal through FormatAllegianceInfoLines itself, not just through
// FindVassals, with three vassals so the ordering is unambiguous.
[Fact]
public void ParseAndFormatAllegianceInfoResponse_ThreeVassals_PrintsInReverseWireOrder()
{
const uint monarchGuid = 0x50000001u;
const uint vassalAGuid = 0x50000010u;
const uint vassalBGuid = 0x50000011u;
const uint vassalCGuid = 0x50000012u;
byte[] wire = BuildAllegianceWire(
monarchGuid,
new()
{
(monarchGuid, 0u, true, "Monarch"),
(vassalAGuid, monarchGuid, true, "VassalA"),
(vassalBGuid, monarchGuid, true, "VassalB"),
(vassalCGuid, monarchGuid, true, "VassalC"),
});
var response = ClientCommandResponses.ParseAllegianceInfoResponse(wire);
Assert.NotNull(response);
var lines = ClientCommandResponses.FormatAllegianceInfoLines(response.Value).ToArray();
Assert.Equal(
new[]
{
"Note: An asterisk (*) indicates that the character is currently online.",
"Allegiance information for Monarch *:",
" Vassals: ",
" VassalC *",
" VassalB *",
" VassalA *",
},
lines);
}
// FA1 review round (blast SF-1): the §4.4 tree-assembly rules turned a
// malformed tree (orphan / self-parent / duplicate treeParent) into a
// whole-message discard (ParseAllegianceInfoResponse returns null),
// and GameEventWiring's registration is `if (info is null) return;` —
// so the live @allegiance info command now goes from "prints a
// possibly-garbled roster" to "prints nothing at all" on a malformed
// tree. Pin that silent-drop behavior at the wiring layer that shows
// it to the user.
[Fact]
public void WireAll_AllegianceInfoResponse_MalformedTree_PrintsNothing()
{
var dispatcher = new GameEventDispatcher();
var chat = new ChatLog();
GameEventWiring.WireAll(dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), chat);
const uint monarchGuid = 0x50000001u;
byte[] payload = BuildAllegianceWire(
monarchGuid,
new()
{
(monarchGuid, 0u, true, "Monarch"),
(0x50000005u, 0x5000FFFFu /* never-seen parent — orphan */, true, "Orphan"),
});
GameEventEnvelope? envelope = GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.AllegianceInfoResponse, payload));
Assert.NotNull(envelope);
dispatcher.Dispatch(envelope.Value);
Assert.Empty(chat.Snapshot());
}
[Fact]
public void FormatAllegianceInfoLines_NoAllegiance_PrintsNothing()
{

View file

@ -166,6 +166,33 @@ public sealed class FellowshipEventsTests
Assert.Equal(3u, update.Value.UpdateType);
}
// D5/mechanism SF-3: the same raw-uint, never-a-bool shareLoot
// requirement pinned for ParseFellowshipFullUpdate above
// (ParseFellowshipFullUpdate_ShareLootIsRawNotBool_D5) must ALSO hold
// at the 0x02C0 site — ReadFellow is shared by both parsers, but
// nothing previously asserted the <<1 incremental-update shape here;
// a future split of ReadFellow could silently reintroduce a bool read
// on this leg without either test catching it.
[Fact]
public void ParseFellowshipUpdateFellow_ShareLootIsRawNotBool_D5()
{
byte[] wire = new AceWireWriter()
.Write(0x50000005u) // guid FIRST
.Write((uint)10).Write((uint)5).Write((uint)3)
.Write((uint)100).Write((uint)80).Write((uint)60)
.Write((uint)90).Write((uint)70).Write((uint)50)
.Write((uint)2) // shareLoot — ACE's incremental-update "<<1" shape
.WriteString16L("Vitals")
.Write((uint)3) // updateType = 3 UpdateVitals
.ToArray();
var update = GameEvents.ParseFellowshipUpdateFellow(wire);
Assert.NotNull(update);
Assert.Equal(2u, update.Value.Member.ShareLoot);
Assert.NotEqual(1u, update.Value.Member.ShareLoot);
}
// ── 0x00A3/0x00A4 S→C ─────────────────────────────────────────────────
[Fact]
@ -194,10 +221,15 @@ public sealed class FellowshipEventsTests
Assert.True(GameEvents.ParseFellowshipDisband(ReadOnlySpan<byte>.Empty));
}
// FA1 review round (mechanism SF-2): retail's DispatchUI_Disband
// @0x006A5E80 reads only the opcode and never inspects a trailing
// body — the pre-fix parser's `payload.Length == 0` check was an
// invented validation retail does not perform. A non-empty body
// (an ACE change, a trailing pad) must still succeed.
[Fact]
public void ParseFellowshipDisband_NonEmptyBody_ReturnsFalse()
public void ParseFellowshipDisband_NonEmptyBody_StillReturnsTrue()
{
Assert.False(GameEvents.ParseFellowshipDisband(new byte[] { 1 }));
Assert.True(GameEvents.ParseFellowshipDisband(new byte[] { 1 }));
}
// ── 0x01C9/0x01CA dead events — parse-and-ignore, must never fail ──────