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

594 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `0x417``0x41C`** — 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 `1047``1052`/`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` + str16L`0400 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 `006a6835` — **one 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 → `Fellow``updateType`. ✔ 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 == _id` ≡
`parentGuid == 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-2** — `BuildFellowshipQuit`/`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-4** — `GameEvents.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-5** — `4281750b`'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 `005b6e9e`→`005b6ecd`. `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.Allegiance` —
**so 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.