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
|
|
@ -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 ─────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue