acdream/docs/research/2026-08-12-fa2-review-mechanism.md
Erik cc1a319c1f docs: FA2 mechanism re-review -- CLOSED, no reopen (1 carry-forward)
All 2 MUST-FIX and all 6 SHOULD-FIX dispositions re-derived in the actual
fix-round diffs (4272ad0e, ded23067, ed8b3ec9); every blast disposition
spot-verified. Nothing skipped, no fix introduced a new mechanism defect.

MF-1: RuntimeGenerationResetStage.Allegiance added and drained, the owner
clears the profile AND drops HasServerSeed, the inverted test flipped. Stage
enumeration verified consistent everywhere -- every reference outside the
enum's own file is by NAME, nothing serializes the ordinal, so the +1 shift
is inert. MF-2: ApplyInfoResponseSelf, the delegate hole and the self-gate
are all gone (0 whole-tree hits); the test was rewritten to pin text-only
output for self and other guids alike.

SF-3's RecalculateEvenXPSplitting port checked line-for-line against lane B
2.10, including the deliberate leaderless-table departure -- lane B 7.4 says
verbatim "treat a leaderless table as leave _even_xp_split at 1", so the
citation is accurate. SF-4's 900s gate confirmed to have real data (FA1 does
parse 0x02BE field 8) and to gate only the new-guid branch. SF-1/2/5/6 all
land as specified.

Blast: the teardown table re-derived for every N in 0..13 (case 9 was
genuinely one flag over); the new reflection walk pins every intermediate
stage; seam-doc and plan addenda are dated and accurate; the corrected 11/10
counts are right. Audited blast SF-6's no-register-row conclusion and AGREE
-- clear-at-reset plus 0x0020-only seeding means acdream now matches retail,
so no deviation remains for a row to name.

Suite claim 13,201/4/0 -> 13,215/4/0 (+14) reproduced exactly by counting
discovered cases per file. Targeted post-fix Release runs: 95/95 Runtime,
67/67 Core.Net.

CF-1 (FA5, not a reopen): nothing re-subscribes 0x001F now that the reset
clears the owner, and the plan's FA5 row cites 0x027B for the panel-show
path -- which after MF-2 is text-only and feeds nothing.

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

697 lines
39 KiB
Markdown
Raw Permalink 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 FA2 — mechanism-faithfulness review
**Reviewer lens:** mechanism faithfulness (retail behavior vs. shipped code).
**Target:** `1c401048` (Core.Net), `369729f0` (Runtime), `cced83b4` (App/Headless),
`12053e61` (docs/ledger), against
`docs/plans/2026-08-11-fellowship-allegiance-campaign.md` slice FA2 + D2/D4,
lanes B/C/D (`2026-08-11-fa-fellowship-wire.md`,
`2026-08-11-fa-allegiance-wire.md`, `2026-08-11-fa-acdream-seams.md` incl. its
FA1 fix-round addenda), and the J4.x precedent closeouts.
**Date:** 2026-08-12.
## Verdict
**APPROVE-WITH-FIXES — 2 MUST-FIX, 6 SHOULD-FIX.**
Both MUST-FIX findings are on the allegiance owner and they **compound**: the
owner is seeded from a message retail never seeds from (MF-2), and it is never
cleared at the boundary retail clears it at (MF-1). Every fellowship mechanism
in the slice re-derived clean against the decomp. Nothing in the wiring,
command, view, teardown, or register work is wrong.
Neither MUST-FIX is user-visible today — no FA2 command or view has a
production consumer yet (verified below). They are must-fix because FA3FA5
build the panel directly on this owner, and MF-1 is already pinned by a
passing test, which is the "wrong behavior locked in by test" shape FA1's own
review called out when it deleted `AllegianceTree`.
---
## 1. MUST-FIX
### MF-1 — Allegiance surviving a generation reset contradicts retail, contradicts its own cited precedent, and is pinned by a test
`RuntimeAllegianceState` participates in no reset stage
(`src/AcDream.Runtime/RuntimeGenerationReset.cs:39-41` says so explicitly;
`src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs:18-27` documents the
intent), and `tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs`
(`FellowshipClearsAtResetButAllegianceSurvivesReconnect`) asserts the profile,
the name and `HasServerSeed` all survive `ResetGeneration`.
**(a) Retail clears it at exactly this boundary.**
```
00569fa0 void __fastcall ClientAllegianceSystem::OnEndCharacterSession(...)
00569fa3 return AllegianceProfile::Clear(&this->m_allegianceProfile);
```
(`docs/research/named-retail/acclient_2013_pseudo_c.txt:375000-375004`,
`symbols.json` `0x00569FA0`.) `AllegianceProfile::Clear @0x005B6A60`
(`:453700-453707`) zeroes `_total_members`, `_total_vassals` and tail-calls
`AllegianceHierarchy::Clear` — the whole tree.
The sibling hook is the control that proves the boundary is the right one:
```
005690a0 void __fastcall ClientFellowshipSystem::OnEndCharacterSession(...)
005690a8 if (m_pFellowship != 0) m_pFellowship->vtable->__vecDelDtor(1);
005690b0 this->m_pFellowship = nullptr;
```
(`:373778-373790`.) **FA2's fellowship half is byte-faithful; the allegiance
half is the exact inverse of what retail does at the same hook.**
The hook is a per-character-session pair, and it maps 1:1 onto acdream's
generation: `UIQueueManager::OnBeginCharacterSession` fires on
`PlayerDescription` (`0x13`, `@0x0055B173`, `:359247`) and
`UIQueueManager::OnEndCharacterSession` fires from `gmGamePlayUI::~gmGamePlayUI`
(`@0x004EA339`, `:238072`), fanning out to every client system's vtable slot
(`UIQueueManager::OnEndCharacterSession @0x0055A6D0`, `:358492-358501`).
Corroboration that the cached profile really is per-session state:
`gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220` (`:158415-158426`)
gates every logon/logoff line on
`AllegianceProfile::GetData(&eax->m_allegianceProfile, guid, …)` — the cached
profile, i.e. the thing `Clear` empties.
**(b) The cited precedent CLEARS and re-latches — the citation is inverted.**
`RuntimeAllegianceState.cs:24-27` and lane D §1.3
(`2026-08-11-fa-acdream-seams.md:144-148`) both justify persistence as "same
class as `RuntimeCharacterOptionsState`'s `HasServerSeed` latch". That
precedent does the opposite:
```csharp
// src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:1007-1017
public void ResetSession()
{
Volatile.Write(ref _options1, DefaultOptions1);
Volatile.Write(ref _options2, DefaultOptions2);
Interlocked.Increment(ref _revision);
lock (_dirtyGate) { _isDirty = false; _hasServerSeed = false; }
}
```
and its own doc comment (`:999-1006`) names the hazard FA2 is now exposed to
verbatim: *"a stale seed surviving a session boundary could let a flush ship
the PRIOR character's words over the new one's."* It runs every generation via
reset stage `Character = 7``RuntimeCharacterState.ResetSession`
`Try(Options.ResetSession, …)` (`RuntimeCharacterState.cs:442`).
**(c) The process model does NOT preclude a cross-character reset.**
The graphical host constructs its connect options with **no character
selector**:
```csharp
// src/AcDream.App/Composition/SessionPlayerComposition.cs:1127-1132
new LiveSessionConnectOptions(
d.Options.LiveMode, d.Options.LiveHost, d.Options.LivePort,
d.Options.LiveUser ?? string.Empty, d.Options.LivePass ?? string.Empty)
```
`LiveSessionConnectOptions.Character` therefore defaults to `null`
(`src/AcDream.Runtime/Session/LiveSessionContracts.cs:16`), so
`LiveSessionController.StartCore` (`:611-618`) falls through
`TrySelectCharacter` (`:841-852`) to `CharacterList.TrySelectFirstAvailable`,
whose only filter is
`IsAvailableActiveIdentity(c) => c.Id != 0 && c.SecondsGreyedOut == 0`
(`src/AcDream.Core.Net/Messages/CharacterList.cs:111-112`) — and ACE derives
`secondsGreyedOut` from `character.DeleteTime`
(`references/ACE/.../GameMessageCharacterList.cs:28`). **Which character enters
world at generation N+1 is resolved fresh from a server-supplied list, and
nothing in `RuntimeAllegianceState` keys on character identity.** The reset
genuinely runs between the two: `StopCore` (`LiveSessionController.cs:676-701`)
always drains the retired scope, whose teardown calls
`Host.ResetSessionState(Generation)` (`:231`), which is the graphical host's
`_domain.Runtime.ResetGeneration(...)` hop
(`src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:193`).
Headless is *not* exposed: `HeadlessSessionHost` requires exactly one selector
field (`MapCharacterSelector`, `:830-835`; a fully-null selector makes
`selectorCount != 1` and `TrySelectCharacter` returns false), so each headless
root is character-pinned for its whole life. The exposure is the graphical
host only — but the graphical host is the one FA3FA5 target.
**(d) There is no re-seed that would mask it.**
ACE pushes `0x0020 AllegianceUpdate` on a tree change
(`AllegianceManager.cs:398`, `Allegiance.cs:361`) or in reply to `0x001F`
(`GameActionAllegianceUpdateRequest.cs:9-22`). FA2 wires no `0x001F` sender to
any lifecycle event (verified in SF/clean §3.4). Retail's own re-seed is
`gmAllegianceUI::RecvNotice_PlayerDescReceived @0x00490D59`
`Event_UpdateRequest(1)` (lane C §1.2), which acdream does not have yet. So for
a second character with **no** allegiance, character A's `AllegianceName`,
monarch, vassal list, `HasProfile` and `HasServerSeed` stay readable
indefinitely.
**Recommended fix.** Add `RuntimeGenerationResetStage.Allegiance` calling a new
`RuntimeAllegianceState.ResetSession()` that clears the profile **and**
`_hasServerSeed` (mirroring the options precedent exactly); invert the test;
correct plan §2 D2 and lane D §1.3's inverted precedent citation. Anchor the
missing PlayerDesc-triggered `0x001F(1)` resubscribe to FA5 as a TS row so the
clear does not silently mean "blank until the panel is opened".
If the campaign still wants persistence after seeing this, it needs BOTH a
divergence-register row (CLAUDE.md: same commit as the deviation) AND a
character-identity key on the retained data — persistence with no identity
guard is the failure mode, not the persistence itself.
### MF-2 — `0x027C AllegianceInfoResponse` must not seed the cached profile; retail's handler is print-only
`RuntimeAllegianceState.ApplyInfoResponseSelf`
(`RuntimeAllegianceState.cs:101-116`) writes monarch, records, name, totals,
`_hasProfile` and `_hasServerSeed` from `0x027C`. Retail does not.
The dispatcher unpacks `0x027C` into a **stack-local** profile and destroys it
at return:
```
006a7470 CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent(...)
006a74cd void var_118; CAllegianceProfile::CAllegianceProfile(&var_118);
006a74f1 AllegianceProfile::UnPack(&var_118, &arg2, …);
006a74ff ClientAllegianceSystem::Handle_Allegiance__AllegianceInfoResponseEvent(
arg1->m_pAllegianceSystem, edi, &var_118);
006a750a AllegianceHierarchy::~AllegianceHierarchy(&var_114);
```
(`acclient_2013_pseudo_c.txt:686294-686313`.) The handler
(`@0x0056A1D0`, `:375144-375210`) reads only `arg3` and calls
`ClientSystem::AddTextToScroll` — **it never touches
`this->m_allegianceProfile`.**
Contrast the only path that does seed:
```
0056a120 ClientAllegianceSystem::Handle_Allegiance__AllegianceUpdate(...)
0056a129 AllegianceProfile::operator=(&this->m_allegianceProfile.vtable, arg2);
```
(`:375103-375109`.) `0x0020` is the single inbound writer of the cached
profile, which is why lane C describes `0x0020` as the subscribed push and
`0x027C` as a query response.
**Second-order defect the divergence already carries.** `0x027C` has no rank
field, so `ApplyInfoResponseSelf` deliberately retains the previous `_rank`
(`RuntimeAllegianceState.cs:97-99`). On a client where the only allegiance
message ever received is a self `@allegiance info` response,
`Snapshot.HasProfile == true` with `Snapshot.Rank == 0` — FA5's monarch/self
block would render a ranked character as rank 0 (no title). That state is
unreachable in retail precisely because retail never seeds from `0x027C`.
**Recommended fix.** Drop `ApplyInfoResponseSelf`, its
`onAllegianceInfoResponseSelf` delegate hole, and the self-gate, leaving the
`@allegiance info` chat path exactly as it is today (the fold itself is
correct — see §3.2). If it is kept deliberately, it needs a register row naming
the rank hazard.
---
## 2. SHOULD-FIX
**SF-1 — The nine new delegate holes are passed unconditionally, so nine event
types silently leave the unhandled-count diagnostic.**
`LiveSessionEventRouter.cs:238-264` passes non-null lambdas
(`update => social.Fellowship?.ApplyFullUpdate(update)` etc.) regardless of
whether `social.Fellowship`/`Allegiance` is supplied, so
`GameEventWiring`'s `if (onX is not null)` guards always pass and the handlers
always register. `GameEventDispatcher.Dispatch` (`:95-117`) only increments
`_unhandledCounts` when there is no registration, so
`GetUnhandledCount(FellowshipFullUpdate)` and its eight siblings now read 0 for
any caller that did not supply an owner (bare-`ChatLog` tests, future
partial hosts) even though the parse result is discarded. Pass the delegates
conditionally, or hoist the null check to the binding site.
**SF-2 — Disposed check is outside the lock in both new owners (TOCTOU).**
Every mutator does `ObjectDisposedException.ThrowIf(IsDisposed, this)` and then
takes `_gate` separately (`RuntimeFellowshipState.cs:69-71, 94-96, 112-114,
133-135, 148-150, 207-208`; `RuntimeAllegianceState.cs:80-82, 104-106, 132-134,
139-141, 146-148`). `IsDisposed` takes and releases the lock itself, so an
inbound event racing `Dispose` can repopulate `_members`/`_records` after
`_disposed = true`, permanently falsifying `CaptureOwnership().IsConverged` and
failing the J-slice ownership ledger at teardown. The established precedent
checks inside the lock (`RuntimeCommunicationState.cs:377-379`). Inbound events
run on the decode thread and `Dispose` on the host thread, so this is a real
window, currently masked by the router's `accepting` gate.
**SF-3 — Retail's client-side `RecalculateEvenXPSplitting` is not modelled.**
`Fellowship::UpdateFellow @0x005B9730` calls
`Fellowship::RecalculateEvenXPSplitting(this)` at `@0x005B9785` on every
upsert (`:457071-457098`), as do `AddFellow @0x005B94D5` and
`RemoveFellow @0x005B96EE` (lane B §2.10). FA2 stores `_evenXpSplit` from the
full update and never recomputes it on `ApplyUpdateFellow`/`ApplyQuit`/
`ApplyDismiss`. FA4's share-percentage column branches on exactly this field
(lane B §2.9). Against ACE it is largely masked — `RemoveFellowshipMember` and
`AddFellowshipMember` both finish with `UpdateAllMembers()`, a FullUpdate to
everyone (`references/ACE/Source/ACE.Server/Entity/Fellowship.cs:179,219-227`)
— but the retail mechanism is absent, and the owner is where retail keeps it.
**SF-4 — Retail's locked/departed admission gate on the upsert path is not
modelled.** `Fellowship::UpdateFellow` falls through to
`Fellowship::AddFellow @0x005B9480` when the guid is absent (`@0x005B976F`),
and `AddFellow` refuses a guid while `_locked` unless it is in
`_fellows_departed` within 900 s (lane B §2.11, `@0x005B94A5`). FA2's
`ApplyUpdateFellow` always inserts, and `ApplyFullUpdate` discards
`update.Departed` entirely (`RuntimeFellowshipState.cs:67-84` — field 8 of
`0x02BE` per lane B §3.9). The insert-a-new-member branch IS live against ACE
(`AddFellowshipMember` sends `UpdateFellow` to existing members *before* the
FullUpdate, `Fellowship.cs:161-163`), so this is reachable, not theoretical.
**SF-5 — `AllegianceProfileLookups` is re-implemented rather than reused.**
`RuntimeAllegianceState`'s `TryGetMember`/`TryGetPatron`/`GetVassals`
(`:214-290`) duplicate FA1's `ClientCommandResponses.AllegianceProfileLookups`
(`:250-294`) line for line. They agree today — I diffed both against
`AllegianceHierarchy::GetPatron @0x005B6DD0` (`:453991-454011`) and the §4.4
reversal rule and both are correct — but two copies of a retail walk is the
shape that drifts. `AllegianceProfileLookups` is `private static`; promoting it
to `internal` (or public) lets Runtime call the one implementation.
**SF-6 — `RuntimeStateCheckpoint`'s new defaulted snapshots carry null
strings.** `GameRuntimeViews.cs:241-245` defaults `Fellowship`/`Allegiance` to
`default`, whose `Name`/`AllegianceName` are `null` (not `string.Empty` — the
owners' own initialisers are correct, `RuntimeFellowshipState.cs:41`,
`RuntimeAllegianceState.cs:49`). Only test-constructed checkpoints hit it
(`GameRuntimeContractTests.cs:150` is the sole positional site), and the trace
recorder only reads scalars (`GameRuntimeEvents.cs:184-189`), so nothing NREs
today. Worth a non-null default.
**Observations (no action requested).** `RuntimeFellowMemberSnapshot.ShareLoot`
is exposed as `bool` while lane B §4.1's recommendation is "model as a raw
`uint`" — the predicate is right (`!= 0`, never `== 1`,
`RuntimeFellowshipState.cs:291-293`, pinned by
`ShareLoot_ReadsTheRawWireBitAsNonZero_NeverEqualsOne`) and the raw value is
retained in the owner, so this is a view-shape preference, not a defect.
`IRuntimeFellowshipView` has no member enumeration (only
`TryGetMember(guid)` + `MemberCount`), which FA4's roster list box will need.
---
## 3. What verified clean
### 3.1 Fellowship lifecycle rules — every rule re-derived against the decomp
| Rule | FA2 | Retail |
|---|---|---|
| Full update REPLACES | `ApplyFullUpdate` clears then refills, sets `_isInFellowship` (`:67-84`) | `Handle_Fellowship__FullUpdate @0x005693C0`: allocate `CFellowship` if absent, then `Fellowship::operator=` (`:374037-374071`) |
| Incremental UPSERTS by guid | `_members[update.MemberGuid] = update.Member` (`:92-101`) | `Fellowship::UpdateFellow @0x005B9730``Fellow::operator=` on hit, `AddFellow` on miss (`:457071-457098`) |
| SELF quit clears the WHOLE snapshot | `ApplyQuit`, `quitterGuid == selfGuid``ClearLocked()` (`:110-124`) | `Handle_Fellowship__Quit @0x00569E90`: `arg2 != player_id``RemoveFellow`, else `DeleteFellowship` (`:374914-374950`) |
| Other-quit removes ONE | `_members.Remove(quitterGuid)` | same site |
| Dismiss identical to quit | `ApplyDismiss` (`:130-144`) | `Handle_Fellowship__Dismiss @0x00569F10` is byte-identical (`:374953-374985`) |
| Disband always clears | `ApplyDisband` (`:147-151`) | `Handle_Fellowship__Disband @0x00569E70``DeleteFellowship` (`:374903-374910`) |
The `if (!_isInFellowship) return;` guards on the three incremental mutators
are strictly defensive: retail passes a possibly-null `m_pFellowship` straight
into `Fellowship::IsFellow`/`RemoveFellow` with no guard, so the equivalent
retail state faults rather than doing something different. ACE never reaches
it (`Player.LogOut_Inner``FellowshipQuit(false)`,
`references/ACE/.../WorldObjects/Player.cs:531-532`, so a reconnect is never
mid-fellowship; and `HandleFellowshipUpdateRequest` sends a FullUpdate before
any vitals stream, `Player_Fellowship.cs:142-149`).
**The leader hand-off rule is exact.** `RequiresLeaderHandoffBeforeQuit`
(`RuntimeFellowshipState.cs:172-193`) returns true only when
`!disband && _isInFellowship && _leaderGuid == selfGuid` and a non-self member
exists. Retail's Quit button, `gmFellowshipUI::ListenToElementMessage` case 8
(`:156425-156444`):
```
0049034b case 8:
00490353 if (m_pFellowship_1 != 0)
0049035d if (m_pFellowship_1->_leader == GetPlayerID())
00490361 eax_18 = Fellowship::GetNonLeaderFellowID(m_pFellowship_1);
00490368 if (eax_18 != 0) AssignLeadershipToFellow(this, eax_18);
004903f1 CM_Fellowship::Event_Quit(0);
```
and case `0xC` (Disband) is `CM_Fellowship::Event_Quit(1)` with **no** hand-off
(`:156475-156479`). FA2 collapses the two retail buttons into one
`Quit(disband)` and gates the hand-off on `!disband` — behaviourally identical.
`Fellowship::GetNonLeaderFellowID @0x005B9200` (`:456723-456770`) walks the
hash table returning the first key `!= _leader`; FA2 skips `selfGuid`, which
equals `_leaderGuid` on that branch. Equivalent modulo iteration order, which
retail does not define either.
**Ordering is preserved on both hosts.** Direct sends `0x0290` then `0x00A3`
inline (`DirectGameRuntimeCommandAdapter.cs` `Quit`); App publishes two bus
messages under one lock (`LiveSessionCommandRouter.Publish`, `:293-300`), so
`FellowshipAssignNewLeaderRuntimeCmd` cannot be reordered after
`FellowshipQuitRuntimeCmd`.
**`IsFull >= 9`** — verified by absence. No FA2 code encodes a fellowship
capacity anywhere (grep for `IsFull` returns only unrelated
`IsFullyConstrained`/`OutsideIsFullScreen`/`IsFullyResident`), and
`RuntimeFellowshipSnapshot` exposes `MemberCount` without an `IsFull`. Lane B
§7.1's byte-decoded `cmp [ecx+0x14],9 / sbb / inc` result has nothing to be
wrong about yet; FA4's `UpdateButtons` port is where it lands.
### 3.2 The shared `0x027C` registration — the folding claim is correct and the live output is byte-identical
`GameEventDispatcher.Dispatch` (`:95-117`) is a single
`_handlers.TryGetValue``registration.Handler(envelope)`; `Register`
(`:57-76`) writes `_handlers[type] = node`, i.e. it REPLACES. The seam doc
§2.3's "both fire" claim is wrong and the implementer's correction is right —
a literal second `registrar.Register(AllegianceInfoResponse, …)` would have
silently killed the live `@allegiance info` output.
The folded handler (`GameEventWiring.cs`, `AllegianceInfoResponse`
registration) keeps the pre-existing three lines verbatim — parse, `if (info is
null) return;`, `foreach (FormatAllegianceInfoLines) chat.OnSystemMessage(line,
chatType: 0u)` — and only appends the self-gated callback afterwards. **One
parse feeds both; there is no double-parse divergence and no reordering of the
chat emission.** Pinned by
`WireAll_AllegianceInfoResponse_SelfGated_FiresOnlyForOwnGuid`, which asserts
`chatLines > 0` on both the self and the other-player response and `observed ==
null` only on the latter. (Note this verdict is about the *fold*; whether the
callback should exist at all is MF-2.)
Minor: the gate is `playerGuid is not null && info.Value.TargetGuid ==
playerGuid()`, with no `playerGuid() != 0` clause. ACE always writes a real
guid (`GameEventAllegianceInfoResponse.cs:9-11`), so it is unreachable, but a
`!= 0` clause would be free.
### 3.3 D4's `0x00A6` — present, generation-gated, not fired
`IRuntimeFellowshipCommands.SetPanelOpen` (`GameRuntimeCommands.cs:341-343`) is
implemented on both adapters (`DirectGameRuntimeCommandAdapter.cs:941`,
`CurrentGameRuntimeCommandAdapter.cs:934`), both behind the generation
`Validate` gate, and reaches `WorldSession.SendFellowshipUpdateRequest`
`SocialActions.BuildFellowshipUpdateRequest`. A whole-`src` grep for
`SetPanelOpen` finds no caller other than the unrelated
`ToolbarController.SetPanelOpen(uint panelId, bool open)`. **No premature
send.** The same holds for `IRuntimeAllegianceCommands.SetUpdateSubscription`
(`0x001F`) — declared, implemented, uncalled.
### 3.4 Wiring symmetry
Both `LiveSocialSessionBindings` construction sites are updated in the same
commit with identical named arguments —
`src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:261-263`
(`Fellowship: _domain.Runtime.FellowshipOwner, Allegiance:
_domain.Runtime.AllegianceOwner`) and
`src/AcDream.Headless/Hosting/HeadlessSessionHost.cs:785-787`
(`Fellowship: Runtime.FellowshipOwner, Allegiance: Runtime.AllegianceOwner`).
There is still exactly one `GameEventWiring.WireAll` call site
(`LiveSessionEventRouter.cs:234`), so both hosts get the same registration.
The self-guid used for the quit/dismiss self-vs-other split resolves to the
same owner on every path: the router uses `inventory.PlayerGuid()`, bound to
`() => _player.Identity.ServerGuid` in App
(`LiveSessionRuntimeFactory.cs:277`) and
`() => Runtime.PlayerIdentity.ServerGuid` in Headless
(`HeadlessSessionHost.cs:745`); Direct uses
`_runtime.PlayerIdentity.ServerGuid`; App's adapter uses
`_view.Lifecycle.PlayerGuid`, which `GameRuntime` fills from
`PlayerIdentity.ServerGuid` (`GameRuntime.cs:484-488`). One source.
The two adapters' zero-guid rejection paths look different
(`EmitUnsupported(...,Rejected,guid)` vs `EmitResult(...,Rejected,guid)`) but
are semantically identical — both call
`EventSink.EmitCommand(domain, op, status, primaryObjectId)` and return
`Result(status, primaryObjectId)`; `EmitResult` merely also passes
`text: null` (`DirectGameRuntimeCommandAdapter.cs:1355-1383`).
`IRuntimeEventObserver` is untouched — the `369729f0` diff of
`GameRuntimeEvents.cs` adds only two `RuntimeCommandDomain` ordinals and six
trace-string lines; the interface (`:100-118`) is unchanged, so the five bot
policies and the trace recorder still compile. This honours D2.
The Runtime-only assembly guard still holds: `GameRuntimeTests.cs:174-181`
asserts no `AcDream.App`/`Silk.NET` assembly loads, and both new owners live in
`AcDream.Runtime` depending only on `AcDream.Core.Net.Messages` types.
`GameRuntimeContractTests.cs` gained both new types to its owned-type list.
### 3.5 TS-81
The register row is honest and the mechanism claim is verifiable. At
`gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220` the two candidate
strings really do resolve through mislabelled vtable-slot symbols:
```
00492278 if (arg3 == 0)
00492281 var_48_1 = &gmAllegianceUI::`vftable'.RecvNotice_PrevSpellTab;
00492278 else
0049227a var_48_1 = &gmAllegianceUI::`vftable'.RecvNotice_UpdateSpellComponents;
00492286 AC1Legacy::PStringBase<char>::PStringBase<char>(&arg3, var_48_1);
```
(`:158434-158439`) — a `PStringBase<char>` constructed from what BN typed as
two unrelated vtable entries. Refusing to invent English here is correct.
`ApplyLoginNotification` does bump the revision
(`RuntimeAllegianceState.cs:131-135`), pinned by
`Revision_IsMonotonicAcrossEveryEventKind`. TS-80's narrowing is also accurate:
`Create(gen, name, shareXp)` takes the flag but no caller reads
`FellowshipShareXP` into it.
### 3.6 Snapshot immutability, revision monotonicity, J-owner template
Snapshots are `readonly record struct`s of scalars plus member value-copies;
the parsed `IReadOnlyList<AllegianceMemberRecord>` stored by
`ApplyUpdate`/`ApplyInfoResponseSelf` is a freshly allocated list per parse
(`ClientCommandResponses.ReadAllegianceProfileBody`, `:518-519`) held by no one
else, so there is no aliasing path back into the owner. Every mutator bumps a
monotonic `long`; the no-change paths (`ClearLocked` when already empty,
`Remove` returning false) deliberately do not, which is correct for a poll
contract.
The 8-edit template is complete and the teardown maths is right:
`GameRuntimeTeardownStage` renumbers `IdentityDisposed` `1<<9→1<<11` and
`EntityObjectsDisposed` `1<<10→1<<12` (in-memory flags only — no serialized
consumer), `TeardownStageCount` `11→13`, and the stage masks at
`GameRuntime.cs:716-735` compose correctly, with `10`/`11`/`12` expressed as
`Complete & ~…` of exactly the not-yet-run stages. `RuntimeGenerationResetStage`
stays contiguous (`Fellowship = 12` before `BeginEntityRetirement = 13`), which
`Advance`'s `state.Stage++` requires. Both fault-injection construction points
were added and are covered.
### 3.7 Test run
`dotnet test tests/AcDream.Runtime.Tests --no-build -c Release --filter
"FullyQualifiedName~Fellowship|FullyQualifiedName~Allegiance|FullyQualifiedName~GenerationReset"`
**43 passed, 0 failed, 0 skipped** on the committed Release binaries. The
ledger's 13,201/4/0 full-suite claim was not independently re-measured (no
build permitted for this review).
---
## 4. Disposition summary
| # | Severity | Finding |
|---|---|---|
| MF-1 | MUST-FIX | Allegiance survives generation reset; retail clears at `OnEndCharacterSession`, the cited precedent clears, the graphical host can enter a different character in the same process, and the behavior is test-pinned |
| MF-2 | MUST-FIX | `0x027C` seeds the owner; retail's handler is print-only over a stack temporary. Carries a stale-`Rank` second-order defect |
| SF-1 | SHOULD-FIX | Unconditional delegate holes remove 9 event types from the unhandled-count diagnostic |
| SF-2 | SHOULD-FIX | Disposed check outside the lock in both owners (ownership-ledger convergence risk) |
| SF-3 | SHOULD-FIX | `RecalculateEvenXPSplitting` not modelled on upsert/removal |
| SF-4 | SHOULD-FIX | Locked/departed admission gate + `0x02BE` field 8 not modelled |
| SF-5 | SHOULD-FIX | `AllegianceProfileLookups` duplicated in Runtime instead of reused |
| SF-6 | SHOULD-FIX | Defaulted checkpoint snapshots carry null `Name`/`AllegianceName` |
---
# 5. Narrow re-review — 2026-08-12
**Scope:** verify each finding's disposition in the actual fix-round diffs
(`4272ad0e`, `ded23067`, `ed8b3ec9`), spot-verify the blast dispositions, and
check the suite claim. Read-only.
## VERDICT: CLOSED — no reopen. One carry-forward (CF-1) for FA5.
All 2 MUST-FIX and all 6 SHOULD-FIX from this doc are correctly applied, and
every blast disposition I spot-checked holds. Nothing was skipped, and no fix
introduced a new mechanism defect. The two MUST-FIX corrections are real ports,
not suppressions.
## 5.1 MUST-FIX dispositions
**MF-1 — FIXED.** `RuntimeAllegianceState.ResetSession()` (`:192-209`)
delegates to a new `ClearLocked()` that clears the profile **and** drops
`_hasServerSeed`, exactly the `RuntimeCharacterOptionsState.ResetSession`
clear-and-relatch shape. `RuntimeGenerationResetStage.Allegiance = 13` is new
(`RuntimeGenerationReset.cs:39-58`), drained at
`case …Allegiance: Advance(state, _allegiance.ResetSession)` (`:320-322`), and
the owner is constructor-injected with a null check (`:113,148-149`). The enum
stays contiguous (`Allegiance = 13``Complete = 22`), which `Advance`'s
`state.Stage++` requires. **Stage enumerations are consistent everywhere:** a
whole-tree grep for `RuntimeGenerationResetStage.` outside its own file returns
only by-**name** references (`LiveSessionResetPlanTests.cs:279`,
`RuntimeGenerationResetTests.cs:121,160`, two doc-comment `<see cref>`s) — no
consumer depends on an ordinal and nothing serializes it, so the +1 shift is
inert. The test is inverted, not deleted:
`FellowshipClearsAtResetButAllegianceSurvivesReconnect`
`FellowshipAndAllegianceBothClearAtGenerationReset`, now asserting
`!HasProfile`, `!HasServerSeed`, `AllegianceName == string.Empty`,
`MonarchGuid == 0`, `RecordCount == 0`.
One shape worth naming as correct rather than sloppy: `ResetSession` carries
**no** disposed guard, and `ClearLocked` on already-cleared fields computes
`changed == false` so it does not even bump the revision — a true no-op after
`Dispose`. That is right for a *retryable* reset transaction (a throwing guard
could never converge on retry), it matches the
`RuntimeInventoryState.ResetExternalContainer` /
`RuntimeCommunicationState.ResetNegotiatedChannels` precedent, and
`RuntimeFellowshipState.ResetSession` was brought to the same shape in
`ded23067` (blast SF-5). Note this is deliberately the opposite of SF-2's
lock-first *mutator* guard — mutators must reject post-dispose writes, reset
must tolerate them. Both are right.
**MF-2 — FIXED.** `ApplyInfoResponseSelf` is deleted from
`RuntimeAllegianceState`; the `onAllegianceInfoResponseSelf` delegate hole is
deleted from `GameEventWiring.WireAll`; the `TargetGuid == playerGuid()`
self-gate is deleted from the `AllegianceInfoResponse` registration, which is
now the pre-FA2 three-line parse → null-check → `FormatAllegianceInfoLines`
chat emission and nothing else; the router's forwarding lambda is gone. A
whole-tree grep for `ApplyInfoResponseSelf|onAllegianceInfoResponseSelf`
returns **0 hits** in `src/` and `tests/`. The test was rewritten rather than
dropped: `WireAll_AllegianceInfoResponse_SelfGated_FiresOnlyForOwnGuid`
`…_IsTextOnly_ForSelfAndOtherGuidsAlike`, pinning the chat output firing
unconditionally for both a self query and a by-name query. The stale-`Rank`
second-order defect dies with the seeding path.
## 5.2 SHOULD-FIX dispositions
**SF-1 — FIXED.** All nine delegate holes are now passed conditionally
(`social.Fellowship is { } x ? x.ApplyFullUpdate : null`, etc.,
`LiveSessionEventRouter.cs`), so `GameEventWiring` registers a handler only
when an owner exists and `GameEventDispatcher.GetUnhandledCount` reports
correctly for owner-less callers. Nine, not ten — `onAllegianceInfoResponseSelf`
was removed by MF-2, which is consistent.
**SF-2 — FIXED.** Every mutator in both owners now takes `_gate` first and
checks the plain `_disposed` field inside it
(`RuntimeFellowshipState.ApplyFullUpdate`/`ApplyUpdateFellow`/`ApplyQuit`/
`ApplyDismiss`/`ApplyDisband`; `RuntimeAllegianceState.ApplyUpdate`/
`ApplyLoginNotification`/`ApplyUpdateDone`/`ApplyUpdateAborted`), matching
`RuntimeCommunicationState.cs:377-379`. The decode-thread-vs-host-thread window
that could repopulate a disposed owner and permanently falsify
`CaptureOwnership().IsConverged` is closed.
**SF-3 — FIXED, and the port is faithful.** `RecalculateEvenXpSplit()` is a
line-for-line port of lane B §2.10's transcription of
`Fellowship::RecalculateEvenXPSplitting @0x005B92E0`: early-return on
`!_shareXp` leaving the flag untouched; min/max scan; `_evenXpSplit = true`;
then `if (minLevel < 50) { if (maxLevel > leaderLevel + 5) false;
if (minLevel + 5 < leaderLevel) false; }`. Called from `ApplyUpdateFellow`
after the upsert (retail: `Fellow::operator=` then
`RecalculateEvenXPSplitting`, `@0x005B977E`/`@0x005B9785`) and from
`ApplyQuit`/`ApplyDismiss` on the removal branch only (retail `RemoveFellow
@0x005B96EE`) — **not** from `ApplyFullUpdate`, which is correct and is the
part that could easily have been got wrong: retail's `Fellowship::operator=`
copies the server's authoritative flag and does not recompute. `ClearLocked`
does not recompute either, which is right — a full clear is retail's
`DeleteFellowship`, not `RemoveFellow`.
Two deliberate departures, both flagged in the code and both defensible:
`minLevel` seeds from `uint.MaxValue` rather than retail's `100000` (identical
for any reachable level, and the empty-table case converges to the same
result); and the leaderless-table case returns `_evenXpSplit = true` instead of
replaying retail's unsigned wraparound against the `0xFFFFFFFF` sentinel. I
verified that second one against the cited source rather than trusting the
comment: lane B §7.4 (`2026-08-11-fa-fellowship-wire.md:962-966`) says verbatim
*"treat a leaderless table as 'leave `_even_xp_split` at 1'"*. The citation is
accurate and the lane is the pinned spec.
**SF-4 — FIXED.** `ApplyFullUpdate` now stores `update.Departed` into a
`_fellowsDeparted` dictionary (cleared alongside `_members` in both
`ApplyFullUpdate` and `ClearLocked`), and `ApplyUpdateFellow` gates the
new-guid branch through `IsAdmissibleWhileLocked``_fellowsDeparted`
membership plus a 900 s (`0x384`) window, matching `Fellowship::AddFellow
@0x005B94A5`. The gate correctly applies **only** to the "is this guid NEW"
branch; an existing member's refresh is never gated, matching retail's
`UpdateFellow`-hits-the-table path, and a refused guid skips the recompute
exactly as retail's early-returning `AddFellow` does. I confirmed the gate has
real data to work with: FA1's parser does read `0x02BE` field 8
(`GameEvents.cs:693-701``[u16 count][u16 buckets]` then
`count × [u32 guid][i32 timestamp]`), so this is not an always-refuse gate.
**SF-5 — FIXED.** `AllegianceProfileLookups` promoted `private``internal`
with `<InternalsVisibleTo Include="AcDream.Runtime" />` added to
`AcDream.Core.Net.csproj`; `TryGetMember`/`TryGetPatron`/`GetVassals` now call
`FindData`/`FindPatron`/`FindVassals` and the duplicated `TryGetMemberLocked`
helper is deleted. One implementation of the retail walk again.
**SF-6 — FIXED.** `RuntimeStateCheckpoint`'s `Fellowship`/`Allegiance`
parameters are now required rather than `= default`, and both snapshot types
gained an explicit parameterless constructor seeding `string.Empty`. The stated
reasoning is correct C#: `default(T)` bypasses a struct's parameterless
constructor, and CS1736 forbids a non-constant `new(...)` as an optional
parameter default — so required parameters really were the only way to
guarantee a non-null `Name`/`AllegianceName`.
## 5.3 Blast dispositions (spot-verified)
- **Teardown ledger (blast MF-1) — FIXED, better than the report asked for.**
Case 9 was genuinely one flag too many (it included `FellowshipDisposed`, the
stage it had not yet run); it is now
`Complete & ~Fellowship & ~Allegiance & ~Identity & ~EntityObjects` = 9
flags. I re-derived the whole table: at `_disposeStage == N` exactly N flags
are set, for every N in 0..13. The new
`CompletedTeardownStagesAccumulatesExactlyOneFlagPerStage` reflection walk
pins **every** intermediate stage (the previous tests only sampled stage 0
and `Complete`, which is precisely why the off-by-one survived), and its
closing `Assert.Equal(Complete, expected)` ties the hand-written ordered flag
list back to the enum so a future owner insertion cannot silently drop or
duplicate a flag.
- **Router self-guid test (blast SF-4) — FIXED.**
`FellowshipQuit_RoutesSelfGuidToClearAndOtherGuidToRemove` builds real
`RuntimeFellowshipState`/`RuntimeAllegianceState` owners, wires them through
the one production `LiveSessionEventRouter` registration site with
`PlayerGuid: () => self`, and dispatches real `0x00A3` envelopes for both an
other-quit (one member removed, still in fellowship) and a self-quit (whole
snapshot cleared). This was the slice's only non-trivial lambda and it now
has end-to-end coverage.
- **`ResetSession` guard alignment (blast SF-5) — FIXED**, see MF-1 above.
- **`GetVassals` allocation (blast SF-7) — documented** as an intentional
exception with the reason (`yield return` is illegal inside a `lock`) and a
caching hint for FA3. Acceptable disposition for a convention note.
- **Seam-doc addenda (blast SF-1/SF-2) — LANDED and dated.** §1.3's inverted
`RuntimeCharacterOptionsState` citation, §2.3/§9's "dispatcher permits
multiple owned handlers", the §8 table's Allegiance row, and the
executive-summary ownership bullet all carry
`[FA2 fix-round addendum, 2026-08-12 …]` strikes citing the review docs;
§2.3 additionally gains the `0x01C9`/`0x01CA` "correctly left unregistered"
disposition it was missing. The plan's D2, its architecture blurb, and FA2's
slice-map contract row all carry matching dated corrections, so the
"allegiance survives reconnect" claim does not survive uncorrected anywhere.
- **Ledger count corrections (blast SF-3) — LANDED:** 11 `Send*` wrappers (not
12 — `SendAllegianceInfoRequest` pre-dates FA2) and 10 delegate holes (not
15). I re-counted both against the diffs; both corrected figures are right.
- **Register-row re-evaluation (blast SF-6) — I audited the conclusion and
AGREE.** With MF-1's clear-at-reset landed, acdream's allegiance lifetime now
*matches* `ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0`
`AllegianceProfile::Clear`, and with MF-2 landed `0x0020 AllegianceUpdate` is
the sole seeder, matching retail's sole `AllegianceProfile::operator=` writer
at `@0x0056A129`. The register tracks acdream-vs-retail *deviation*; there is
no deviation left for a row to name — the same reasoning FA1's D9 addendum
used for the absent WeenieError strings. **No register row is owed, and no
other FA2 deviation surfaced during this re-review.** TS-81 (login-notice
text) and TS-80 (narrowed) remain correct as filed.
## 5.4 Suite claim
**Consistent with the diffs, exactly.** Counting discovered xunit cases
(`[Fact]` + `[InlineData]` rows) at `12053e61` vs the fix-round tip:
`RuntimeAllegianceStateTests` 7 → 9 (**+2**: one `ApplyInfoResponseSelf_…`
deleted, three `ResetSession_*` added); `RuntimeFellowshipStateTests`
10 Facts + 4 InlineData = 14 → 17 Facts + 7 InlineData = 24 (**+10**);
`GameRuntimeTests` **+1**; `LiveSessionEventRouterTests` **+1**;
`RuntimeGenerationResetTests` and `GameEventWiringTests` **+0** (one in-place
rename each). Total **+14**, matching the ledger's 13,201/4/0 → 13,215/4/0
claim. The absolute totals were not independently re-measured (no build
permitted); the delta is exact.
Targeted runs on the post-fix Release binaries (built 02:19, after the last
source edit at 02:06 and the final code commit at 02:17):
`AcDream.Runtime.Tests` filtered to Fellowship/Allegiance/GenerationReset/
GameRuntimeTests/LiveSessionEventRouter — **95 passed, 0 failed, 0 skipped**;
`AcDream.Core.Net.Tests` filtered to GameEventWiring — **67 passed, 0 failed,
0 skipped**.
## 5.5 Carry-forward (FA5, not a reopen)
**CF-1 — nothing re-subscribes allegiance now that the reset clears it, and the
plan's FA5 row names the wrong opcode for the panel-show path.** Retail sends
`Event_UpdateRequest(1)` (`0x001F`) at `gmAllegianceUI::PostInit @0x004911C6`,
at `RecvNotice_PlayerDescReceived @0x00490D59` (i.e. every character-session
start), and on the panel's visible branch `@0x004912DD` (lane C §1.2). acdream
wires `0x001F` to no lifecycle event, so post-reset the owner stays empty until
ACE happens to push `0x0020` on a tree change. Before MF-1 this was masked by
persistence; the fix correctly exposes it. No register row is owed while there
is no allegiance panel at all (the whole surface is FA3/FA5 scope) and no
consumer reads the owner, but FA5 must wire it — and the plan's FA5 row
currently says *"profile parse on show (`0x027B`)"*, which is the by-name
**info request**, not the subscribe toggle. That distinction is now
load-bearing: after MF-2, `0x027B`'s response (`0x027C`) is text-only and no
longer feeds the owner at all, so an FA5 that follows the plan literally would
open a panel with chat text and no data. Correct the plan row to `0x001F`
before FA5 starts.