acdream/docs/research/2026-08-12-fa1-review-mechanism.md
Erik 96df892d21 docs: FA1 mechanism re-review -- CLOSED, all 7 findings verified fixed
Narrow re-review of the fix round (ed308087 code+tests, 511ba6e5 docs)
against the mechanism findings doc. Verdict CLOSED, no REOPEN.

All 2 MUST-FIX and 5 SHOULD-FIX verified in the actual diffs, each
re-derived rather than taken on the commit message's word:

- MF-1: zero-id rejection on BOTH the monarch and child paths, which is
  also what makes treeParent == 0 provably fatal (knownIds can never
  contain 0); three boundary tests.
- MF-2: the 0x001F builder re-verified against primary source --
  CM_Allegiance::Event_UpdateRequest @0x006A7260 allocates 0x10, stores
  0x1f at 006a72ba, writes the arg as a full u32 at 006a72cb. Both golden
  vectors correct; all five new anchors resolve; the ACE claim
  (GameActionAllegianceUpdateRequest.cs:12 reads and ignores the value)
  is accurate.
- SF-1: monarch clear placed at retail's own position/guard; the fixture
  relocation onto a vassal is not just correct but necessary, since the
  clear would otherwise mask the legacy-compat fallback.
- SF-2/SF-3/SF-5 all closed; SF-5 resolved better than asked, renumbering
  to the real AllegianceVersion enum values (verified against
  acclient.h:2979-2994) and naming gate 5 as real-but-gating-nothing.

Spot-verified all six blast dispositions: AP-90 re-pointed without being
wrongly retired; four seam-map corrections applied as dated strikes (its
open-question-8 answer independently re-verified against PackString16L
and ACE's ReadString16L pad skip); D9 + slice row struck and annotated;
ledger arithmetic now closes (13,153 total sums correctly, -4 skips =
13,149).

Suite claim corroborated: the fix diff adds exactly +9 [Fact]/[Theory]
and removes 0, and the post-fix Release binaries (stamped after
ed308087, so --no-build is legitimate here) measure AcDream.Core.Net.Tests
at 886/0/0 -- exactly the blast doc's 877 pre-fix anchor plus 9, with all
9 new tests in that project.

One carry-forward, NOT a reopen: blast MF-2's enumeration stopped at four
rows; lane D still names the deleted AllegianceTree at :128 and :214,
both forward-looking FA2 design guidance of the same danger class as the
:791 row that was corrected. Two more dated addenda close it; FA2 should
not start before that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 00:55:06 +02:00

36 KiB
Raw Blame History

Campaign FA slice FA1 — MECHANISM-FAITHFULNESS review

Date: 2026-08-12. Lens: mechanism faithfulness (is every byte and every rule right?). Mode: read-only; this doc is the only file written.

Target: 7be86f47 (builders), 6bedbc47 (parsers), 5f9aa16f (WeenieError), 4281750b (delete AllegianceTree), ledger ee1124ca.

Contract: docs/plans/2026-08-11-fellowship-allegiance-campaign.md slice FA1 + D3/D5/D6/D9, with docs/research/2026-08-11-fa-fellowship-wire.md (lane B) and docs/research/2026-08-11-fa-allegiance-wire.md (lane C) as ground truth.

Primary sources re-read independently for this review (not taken on the lane docs' word): docs/research/named-retail/acclient_2013_pseudo_c.txt (Fellow::UnPack @0x005B9AD0, Fellowship::UnPack @0x005B94F0, PackableHashTable<unsigned long,Fellow>::UnPack @0x00569A90, CM_Fellowship::DispatchUI_UpdateFellow @0x006A6700, CM_Fellowship::Event_Create @0x006A67A0, AllegianceHierarchy::UnPack @0x005B7520, AllegianceHierarchy::Add @0x005B6E90, AllegianceProfile::UnPack @0x005B6B90, the 0x20 dispatch case at 0055b5dd, ClientCommunicationSystem::HandleFailureEvent @0x00571990), docs/research/named-retail/acclient.h:2979-2994 (AllegianceVersion), docs/research/named-retail/symbols.json, and the ACE writers references/ACE/Source/ACE.Server/Network/Structure/AllegianceHierarchy.cs, .../AllegianceData.cs, .../AllegianceProfile.cs, .../GameEvent/Events/GameEventAllegianceUpdate.cs.


VERDICT: APPROVE-WITH-FIXES

Every wire layout in this slice is byte-correct against the retail decompilation, and both premise-contradiction calls are independently verified correct from primary source (§2). The fixes below are two rule/coverage gaps and a set of smaller fidelity items; none of them invalidates a landed byte vector, and all of them are unreachable against ACE today — which is exactly why they need pinning now rather than discovering them against a non-ACE server or a hand-built FA2 fixture.

MUST-FIX: 2. SHOULD-FIX: 5. Nits (non-blocking): 5.


1. MUST-FIX

MF-1 — AllegianceHierarchy::Add's FOURTH rejection rule (_id == 0) is not modeled

src/AcDream.Core.Net/Messages/ClientCommandResponses.cs:501-524 models three of the four rejection rules in AllegianceHierarchy::Add @0x005B6E90. The whole function body in retail is wrapped in if (_id != 0):

005b6e96      uint32_t _id = arg3->_id;
005b6e9e      if (_id != 0)          <-- every success path is inside this
              ...
005b6ecd      return 0;              <-- _id == 0 falls straight out

and the caller (005b779d) turns any Add failure into return 0 from UnPack, i.e. the whole message is discarded. Lane C §4.4's own pseudo-code lists it first (if (data._id == 0) return 0; // reject).

acdream reads the monarch at :505 and each record at :512 with no CharacterId != 0 check, so:

  • a profile whose monarch record has id 0 is accepted where retail discards it, and
  • every subsequent record naming treeParent = 0 is then admitted by knownIds.Contains(0) — where retail rejects treeParent == 0 unconditionally (005b6ee5: esi = (arg2 != 0) ? Search(...) : nullptr, then esi != 0 is required).

One guard fixes both: reject the whole message when any record's CharacterId == 0. Add the boundary test beside the existing three at tests/AcDream.Core.Net.Tests/Messages/AllegianceProfileVersionGateTests.cs:312-353.

Unreachable against ACE (AllegianceData.cs:69 always writes a real guid) — the same "correct only because ACE is generous" class lane C §0 warns about for the version gates, which this slice otherwise closed.

MF-2 — 0x001F AllegianceUpdateRequest builder is missing

src/AcDream.Core.Net/Messages/AllegianceRequests.cs:34-83 ships Swear (0x001D), Break (0x001E) and the new Kick alias — and nothing else. There is no 0x001F builder anywhere in the tree (repo-wide grep for 0x001F / AllegianceUpdateRequest over src/**/*.cs returns only PropertyBool.AllegianceUpdateRequest, an unrelated property id).

0x001F is the allegiance panel's subscribe/unsubscribe toggle — the exact structural twin of the fellowship 0x00A6 this slice did repair and rename. Lane C §1.2 pins all four retail send sites (gmAllegianceUI::PostInit tail @0x004911C6 → 1, RecvNotice_PlayerDescReceived @0x00490D59 → 1, OnVisibilityChanged visible @0x004912DD → 1, hidden @0x00491311 → 0) and lane C §7.1 numbers it #3 of the six messages the minimum-viable panel needs, with the explicit instruction "ACE ignores the value but retail servers do not; send it correctly."

FA1 is the Core.Net truth slice and FA2 (Runtime owners + typed commands) lands before FA5; leaving this out pushes a AcDream.Core.Net builder addition into a Runtime or panel slice. Six lines plus a golden vector ([u32 0xF7B1][u32 seq][u32 0x1F][u32 on], total 0x10 per lane C §3.2).


2. The two premise-contradiction calls — BOTH VERIFIED CORRECT

I re-derived both from primary source rather than accepting the commit messages. Both stand.

(a) The 8 fellowship WeenieError ids genuinely have no retail text — 5f9aa16f is right, D9's premise was wrong

D9 asserted "the 8 missing fellowship WeenieError strings are added in FA1 … all resolve from the DAT string tables, never invented." The implementer refused and documented absence instead. Verified:

  1. Case-label walk. In HandleFailureEvent @0x00571990 (acclient_2013_pseudo_c.txt:382616+) the label sequence is case 0x413: → case 0x414: → case 0x416: → case 0x41d: → case 0x41e: → case 0x41f:no label for 0x4170x41C — and case 0x4d9: → case 0x4da: → case 0x4dd:no label for 0x4DB/ 0x4DC.
  2. The else if chain too. WeenieErrorMessages' own class doc warns that five ids dispatch via else if (arg2 == N) above the switch, so a case-label-only sweep would be unsound. The function contains exactly seven such comparisons — 0x43, 0x3f7, 0x3e, 0x23, 0x36, 0x4e8, 0x48a, 0x408, 0x3ee, 0x4f — none of them ours.
  3. Whole-file sweep. \b0x417\b\b0x41c\b, \b0x4db\b, \b0x4dc\b across all 1.4 M lines return only unrelated hits: struct field offsets in three *(uint16_t*)(ecx+0x418) copy loops, and [0x417]/[0x418]/… array-index headers inside a MeshBuffer render-batch data dump at .data 0x008872xx / 0x008c4axx. Nothing textual, nothing comparative.
  4. Decimal forms. No 10471052/1243/1244 anywhere in the function's address range.
  5. No default: in the switch, so an unmatched id produces no text at all — which is precisely what Resolve() already returns.

Conclusion: acdream's existing silence for 0x0417 FellowshipIgnoringRequests and 0x04DB FellowshipDeclined (the two ACE actually sends) is retail-faithful. Adding invented English would have been the exact failure mode the table's no-default-case rule exists to prevent. The conformance [Theory] at tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs:390-393 is the right artifact. No fix owed; no register row owed (silence is the retail behavior, so there is no deviation to record).

(b) 0x0275 is client-authored — a receive parser was never the ask, and D6's need is satisfied

Verified: 0x0275 is a C→S GameAction sub-opcode (CM_Character::Event_ConfirmationResponse @0x006A1210, buffer 0x18; consumed server-side by ACE's GameActionConfirmationResponse). Retail never receives it. So "the missing third message of the triple" (D6) could not have meant a parser.

ClientCommandRequests.BuildConfirmationResponse already existed pre-FA1 byte-for-byte (git show 6bedbc47~1 — 24-byte body, [env][seq][0x0275][type][context][accepted], matching retail's 0x18 allocation exactly). What was genuinely absent was the typed discriminator, and GameEvents.ConfirmationType (src/AcDream.Core.Net/Messages/GameEvents.cs:552-561) supplies it with all seven values matching retail's ClientUISystem::Handle_Character__ConfirmationRequest @0x005640A0 switch (symbol verified at that exact address) and ACE's enum.

D6's FA4/FA5 flows are buildable on this today: receive 0x0274 via ParseCharacterConfirmationRequest (:512, Type as uint — cast to the new enum), send via BuildConfirmationResponse((uint)ConfirmationType.Fellowship, …), receive 0x0276 via ParseCharacterConfirmationDone (:533). The reinterpretation is sound.


3. SHOULD-FIX

SF-1 — the monarch's MayPassupExperience must be forced false

AllegianceHierarchy::UnPack's last act before returning success is AllegianceData::SetMayPassupExperience(&m_pMonarch->_data, 0) (acclient_2013_pseudo_c.txt:454670, 005b77b3) — the monarch can never pass up, whatever the wire bit says. Lane C §4.1 names this in the same paragraph as the HasPackedLevel-absent rule that ClientCommandResponses.cs:564-565 did port; only one of the pair landed. The field is public (AllegianceMemberRecord.MayPassupExperience, :201), so FA5 could render it wrong. Clear it on the monarch record at :505-506.

Inert against ACE (AllegianceData.cs:76 never sets the bit for a monarch), but it becomes live the moment the record is hand-built or a non-ACE server is used.

SF-2 — ParseFellowshipDisband rejects a non-empty body; retail never does

src/AcDream.Core.Net/Messages/GameEvents.cs:749: ParseFellowshipDisband(payload) => payload.Length == 0. Retail's CM_Fellowship::DispatchUI_Disband @0x006A5E80 reads only the opcode and then calls the handler — it never inspects or validates a trailing byte. Encoding a length check retail does not perform means any future non-empty body (an ACE change, a trailing pad) silently swallows the disband and leaves FA2's roster stuck in a fellowship the server already destroyed. The faithful contract is unconditional acceptance. The matching test at tests/AcDream.Core.Net.Tests/Messages/FellowshipEventsTests.cs:197-201 pins the unfaithful half and should flip with it.

SF-3 — D5's <<1 shape is not pinned at the 0x02C0 site

D5 requires the raw-uint shareLoot read at both message sites. The mechanism is right — ReadFellow (GameEvents.cs:777, shareLoot at :786) is shared by ParseFellowshipFullUpdate (:661) and ParseFellowshipUpdateFellow (:706), and FellowMember.ShareLoot (:622) is a uint with no == 1 comparison anywhere in the tree. But the tests pin 0x10 and 2 only at 0x02BE (FellowshipEventsTests.cs:70,95); the 0x02C0 fixture writes shareLoot = 0 (:154). Add the <<1-shape (2) assertion at the 0x02C0 site so a future refactor that splits ReadFellow cannot silently reintroduce a bool there.

Carry-forward for the FA2 reviewer: != 0 semantics are currently only documented (:606-609) — nothing consumes ShareLoot yet. The consuming comparison lands in FA2 and must be checked there.

SF-4 — AP-90's register row now cites a deleted class

docs/architecture/retail-divergence-register.md:298 (AP-90) reads "… and its AllegianceTree is not wired into GameWindow". 4281750b deleted that class. The row itself is still valid (radar relationship state is still not delivered), but the prose must be re-pointed at ClientCommandResponses.AllegianceProfileLookups / the FA2 owner. The register rule says the bookkeeping rides the same commit; this one slipped.

SF-5 — the "eleven gates" numbering in the code comments tops out at ten, and one lane citation does not exist

Two documentation-accuracy items that will cost a future auditor time:

  • ClientCommandResponses.cs:411-491 labels the gates "1/2, 3, 4, 5, 6, 7, 8, 9, 10". The contract, the commit message and lane C all say eleven. All eleven fields are gated correctly (verified §4 below) — only the comment numbering is off, and it is the exact artifact someone would use to answer "are all eleven present?".
  • SocialActions.cs:184 cites "lane B §4.5, §12". Lane B has no §12 — the intended reference is master-table row 12 (Panel visibility → update request).

4. Verified clean (re-derived, not taken on trust)

4.1 Golden byte vectors — all six independently re-derived, all correct

Builder Expected bytes (hand-derived from the cited retail sender) Test Verdict
BuildFellowshipCreate(seq 7,"Team",true) B1F70000 07000000 A2000000 + str16L0400 54 65 61 6D 0000 + 01000000 = 24 B SocialActionsTests.cs:65-83
BuildFellowshipCreate(seq 1,"X",false) 12 + 4 (str16L 0100 58 00) + 4 = 20 B, tail 00000000 :85-95
BuildFellowshipUpdateRequest(9,true) …A6000000 01000000 :130-146
BuildFellowshipAssignNewLeader(2,0x50001234) …90020000 34120050 :151-168
BuildFellowshipChangeOpenness(4,false) …91020000 00000000 :173-190
BuildSwear/BuildBreak/BuildKick …1D000000 AAAA0000 / …1E000000 BBBB0000 / …1E000000 42000050 AllegianceRequestsTests.cs:39-90

The 0x00A2 repair is confirmed at the strongest possible provenance: CM_Fellowship::Event_Create @0x006A67A0 allocates strPackSize + 0x10, stores 0xa2 at 006a6809, packs the string at 006a6828, then *(uint32_t*)var_c = arg2 at 006a6835one trailing u32 shareXP and no openness field. The prior two-byte openness/shareXp shape (and the test that pinned it) was exactly the inversion lane B §5.2(a) described.

PackString16L (SocialActions.cs:389-403) is Windows-1252 with the pad computed over 2 + length — matching retail's PStringBase::Pack, ACE's WriteString16L, and the reader ReadString16L (GameEvents.cs:913-926). No endianness, padding, or encoding slip anywhere in the set.

Byte-write-vs-u32-write equivalence (body[12] = 0|1 into a zeroed 16-byte buffer) is byte-identical to retail's full-word store for these 0/1 payloads — see nit N-2.

4.2 Fellow record layout (the load-bearing one)

Fellow::UnPack @0x005B9AD0 reads, in order: _cp_cache, _lum_cache, _level, _max_health, _max_stamina, _max_mana, _current_health, _current_stamina, _current_mana, _share_loot, then str16L _name (min size 0x28). GameEvents.ReadFellow (:777-793) is field-for-field identical. ✔

4.3 0x02BE field order and the hash-table header

Fellowship::UnPack @0x005B94F0: _fellowship_table_name_leader_share_xp_even_xp_split_open_fellow_locked_fellows_departed, then returns — it never reads field 9 (locks). ParseFellowshipFullUpdate (GameEvents.cs:637-681) matches exactly, including the deliberate non-parse of field 9, which is the retail-faithful choice (lane B §3.9 suggested reading it; retail does not, and lane B's own U3 flags the struct width as unverified — the implementer's stop-and-document call is correct and needs no register row).

Header: PackableHashTable<unsigned long,Fellow>::UnPack @0x00569A90 reads one u32 and splits _table_size = v >> 16, count = v & 0xFFFF — so the wire is [u16 count][u16 numBuckets] LE, which is what the parser reads for both the member table (:653-654) and the departed table (:670-671). ✔

4.4 0x02C0 is guid-first — the Chorizite disagreement independently re-resolved

CM_Fellowship::DispatchUI_UpdateFellow @0x006A6700:

006a6726  cond = *(uint32_t*)eax_3 == 0x2c0;      // opcode at +0
006a673b  uint32_t edi = *(uint32_t*)(eax_3 + 4); // MEMBER GUID at +4
006a6763  Fellow::UnPack(&var_30, &arg2, …);      // Fellow from +8
006a676c  uint32_t ecx_4 = *(uint32_t*)eax_7;     // updateType last

Retail puts the guid first. ParseFellowshipUpdateFellow (GameEvents.cs:700-712) reads guid → FellowupdateType. ✔ The lane-B three-source resolution (retail + ACE + holtburger over Chorizite's generated shape) is confirmed at its retail end.

4.5 The eleven version gates — every threshold and the ORDER

Re-derived from AllegianceHierarchy::UnPack @0x005B7520 and the AllegianceVersion enum (acclient.h:2979-2994). Order on the wire is not monotonic in version — officer titles (v9) precede the pools block (v2) — and the implementation follows the wire, not the numbering:

# Field Retail predicate (address) ClientCommandResponses.cs
1 officers PHashTable<u32,u32> m_oldVersion >= 6 (005b7570/005b7585) :416-425
2 legacy spokesperson 4-byte skip 1 <= v < 6 (005b759b) :426-429
3 officer titles PSmartArray<PString> (bare i32 count) >= OfficersTitlesAdded(9) (005b75b4) :434-439
47 monarch/spokes broadcast time+today ×4 >= PoolsAdded(2) (005b75e0) :442-448
8 m_motd, m_motdSetBy >= MotdAdded(3) (005b7645) :453-457
9 m_chatRoomID >= ChatRoomIDAdded(4) (005b7696) :461-462
10 m_BindPoint (32-byte Position) >= Bindstones(7) (005b76b2) :468-472
11 m_AllegianceName + m_NameLastSetTime >= AllegianceName(8) (005b76d1) :477-481
12 m_isLocked >= LockedState(10) (005b770d) :485-486
13 m_ApprovedVassal >= ApprovedVassal(11) (005b7728) :490-491

BannedCharactersAdded(5) gates nothing — confirmed, and pinned by the negative test VersionGate_4to5_BannedCharactersAddedGatesNothing.

Boundary tests sit on the boundaries. Every gate has an N-1/N fixture pair: 0→1, 1→2, 2→3, 3→4, 4→5 (negative), 5→6, 6→7, 7→8, 8→9, 9→10, 10→11 (AllegianceProfileVersionGateTests.cs:152-308). For the gates whose fields are not surfaced (pools, bind point, officers, titles) the assertion is the monarch record surviving intact — which IS a real structural proof here, because the fixture writer (BuildProfileWire, :40-134) applies the gate conditions independently of the parser: a wrong threshold would consume or skip bytes the writer did not emit and garble every field after it.

Cross-checked against ACE's writer (AllegianceHierarchy.cs:140-155): identical field order, and oldVersion = 0x000B always (:50) — so these gates are inert against ACE and the live @allegiance info path is unchanged, exactly as intended.

4.6 AllegianceData field order and AllegianceProfile/0x0020 framing

AllegianceData per lane C §4.1, cross-checked against ACE AllegianceData.cs:95-117: id, cpCached, cpTithed, bitfield, u8 gender, u8 heritage, u16 rank, [u32 level if HasPackedLevel], u16 loyalty, u16 leadership, [8 bytes either branch], str16L name. ReadAllegianceData (:532-571) matches. ✔

AllegianceProfile::UnPack @0x005B6B90 = totalMembers, totalVassals, hierarchy — matches :406-409. ✔ The 0x20 dispatch case (0055b5dd-0055b607) reads one u32 and then AllegianceProfile::UnPack, and ACE's GameEventAllegianceUpdate.cs:22-25 writes rank then the profile — so ParseAllegianceUpdate's rank-leading + shared-body split (:375-391) is right, as is the guid-leading 0x027C sibling. Sharing one ReadAllegianceProfileBody is exactly lane C §7.2's reuse verdict honored. ✔

4.7 Tree rules — three of four correct, and the reversal test pins ORDER

Orphan / self-parent / duplicate all match Add's semantics:

  • orphan — retail esi = (arg2 != 0) ? Search(arg2, monarch) : nullptr; if (esi == 0) return 0!knownIds.Contains(parentGuid) (:514);
  • self-parent — retail esi->_data._id != _id required, and esi was found by treeParentId, so it is treeParentId == _idparentGuid == record.CharacterId (:515);
  • duplicate — retail Search(_id, monarch) == 0 required ≡ knownIds.Contains(record.CharacterId) (:516);
  • all three return null, i.e. whole-message discard, matching UnPack's return 0 at 005b779d. ✔

The record loop's first-record-has-no-treeParent shape is confirmed at 005b776e (if (ebp_1 != 0) { read treeParent }) and matches :505-512. ✔ (The fourth rule is MF-1.)

Sibling reversal — the test pins the ORDER, not just membership. FindVassals (:288-294) walks the record list backwards, which is the correct model of retail's push-front insert (node->_peer = parent->_vassal; parent->_vassal = node; at 005b6f15-005b6f38) followed by a GetFirstVassal/GetNextVassal walk. TreeAssembly_SiblingOrder_ReversesOnAssembly (AllegianceProfileVersionGateTests.cs:371-396) asserts the exact sequence {"VassalC","VassalB","VassalA"} against wire order A,B,C. ✔ Note this changes the live @allegiance info vassal render order (it was forward before FA1) — the new order is the retail one; lane C §9 U3 flags a side-by-side eyeball as the closing check.

4.8 Dead opcodes 0x01C9/0x01CA

ParseFellowshipFellowUpdateDone / …StatsDone (GameEvents.cs:759-775) return a non-nullable record and cannot fail: empty payload → RawValue = null, trailing u32 → captured. Both cases are tested (FellowshipEventsTests.cs:205-233). Nothing user-visible is possible — neither is registered in GameEventWiring.WireAll (grep for Fellowship there returns nothing), and RawValue has no consumer. Matches lane B §2.7's parse-and-ignore instruction and its BN-zero-fold caution (payload tolerated, not assumed empty). ✔

4.9 Retail anchors in code comments — spot-check (10, not 5)

Resolved against symbols.json (exact-address match unless noted):

Anchor cited in code Result
gmFellowshipUI::CreateFellowship @0x0048F730 exact ✔
gmFellowshipUI::OnVisibilityChanged @0x0048E460 exact ✔
Event_AssignNewLeader @0x006A5F70 exact (CM_Fellowship::) ✔
Event_ChangeFellowOpeness @0x006A6040 exact (CM_Fellowship::) ✔
AssignLeadershipToFellow @0x0048F1E0 exact ✔
Fellow::Pack @0x005B9A10 / UnPack @0x005B9AD0 exact ✔
AllegianceHierarchy::Add @0x005B6E90 exact ✔
Handle_Character__ConfirmationRequest @0x005640A0 exact (ClientUISystem::) ✔
gmAllegianceUI::CloseKickConfirmationDialog @0x00490B00 exact ✔
gmFellowshipUI Quit button case 8 @0x0049034B; Event_BreakAllegiance @0x006A69DA instruction-level, correctly inside gmFellowshipUI::ListenToElementMessage @0x004901C0 and CM_Allegiance::Event_BreakAllegiance @0x006A6980

No misattributed anchor found.

4.10 Other contract items

  • All 11 unwired S→C parsers are present (fellowship 0x00A3, 0x00A4, 0x01C9, 0x01CA, 0x02BE, 0x02BF, 0x02C0; allegiance 0x0003, 0x0020, 0x01C8, 0x027A), and all 11 ids exist in GameEventType. ✔
  • AllegianceTree deletion is clean — no production or test reference survives (the one AllegianceTree hit in ChatChannelInfoTests.cs:28 is an unrelated test-method name). Lane C §7.3's DELETE verdict honored. ✔ (See SF-4 for the register prose.)
  • Test fixtures are generated by AceWireWriter, a line-for-line mirror of ACE's Extensions.cs writer — provenance is the server's own algorithm, not hand-typed hex. ✔
  • No stale callers of the renamed/re-signatured builders remain. ✔

5. Nits (non-blocking; listed for completeness, no action required)

  • N-1 — Retail degrades gracefully on a short legacy blob (each gated read is additionally guarded by "≥4 bytes remain", e.g. 005b759b, 005b75fc); acdream throws FormatException and discards the message instead. Only reachable on a truncated pre-v11 blob, which ACE cannot produce.
  • N-2BuildFellowshipQuit/BuildFellowshipUpdateRequest/ BuildFellowshipChangeOpenness write a single byte where retail writes a full u32. Byte-identical for the 0/1 payloads these carry (zeroed buffer), and blessed by lane B §5.2 — noted only so nobody "fixes" it into a behavior change.
  • N-3 — Retail's record count is a signed int16_t (005b7546: int16_t i = ecx;if (i > 0)), so a header with the high bit set reads as zero records in retail and as up to 32 767 records in acdream. Bounded and unreachable against ACE.
  • N-4GameEvents.ParseConfirmationResponse (:581) is by construction unreachable in production (nothing receives 0x0275); its only consumer is ConfirmationTripleTests. Justified in its own doc comment as a round-trip conformance mechanism, and it is the cheapest way to prove the builder against a second implementation — but it is new never-called code in a slice whose D3 rule is "connected, repaired, or deleted — never trusted", so FA2 should either route it or leave the doc comment intact as the standing justification.
  • N-54281750b's commit message repeats lane C §0's "~1000×" figure for the deleted AllegianceMath.ComputePassup. The actual error is roughly 40× at maxed loyalty and under-states at low loyalty ((50 + 22.5·291)/291 = 22.67 vs a true passup factor of ≈0.53). The DELETE verdict is unaffected and correct; the magnitude claim originates in the lane doc, not the implementer.

6. Note on evidence I could not produce

The slice's automated gate (Release build + full suite 13,149/4/0) is the regression reviewer's lane. I did not build. The Debug test binaries in this worktree predate FA1 (a --no-build --filter run over the six FA1 test classes matched only the 16 pre-existing SocialActionsTests/ AllegianceRequestsTests cases), so per the stale-build-artifact rule I treat that run as evidence of nothing and reviewed every new test by reading it instead. All test logic above is verified by inspection against the primary sources cited.


7. Re-review of the fix round — 2026-08-12

Fix commits: ed308087 (code + tests), 511ba6e5 (docs).

VERDICT: CLOSED — all 7 findings (2 MUST-FIX, 5 SHOULD-FIX) verified fixed. No REOPEN.

One carry-forward for FA2's start (CF-1, §7.3) — not a reopen of any finding, and not a defect in the fix round: it is residual staleness the blast doc's own MF-2 enumeration did not reach.

7.1 Disposition of my findings

# Finding Disposition Evidence
MF-1 Add's _id == 0 rule unmodeled CLOSED ClientCommandResponses.cs:509-510 rejects a zero-id monarch before it is assigned; :533 adds record.CharacterId == 0u to the child-record guard. Both return null = whole-message discard, matching 005b6e9e005b6ecd. treeParent == 0 is now provably fatal for every non-monarch record: knownIds seeds from a monarch id that cannot be 0, so Contains(0) is always false. Three boundary tests added (AllegianceProfileVersionGateTests.cs:355-405: zero-id monarch, zero-id child, zero treeParent), each asserting null.
MF-2 0x001F AllegianceUpdateRequest builder missing CLOSED AllegianceRequests.cs:40-53 (AllegianceUpdateRequestOpcode = 0x001Fu) + :93-102 (BuildAllegianceUpdateRequest). Re-derived from primary source: CM_Allegiance::Event_UpdateRequest @0x006A7260 allocates 0x10, stores 0x1f at 006a72ba (the cited anchor is the opcode store itself), and writes arg1 as a full u32 at 006a72cb — the builder's [0xF7B1][seq][0x1F][on] is byte-exact. Two golden vectors (on/off) at AllegianceRequestsTests.cs:92-127, both hand-derived and correct. All five anchors in the new doc comment resolve: 0x006A7260 exact; 0x004911C6 / 0x00490D59 / 0x004912DD / 0x00491311 inside gmAllegianceUI::PostInit, ::RecvNotice_PlayerDescReceived, and ::OnVisibilityChanged respectively. The ACE claim is accurate — GameActionAllegianceUpdateRequest.cs:12 reads the u32 and replies unconditionally.
SF-1 Monarch MayPassupExperience not force-cleared CLOSED ClientCommandResponses.cs:544-553: monarch = monarch.Value with { MayPassupExperience = false }, placed after the record loop and inside if (recordCount > 0) — the same position and guard as retail's 005b77a7/005b77b3 (if (m_pMonarch != 0) SetMayPassupExperience(…, 0)), and unreachable from the return null paths exactly as in retail. The fixture relocation is correct and necessary: the old legacy-compat test sat on the monarch, where the new clear would make "fallback fired" indistinguishable from "clear fired". It now carries a two-record wire (monarch bitfield 0xC, vassal bitfield 0x4) and asserts both halves separately; the decisive new test feeds the monarch bitfield 0x1C (the wire bit explicitly SET) and still asserts false.
SF-2 ParseFellowshipDisband validated a body length retail never inspects CLOSED GameEvents.cs:777 is now => true, with the rationale in its doc comment. Test flipped: ParseFellowshipDisband_NonEmptyBody_StillReturnsTrue (FellowshipEventsTests.cs:224-232).
SF-3 D5 <<1 shape unpinned at 0x02C0 CLOSED ParseFellowshipUpdateFellow_ShareLootIsRawNotBool_D5 (FellowshipEventsTests.cs:169-195) writes shareLoot = 2 on the 0x02C0 leg and asserts == 2u / != 1u, mirroring the 0x02BE pin.
SF-4 AP-90 cites the deleted AllegianceTree CLOSED (shared with blast MF-1 — see §7.2)
SF-5 Gate comment numbering stopped at ten; bogus "lane B §12" citation CLOSED, and better than asked ClientCommandResponses.cs:411-424 renumbers to the actual AllegianceVersion enum values rather than wire-appearance order, and states explicitly that the wire order is a different sequence. I re-checked all ten labels against acclient.h:2979-2994: SpokespersonAdded 1, PoolsAdded 2, MotdAdded 3, ChatRoomIDAdded 4, MultipleAllegianceOfficersAdded 6, Bindstones 7, AllegianceName 8, OfficersTitlesAdded 9, LockedState 10, ApprovedVassal 11 — every label now matches its enum member. Gate 5 (BannedCharactersAdded) is called out by name as real-but-gating-nothing, which is what made the old numbering appear to lose a gate. SocialActions.cs:184-185 now cites "master-table row 12".

No path I flagged was fixed by suppression, early return, or a narrowing guard; each is the retail mechanism ported at the site retail performs it.

7.2 Spot-verification of the blast doc's six dispositions

Item Verdict
MF-1 / AP-90 re-point retail-divergence-register.md:298 re-points the evidence column at ClientCommandResponses.cs (AllegianceProfileLookups) and GameEvents.cs (fellowship parsers), keeps the deviation active rather than retiring it (correct — no live owner exists until FA2), and dates the edit. ClientCommandResponses.cs:250-295 is a valid range: the class opens at :250 and FindVassals is at :288.
MF-2 / four seam-map corrections ✔ all four applied as dated strike-through + addendum rather than silent rewrite (the right call for a committed research record): :791 allegiance-owner "wrapping AllegianceTree" and its AllegianceTree.cs:56-162 precedent both struck and re-pointed; :666 SetOpen → BuildFellowshipUpdate struck and re-pointed at BuildFellowshipChangeOpenness (0x0291) with the semantic-bug warning; the 4-arg BuildFellowshipCreate corrected in both places (:429 dimming discussion and :668 bot-command table); open question 8 struck and answered. I verified question 8's answer independently — PackString16L pads the record to a 4-byte multiple including its own u16 length, so 12 + name.Length is already aligned before the trailing u32, and ACE's BinaryReaderExtensions.ReadString16L:47 skips the identical CalculatePadMultiple(2 + length, 4). The answer is correct and the question is properly closed. Cited line numbers SocialActions.cs:144-155, :222-230, :389-403 all resolve.
MF-3 / D9 + slice-row annotation ✔ D9 (:103) and the FA1 slice-map row (:130) both struck and annotated with the shipped finding, each pointing at §2(a) of this doc and at the conformance [Theory]. The annotation correctly states that no register row is owed (acdream matches retail, so there is no deviation to record). The slice row also picks up 0x001F in its opcode list.
MF-4 / ledger arithmetic ✔ reconciled and now internally closed: 13,153 total = 4+916+1559+15+130+119+4856+877+4677 (I summed it: 13,153), minus 4 skips = 13,149 passed, matching the prior figure; the "+50" framing is replaced with the diff-verified +58 / 9 = +49, and the one-test residual is attributed to the §5 baseline being captured at a different point in history rather than papered over. That is an honest reconciliation, not a re-transcription.
SF-1 / two live-command pins ClientCommandResponsesTests.cs:317-390: a three-vassal test through FormatAllegianceInfoLines pinning the exact reversed print order (VassalC / VassalB / VassalA) — the pre-existing full-tree test had one vassal and could not distinguish direction — and a GameEventWiring-layer test proving a malformed tree now prints nothing (Assert.Empty(chat.Snapshot())). Both pin the observable change at the surface a user sees.
SF-4 / doc-comment fix + SF-5 discriminator note GameEvents.cs:596 now cites the real ConfirmationTripleTests. The SF-5 split is cross-referenced at all three sites (:506-512 request, :536-539 done, :561-573 the enum) and states the decision FA4 inherits rather than leaving an unexplained inconsistency.

7.3 Carry-forward for FA2 (not a reopen)

CF-1 — lane D still names the deleted AllegianceTree in four more places, two of them forward-looking. The fix round applied blast MF-2's enumeration exactly; the enumeration itself stopped at four rows. A repo-wide sweep finds four further hits in docs/research/2026-08-11-fa-acdream-seams.md:

  • :128 — the FA2 owner-graph diagram: "AllegianceOwner : RuntimeAllegianceState (new) └── owns the ported AcDream.Core.Allegiance.AllegianceTree".
  • :214 — "AllegianceTree already lives in AcDream.Core.Allegianceso the allegiance tree can be passed directly as a state parameter, exactly like friends".

Both are design guidance FA2 will follow, i.e. the same danger class as the :791 row that was corrected — an FA2 implementer reading either is told to own or pass a class that no longer exists. (:23 and :890 are the other two hits; both are historical "what exists today" inventory prose and are fine left alone, as is every surviving BuildFellowshipUpdate mention in lane B and in the plan's D3 — those correctly describe the pre-FA1 defect and must NOT be rewritten.)

This is the single-lens-enumeration failure mode the project's own feedback_blast_radius_single_lens note describes: the sweep leaked along the axis it did not traverse. One more dated addendum at each of :128 and :214 closes it; FA2 should not start before that.

7.4 Suite claim — consistent, and corroborated by direct measurement

The 13,158 passed / 4 skipped / 0 failed claim is arithmetically consistent and independently corroborated, though I did not run the full solution suite myself.

  • Mechanical test-count delta. ed308087's test diff adds exactly 9 [Fact]/[Theory] attributes, removes 0, and adds 0 new InlineData rows. 13,149 + 9 = 13,158; 13,153 + 9 = 13,162 total. Both stated figures close.
  • The ledger's own +9 attribution is correct: 2 (0x001F golden vectors) + 1 (D5 <<1 at 0x02C0) + 4 (MF-1 ×3 and SF-1's monarch test) + 2 (blast SF-1 live-surface pins) = 9, and that matches the files line by line.
  • Direct measurement. The Release binaries in this worktree are stamped 00:47:1900:47:20, after ed308087 (00:46:42), so --no-build is legitimate evidence here (unlike the stale Debug binaries noted in §6). AcDream.Core.Net.Tests now runs 886 passed / 0 failed / 0 skipped — exactly the blast doc's measured pre-fix anchor of 877 plus 9, and all 9 new tests live in that project. A narrower FA1-family filter (the six FA1 test classes plus ClientCommandResponsesTests) runs 92 passed / 0 failed.

That is as far as this lens can take it: every number in the ledger row now reconciles with every other, and the one project that changed measures exactly the claimed delta. Whether the other eight projects still total what the row says is the regression lens's call.