acdream/docs/research/2026-08-11-fa-acdream-seams.md
Erik ed8b3ec96d docs: FA2 fix-round -- ledger update, D2 correction, seam-map addenda
Plan (docs/plans/2026-08-11-fellowship-allegiance-campaign.md):
- D2 gets the D9-style dated strike/addendum recording the corrected
  allegiance reset semantics (clears at every generation reset; the
  HasServerSeed latch only gates pre-seed rendering WITHIN a session) with
  the three-way evidence citation: the retail OnEndCharacterSession hook,
  the RuntimeCharacterOptionsState precedent's actual clear-and-relatch
  behavior, and the no-character-selector connect path
  (SessionPlayerComposition.cs:1127).
- The architecture blurb and FA2's slice-map contract row get matching
  strike/addendum corrections so the "fellowship session-scoped,
  allegiance survives reconnect" claim does not survive uncorrected
  anywhere in the plan.
- FA2's ledger row: fix-round commit SHAs, corrected delegate-hole/
  wrapper counts (blast SHOULD-FIX 3: 10 not 15, 11 not 12), the
  allegiance register-row re-evaluation conclusion (blast SHOULD-FIX 6 --
  no row needed, MF-1's fix retires the deviation entirely), and the
  reconciled fix-round test totals (13,201/4/0 -> 13,215/4/0, +14,
  arithmetic exact per file).

Seam map (docs/research/2026-08-11-fa-acdream-seams.md), per the FA1
fix-round's established in-place-correction convention:
- SS1.3 and SS9's dispatcher-replaces-not-chains correction is now dated
  and cites the actual GameEventDispatcher.Dispatch behavior, matching the
  code comment already landed in GameEventWiring.cs.
- SS2.3 gets the 0x01C9/0x01CA disposition it was missing (correctly
  left unregistered -- dead COMDAT-fold no-ops per FA1) so FA3 does not
  have to re-derive it or "fix" the gap.
- The SS8 seam-map table's Allegiance-owner row and the executive-summary
  ownership bullet both get the "survives reconnect" claim struck with a
  dated correction to "session-scoped, clears at every generation reset".

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

57 KiB
Raw Blame History

Campaign FA — lane D: acdream seams for Fellowship & Allegiance

Status: RESEARCH ONLY (2026-08-11). Read-only audit of our own tree; no retail decomp claims here (that is lanes AC). Every claim is cited file:line against the worktree .claude/worktrees/eloquent-hugle-42119e.

Purpose: so the FA plan places new code correctly the first time.


0. Executive orientation — what already exists

FA is NOT greenfield. The tree already carries a partial, unwired foundation laid in H.2 (2026-04-18) and extended incidentally by Campaigns CH and OP:

Already present Where Wired to production?
All 11 S→C event ids src/AcDream.Core.Net/Messages/GameEventType.cs:16,19,29,30,55,56,57,84,85,100,101,102 NO parsers, NO handlers
Fellowship C→S builders (5) src/AcDream.Core.Net/Messages/SocialActions.cs:123-168 NO WorldSession.Send* wrapper, NO caller
Allegiance C→S builders (2) src/AcDream.Core.Net/Messages/AllegianceRequests.cs:34-46 NO WorldSession.Send* wrapper, NO caller
Client allegiance tree model src/AcDream.Core/Allegiance/AllegianceTree.cs:56-162 + XP passup :172-183 NO writer; zero references outside its own file
RetailLogTextType.Allegiance 0x12 / .Fellowship 0x13 src/AcDream.Core/Chat/RetailLogTextType.cs:44-45 Colors already conformance-pinned (CH)
RuntimeChatChannel.Fellowship/.Allegiance/.Vassals/.Patron/.Monarch/.CoVassals src/AcDream.Runtime/GameRuntimeCommands.cs:63-90 YES — outbound chat works today (DirectGameRuntimeCommandAdapter.cs:1014-1062)
InputAction.ToggleAllegiancePanel (F3) / .ToggleFellowshipPanel (F4) src/AcDream.UI.Abstractions/Input/InputAction.cs:103-106, defaults KeyBindings.cs:209-210, retail ActionMap ids RetailActionIdentityTable.cs:193-194 NO handlerRetailUiRuntime.HandleInputAction (RetailUiRuntime.cs:548-560) has no case for either
6 dimmed Grouping-group option rows src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs:193-201 Store-only (AD-78)
Retail mutual exclusion (MF-2) src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:818-834 YES — already live
Confirmation types 1 and 4 (allegiance/fellowship) placeholder src/AcDream.App/UI/GameplayConfirmationController.cs:36-40 Generic dialog only; explicitly awaiting FA panels

AllegianceRequests.BuildSwear/BuildBreak and every SocialActions.BuildFellowship* have zero production callers — a whole-tree grep returns only their own definitions and their tests. FA must add the WorldSession.Send* wrappers (§3) before anything can reach the wire.

Open issue ledger: docs/ISSUES.md:12961 (#L.4, "Allegiance, Fellowship, and World remain separate open panel features") and docs/ISSUES.md:524 (#360, the ~22 unported @allegiance/@house subcommands).


1. Runtime ownership — where fellowship/allegiance state belongs

1.1 The J-pattern, as actually implemented

GameRuntime builds every gameplay owner in one ordered, fault-injectable construction transaction (src/AcDream.Runtime/GameRuntime.cs:156-320), exposes each as a public *Owner property (:419-429), and exposes a read-only typed borrowed view per owner (:463-470). Convergence is proved by a per-owner CaptureOwnership() rolled up through RuntimeGameplayOwnership.Capture (src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs:23-44) → RuntimeSimulationOwnership.Capture (src/AcDream.Runtime/RuntimeSimulationOwnership.cs:23-44) → GameRuntime.CaptureOwnership (GameRuntime.cs:545-567).

Every new J-owner therefore costs eight mechanical edits:

  1. a Runtime<X>State class with IDisposable, ResetSession(), CaptureOwnership();
  2. a Runtime<X>OwnershipSnapshot record struct with an IsConverged expression (template: RuntimeCharacterState.cs:9-51);
  3. construction + construction.Own(...) + a new GameRuntimeConstructionPoint fault point (GameRuntime.cs:200-214 is the two-line template);
  4. the *Owner property and its IRuntime<X>View projection (GameRuntime.cs:419-429, :463-470);
  5. a field + a new RuntimeGenerationResetStage ordinal + a Drain case in RuntimeGenerationReset (src/AcDream.Runtime/RuntimeGenerationReset.cs:21-44, :97-121, :236-349);
  6. inclusion in RuntimeGameplayOwnershipSnapshot (RuntimeGameplayOwnership.cs:8-21);
  7. a shutdown-ledger step in GameRuntime's ordered teardown (GameRuntime.cs:703-752 — the numbered switch + => pair must stay in lockstep);
  8. a field on RuntimeStateCheckpoint (src/AcDream.Runtime/GameRuntimeViews.cs:223-241) and the matching RuntimeTraceRecorder.AddCheckpoint string (src/AcDream.Runtime/GameRuntimeEvents.cs:154-224) if the state is to appear in connected-gate artifacts.

1.2 Is RuntimeCommunicationState the natural home? — NO

RuntimeCommunicationState (src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs:55-262) owns ChatLog, SpewBoxState, ChatCommandTargetState, TurbineChatState, FriendsState, SquelchState, ChatWindowState, plus the IRuntimeCommunicationEventSource chat stream (:327-506). Its J4.1 closeout describes its scope as exactly "chat transcript, reply/retell command targets, negotiated Turbine rooms and context cookies, friends, and squelch database" (docs/research/2026-07-26-slice-j4-1-communication-state.md:9-11).

Three reasons FA state does not belong there:

  • Lifetime mismatch. Friends/squelch are cleared at their own reset stages (RuntimeGenerationReset.cs:277-282) and are pure client-side lists. Fellowship membership is a server-authoritative roster with live per-member vitals, and allegiance is a tree. Folding them in makes RuntimeCommunicationOwnershipSnapshot (RuntimeCommunicationState.cs:20-48) a 20-field grab bag whose IsConverged no longer reads as one lifetime.
  • The J4 precedent is one owner per coupled graph, not per theme. J4 split inventory (J4.2), character (J4.3), and communication (J4.1) apart even though all three are "player state"; J4.3's stated rationale is that spellbook + local player "form one lifetime group because vital maxima read active enchantments from this exact spellbook" (RuntimeCharacterState.cs:53-57). Fellowship and allegiance have no such read-coupling to chat.
  • The two FA subsystems are themselves only loosely coupled. Fellowship is a flat ≤9-member roster with a disband/quit/recruit lifecycle; allegiance is a persistent tree with monarch/patron/vassal edges and an XP-passup model that survives logout.

Two sibling owners under GameRuntime, not one, and not a child of Communication:

GameRuntime
├── FellowshipOwner : RuntimeFellowshipState   (new)
│     └── borrows RuntimeEntityObjectLifetime (member guid → ClientObject)
└── AllegianceOwner : RuntimeAllegianceState   (new)
      └── owns the ported AcDream.Core.Allegiance.AllegianceTree
          ^^^ [FA1 fix-round addendum 2026-08-12, re-review CF-1:
          AllegianceTree was DELETED at 4281750b (lane C verdict —
          wrong passup transcription, wire-contradicting tree model).
          RuntimeAllegianceState owns the PARSED profile records from
          ClientCommandResponses.ReadAllegianceProfileBody instead —
          the flat vassal list + monarch/patron/self blocks, already
          assembled with retail's four rejection rules. Do not recreate
          a tree type.]

Rationale and precedent:

  • Separate owners because the reset semantics differ: fellowship is session-scoped (a disconnect drops you from the fellowship server-side — clear it at reset, same class as _inventory.ResetExternalContainer, RuntimeGenerationReset.cs:248-261), whereas allegiance survives reconnect and is re-seeded from PlayerDescription/AllegianceUpdate (same class as RuntimeCharacterOptionsState's HasServerSeed latch, project_settings_options_digest.md DO-NOT-RETRY row "Applying a pre-seed Reset/local snapshot over server truth"). [FA2 fix-round addendum, 2026-08-12 (docs/research/2026-08-12-fa2-review-mechanism.md MUST-FIX 1): this citation was INVERTED. RuntimeCharacterOptionsState's HasServerSeed latch does NOT survive a reset — ResetSession (RuntimeCharacterState.cs:1007-1017) explicitly zeroes the options AND clears _hasServerSeed, with its own doc comment naming the exact hazard this row then walked into: "a stale seed surviving a session boundary could let a flush ship the PRIOR character's words over the new one's." Retail itself clears allegiance at the same per-character-session boundary Fellowship already clears at (ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0 tail-calls AllegianceProfile::Clear, mirroring ClientFellowshipSystem::OnEndCharacterSession @0x005690A0). Corrected: allegiance is now ALSO a reset stage (RuntimeGenerationResetStage.Allegiance) with the identical clear-and-relatch shape as the cited precedent. The two owners remain separate classes because fellowship and allegiance are independent retail systems with independent wire families, not because their lifetimes differ — they no longer do.]
  • Borrow, never copy, the entity table. Fellowship member rows must read live names/vitals from RuntimeEntityObjectLifetime's canonical ClientObjectTable the way RuntimeInventoryState does (GameRuntime.cs:192-193) — a second copy of member names is exactly the "two writable copies" divergence OP9 deleted (project_settings_options_digest.md, AP-196 row).
  • Typed borrowed views, not events, for the panels. IRuntimeSocialView (GameRuntimeGameplayViews.cs:105-110) is the exact template: a Snapshot record struct plus TryGet* accessors, no collection allocation. Add IRuntimeFellowshipView / IRuntimeAllegianceView to GameRuntimeGameplayViews.cs and hang them off IGameRuntimeView (GameRuntimeViews.cs:243-272) so headless bots can assert roster state without touching presentation.
  • Generation-gated commands. Add IRuntimeFellowshipCommands / IRuntimeAllegianceCommands to GameRuntimeCommands.cs alongside IRuntimeSocialCommands (:291-300), register them on IGameRuntimeCommands (:302-325), and add two RuntimeCommandDomain ordinals (GameRuntimeEvents.cs:11-24 — currently Magic = 10, so Fellowship = 11, Allegiance = 12). Both host adapters must implement: DirectGameRuntimeCommandAdapter (src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:20-31 interface list, :718-750 friend-command template) and CurrentGameRuntimeCommandAdapter (src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs:690,703 — it publishes onto the App command bus instead of touching the session).
  • Ordered deltas only if a bot needs them. The J-pattern's event hub (GameRuntimeEventHub, constructed at GameRuntime.cs:304-307 from entity/communication/action owners) feeds IRuntimeEventObserver (GameRuntimeEvents.cs:98-115). A OnFellowship(in RuntimeFellowshipDelta) member is a breaking change to every implementer (5 bot policies in src/AcDream.Headless/Policies/HeadlessBotPolicy.cs, the trace recorder at GameRuntimeEvents.cs:148-311, App observers). Prefer not adding an observer member unless the bot gate (§6) genuinely needs push semantics; a Snapshot.Revision poll off the borrowed view is the cheaper contract and matches IRuntimeSocialView.

1.4 The one thing that should live in Communication

Fellowship/allegiance system message text (recruits, quits, logon notices) is interface text, not fellowship state — it goes through RuntimeCommunicationState.AddText (RuntimeCommunicationState.cs:210-239). See §7.


2. Inbound wire routing — the pattern a new event family must follow

2.1 There is exactly ONE registration site, and it is in Runtime

GameEventWiring.WireAll (src/AcDream.Core.Net/GameEventWiring.cs:36-99) is called from exactly one place in src/: LiveSessionEventRouter.Attach (src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:179-231). A whole-src grep for WireAll returns those two hits only. This is the K-slice unification: both hosts share the router. So a new event family is wired once and both hosts get it — but only if the sink is reachable from both, which is why the sink must be a Runtime owner (§1) and not an App object.

2.2 The mechanical shape

GameEventWiring is a static method taking one positional core (dispatcher, items, combat, spellbook, chat) plus ~20 optional delegate/state "holes" (GameEventWiring.cs:41-99). Core.Net cannot reference AcDream.Runtime — the CH2 comment at GameEventWiring.cs:90-98 states this explicitly ("Core.Net cannot reference AcDream.Runtime directly, so this is a delegate hole exactly like every other Runtime-owned sink above"). Two legal shapes:

  • State object (like friends, squelch, itemMana, vendor, externalContainers): the type lives in AcDream.Core.*, Runtime owns the instance, Core.Net writes into it. FriendsState/SquelchState live in AcDream.Core.Social; AllegianceTree already lives in AcDream.Core.Allegiance — so the allegiance tree can be passed directly as a state parameter, exactly like friends [FA1 fix-round addendum 2026-08-12, re-review CF-1: AllegianceTree was DELETED at 4281750b. The pattern itself stands — the PARSED allegiance profile records (from ReadAllegianceProfileBody, in AcDream.Core.Net) are what gets passed/held; FA2 defines the owned snapshot type in AcDream.Core.* if a Core-side holder is needed, or holds the parsed records directly in the Runtime owner].
  • Delegate hole (like onCharacterOptions, onInterfaceText, onUseDone): a Action<...> the router closes over.

Registration itself: registrar.Register(GameEventType.X, e => {...}) against the owned registrar (GameEventWiring.cs:923-943), which wraps every handler in the accepting gate so a retiring generation cannot receive. Do not register outside WireAll — the RegistrationBuildScope (:945-961) guarantees all-or-nothing ownership.

2.3 The 11 events FA must register

From GameEventType.cs:

Id Name Line
0x0003 AllegianceUpdateAborted :16
0x0020 AllegianceUpdate :19
0x00A3 FellowshipQuit :29
0x00A4 FellowshipDismiss :30
0x01C8 AllegianceUpdateDone :55
0x01C9 FellowshipFellowUpdateDone :56
0x01CA FellowshipFellowStatsDone :57
0x027A AllegianceLoginNotification :84
0x027C AllegianceInfoResponse :85already registered (GameEventWiring.cs:192-198, TS-70 fix)
0x02BE FellowshipFullUpdate :100
0x02BF FellowshipDisband :101
0x02C0 FellowshipUpdateFellow :102

Note 0x027C is already consumed for @allegiance info chat output (GameEventWiring.cs:192-198ClientCommandResponses.ParseAllegianceInfoResponse). FA must not register a second handler for it; either extend the existing lambda or add a second registrar.Register for the same type (the dispatcher supports multiple owned handlers per type — see OwnedGameEventRegistrar.Register, :929-940) and accept that both fire. Decide this in the plan, explicitly.

[FA2 fix-round addendum, 2026-08-12 (docs/research/2026-08-12-fa2-review-blast.md SHOULD-FIX 1): the parenthetical above is WRONG and was disproved during FA2 implementation. GameEventDispatcher.Dispatch (src/AcDream.Core.Net/Messages/GameEventDispatcher.cs:95-117) does a single _handlers.TryGetValue(...) and invokes the ONE stored RegistrationNode; OwnedGameEventRegistrar.Register REPLACES the prior registration for that GameEventType, it does not add a second co-owned handler. A second registrar.Register(GameEventType.AllegianceInfoResponse, ...) call would have silently killed the already-live @allegiance info chat-text output the instant a caller supplied an allegiance-owner callback. FA2 folded both behaviors into the ONE existing registration instead (GameEventWiring.cs's AllegianceInfoResponse handler) — see that method's own code comment for the corrected claim. This was also the right call independent of the correction: FA2's fix round (MUST-FIX 2) subsequently found that 0x027C should not feed a Runtime callback at all, since retail's own handler for it is print-only over a stack-local profile — so the "second handler" question this section poses is now moot either way.]

0x01C9 FellowshipFellowUpdateDone and 0x01CA FellowshipFellowStatsDone from the table above are [FA2 fix-round addendum, 2026-08-12 (docs/research/2026-08-12-fa2-review-blast.md SHOULD-FIX 2): correctly left unregistered by FA2. FA1 established that both COMDAT-fold onto the identical no-op body in the Sept-2013 client and are parse-and-ignore only (src/AcDream.Core.Net/Messages/GameEvents.cs:779-803) — there is nothing for a Runtime owner to consume. This is the disposition for the two rows this section's own title ("The 11 events FA must register") implied FA owed a registration for; FA2 registered the other 9 plus the 0x027C fold, 10 total.]**

2.4 Bindings-record plumbing (both hosts)

LiveSessionEventRouter's five binding records are the host-facing contract (LiveSessionEventRouter.cs:15-82). FA adds either a field to LiveSocialSessionBindings (:72-82) or a new LiveFellowshipSessionBindings/LiveAllegianceSessionBindings record. Prefer extending LiveSocialSessionBindings with trailing optional parameters — the established compatibility convention, stated at :42-44 ("Trailing/optional so every existing positional caller (Headless) compiles unchanged").

Both construction sites must be updated (blast-radius rule: the no-window host is a first-class consumer):

  • Graphical: src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:248-261 (new LiveSocialSessionBindings(_domain.Communication.Chat, ...)).
  • Headless: src/AcDream.Headless/Hosting/HeadlessSessionHost.cs:780-785 (new LiveSocialSessionBindings(Runtime.CommunicationOwner.Chat, ...)).

Each host then wraps the router: GraphicalSessionEventRoute (src/AcDream.App/Net/GraphicalSessionEventRoute.cs) and HeadlessSessionEventRoute (src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs:14-53). Both delegate Attach/Dispose to the shared router, so no FA change is needed in the wrappers provided the sink is Runtime-owned.

2.5 Parsers

New parsers go in src/AcDream.Core.Net/Messages/GameEvents.cs (609 lines, static Parse* returning nullable structs — the file currently has zero fellowship parsers; the only Allegiance token is AllegianceMonarchId at :589, a PlayerDescription field). Follow the ParseChannelBroadcast / ParseTell shape: ReadOnlySpan<byte> in, Nullable<struct> out, null on malformed.


3. Outbound actions — the C→S builder pattern

3.1 SocialActions IS the right file for fellowship

src/AcDream.Core.Net/Messages/SocialActions.cs already declares the five fellowship opcodes as constants (:36-41) and ships all five builders (:123-168). It also — awkwardly — hosts CharacterOptionId (:362-417) and the 0x0005/0x01A1 option builders. Keep fellowship here; do not create a FellowshipActions.cs that splits the family.

Allegiance already has its own file (src/AcDream.Core.Net/Messages/AllegianceRequests.cs:27-56, Swear 0x001D / Break 0x001E). Extend that file for the remaining allegiance GameActions (#360's boot/ban/officer/title/motd/name/lock/house set) rather than moving them into SocialActions.

Every outbound family reaches the wire through a WorldSession.Send<Verb>() method that allocates the sequence and calls SendGameAction — e.g. WorldSession.SendSetSingleCharacterOption (src/AcDream.Core.Net/WorldSession.cs:2202-2206) and SendAllegianceInfoRequest (:2312-2316). No such wrapper exists for any fellowship or swear/break builder. FA must add:

SendFellowshipCreate / Quit / Dismiss / Recruit / Update
SendAllegianceSwear / SendAllegianceBreak

then reach them from DirectGameRuntimeCommandAdapter (Runtime, direct) and via a *RuntimeCmd record + LiveSessionCommandRouter registration for the App bus path (src/AcDream.App/Net/LiveSessionCommandRouter.cs:74-81 declares the records, :173-186 registers the handlers).

3.3 Golden-vector test conventions

tests/AcDream.Core.Net.Tests/Messages/ is the home (SocialActionsTests.cs, AllegianceRequestsTests.cs already exist). Two tiers, both used:

  • Field-probe tests — read individual offsets back (SocialActionsTests.cs:53-104 covers all five fellowship builders; AllegianceRequestsTests.cs:10-34 covers swear/break). Cheap, and what the existing FA builders have.
  • Hand-computed golden byte vector — the OP1 convention, and the stronger one. SocialActionsTests.cs:136-142 states the rule verbatim: "The golden vector below is HAND-COMPUTED, field by field, from that layout — not generated by calling the builder under test. The CH3 builder (deleted 2026-08-09) died of ten green tests pinning a wrong shape." Example at :144-175. Any FA builder whose layout lane A/B/C had to derive (rather than copy from an existing green test) must get a hand-computed golden vector. AceWireWriter.cs in the same directory is the test-only ACE-mirror writer for inbound goldens.

git-visible caution: BuildFellowshipCreate's padding math (SocialActions.cs:128-137) computes pad but never uses it in the body length beyond new byte[12 + name.Length + boolBlock + pad] — the existing test (SocialActionsTests.cs:53-68) asserts bool offsets 20/21 for a 4-char name, i.e. it pins the current shape but not against a retail/ACE reference. Lane A/B should confirm the trailing-pad rule before FA relies on it.


4. The AD-78-dimmed rows that gain consumers

4.1 Exact inventory — the six Grouping-group rows

All six live in CharacterOptionsPageController.Groups[2] (src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs:193-201), header ID_CharacterOption_Grouping_Section, and all six are StoreOnly:

CharacterOptionId Value Word/mask AutoSave ClientDefault Controller line Table line
IgnoreAllegianceRequests 0x01 Options1 0x00000004 yes false :195 CharacterOptionTable.cs:112
IgnoreFellowshipRequests 0x02 Options1 0x00000008 yes true :196 :113
DisplayAllegianceLogonNotifications 0x18 Options1 0x08000000 no false :197 :135
FellowshipShareXP 0x0F Options1 0x00040000 yes true :198 :126
FellowshipShareLoot 0x11 Options1 0x00100000 yes false :199 :128
FellowshipAutoAcceptRequests 0x12 Options1 0x20000000 yes false :200 :129

A seventh row is FA-adjacent but Chat-group: ListenToAllegianceChat (0x1B, Options1 0x40000000) at CharacterOptionsPageController.cs:229already Live, consumed by TurbineChatMembershipGate.cs:107-110. Do not touch it.

4.2 The tests that pin the dimmed list — un-dim goes through these

tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs:

  • ExpectedStoreOnlyIds literal set — the 35-id hand-transcribed authority (:710-753). The six FA ids are at :728-733 under the comment // Group 3 (Grouping) — all 6.
  • StoreOnlyRows_MatchTheDerivationTableExactly (:755-766) — asserts set equality with Groups, plus Assert.Equal(35, ...) and Assert.Equal(15, 50 - ...). Un-dimming N rows requires editing three numbers here: remove from the literal set, and change 3535-N and 1515+N.
  • Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows (:768-819) — builds the real fixture tree and compares each checkbox's LabelColor against UiRenderContext.StoreOnlyCaptionColor vs Vector4.One, row-index-aligned. Derived from Groups, so it follows automatically once Groups flips.

The class doc states the contract explicitly at :700-709: "Wiring a future consumer for any of these means removing it from this literal set AND flipping its Groups table entry from StoreOnly to Live consciously — leaving either one stale fails this test."

The dim mechanism itself is CharacterOptionsPageController.cs:418-423
(`checkbox.LabelColor = spec.StoreOnly ? UiRenderContext.StoreOnlyCaptionColor
Vector4.One`). Rows stay fully interactive either way — only the caption dims.

4.3 Which rows should un-dim, and which should not

The plan must decide per-row, but the code evidence says:

  • IgnoreFellowshipRequests / FellowshipAutoAcceptRequestsalready have a genuine coded client-side consumer: retail's OnChanged mutual exclusion (MF-2), ported at src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:818-834 with the full rationale in the <remarks> at :790-805. The OP4 author flagged this as a known ambiguity and deliberately left them dimmed (CharacterOptionsPageController.cs:147-154: "have a genuine coded mutual-exclusion side effect … kept dimmed per the doc's explicit Group A listing … a future un-dim is a one-line judgment call, not a re-investigation."). FA gives them a real second consumer (the incoming-fellowship-request path), so these two are the clearest un-dim candidates.
  • DisplayAllegianceLogonNotifications — un-dims only if FA implements the AllegianceLoginNotification 0x027A handler AND gates its chat output on the bit. That gate is the consumer.
  • FellowshipShareXP / FellowshipShareLoot — these are server-side policy (ACE decides the split). They un-dim only if FA reads them to drive the create-fellowship dialog's initial checkbox state (BuildFellowshipCreate(seq, name, openness, shareXp), SocialActions.cs:123-138 [FA1 fix-round addendum, 2026-08-12: FA1 repaired this builder — there is no "openness" field on FellowshipCreateOpcode's wire (the prior 4-arg builder invented one and silently sent it as the low byte of shareXP, which ACE would read as an INVERTED shareXP value). The current signature is BuildFellowshipCreate(seq, fellowshipName, shareXp), SocialActions.cs:144-155.], takes shareXp as a parameter — that IS a client-side read). FellowshipShareXP is additionally tagged Group D (deferred) in the derivation table (:137).
  • IgnoreAllegianceRequests — un-dims if FA gates the incoming-swear confirmation on it.

MF-2 is binding on any un-dim. HeadlessConfigurationLoader.cs:252-274 already rejects a bot config declaring both IgnoreFellowshipRequests and FellowshipAutoAcceptRequests true, because retail's recursive clear makes that unsatisfiable. Any FA UI that surfaces both toggles inherits the same constraint — the panel must reflect the auto-clear, since TrySetOption performs it locally before the send (RuntimeCharacterState.cs:812-841).


5. Panel mounting, dialogs, strings

5.1 Cataloging a new gmPanelUI panel

src/AcDream.App/UI/RetailPanelCatalog.cs holds the DAT property 0x10000029 panel-id constants (:10-18) plus two tables: Mounted (:31-43) and Toolbar (:45-51). FA adds Allegiance and Fellowship constants byte-verified from the authored toolbar/panel property, the way OP3 verified Options = 10u (:20-29 documents that exact method: read property 0x10000029 off the toolbar button in the committed fixture AND cross-check the decompiled slot-key table). Do not guess the ids.

Window names go in src/AcDream.App/UI/WindowNames.cs:5-32 (one const string each) — the file's own doc says it exists "so the mount, the window registry, and the toggle keybind all agree on one literal."

5.2 Mount method template

RetailUiRuntime.MountCharacter (src/AcDream.App/UI/RetailUiRuntime.cs:2663-2706) is the cleanest template — 44 lines, five steps:

  1. Import(0x2100002Eu)ImportedLayout?, log-and-return on null (:2665-2670);
  2. build/subscribe the data provider (:2671-2674);
  3. <X>Controller.Bind(layout, ...) (:2675-2682);
  4. RetailWindowFrame.Mount(Host.Root, layout.Root, resolveSprite, new RetailWindowFrame.Options { WindowName = ..., Chrome = NineSlice, ..., Visible = false, ... })RetailWindowHandle (:2683-2700);
  5. _panelUi.RegisterMainPanel(RetailPanelCatalog.X, WindowNames.X, handle) (:2701-2704).

Then add the Mount<X>() call to Initialize() (RetailUiRuntime.cs:385-413 — the ordered list; note MountDialogFactory at :404 runs before MountCharacter at :405, and MountCharacter's own HandleCharacterRaise throws if that ordering is violated, :2720-2723). Any FA panel that raises a confirmation must be mounted after MountDialogFactory.

RegisterMainPanel (src/AcDream.App/UI/Layout/RetailPanelUiController.cs:47-65) enforces name==handle.Name and shares one parent geometry across all registered main panels — that is retail's gmPanelUI single-active-child behavior, so FA panels get "opening Fellowship closes Allegiance" for free.

RetailWindowManager.Register (src/AcDream.App/UI/RetailWindowManager.cs:57-105) is called by RetailWindowFrame.Mount — FA does not call it directly. It requires the outer frame be a direct UiRoot child (:70-72) and fires WindowRegistered (:44) so the opacity controller picks the window up automatically.

Bindings: add a FellowshipRuntimeBindings / AllegianceRuntimeBindings group to the RetailUiRuntimeBindings record (RetailUiRuntime.cs:300-325) — one record per subsystem is the established grouping.

Keybinds: wire InputAction.ToggleAllegiancePanel / .ToggleFellowshipPanel into RetailUiRuntime.HandleInputAction (:548-560 — the ToggleSpellbookPanel case at :552-556 is the one-liner template). The enum members, retail ActionMap ids, and F3/F4 defaults already exist (§0) — only the handler is missing.

Persistence: if the panel should keep its own visibility across sessions, it must NOT be listed in stateManagedVisibilityWindows (RetailUiRuntime.cs:422-428).

5.3 The confirmation seam — recruit / swear

RetailDialogFactory (src/AcDream.App/UI/Layout/RetailDialogFactory.cs:8-38) is the port of retail's DialogFactory @0x004773C0. Two entry points:

  • MakeConfirmation(message, callback, queueKey, priority) (:125-136) — the simple seam.
  • MakeDialog(RetailDialogData, callback) (:74-123) — full control over queue key, priority preemption, and RetailDialogProperty flags.

Server-driven confirmations (which is what an incoming recruit/swear request is) already have a semantic owner: GameplayConfirmationController (src/AcDream.App/UI/GameplayConfirmationController.cs:12-102). Its HandleRequest comment names FA explicitly at :36-40:

"Types 1 and 4 have allegiance/fellowship semantic owners but use the same response tuple, so this controller retains that tuple until those panels exist."

So the FA plan should decide whether types 1/4 stay on the generic controller (retaining the (serverType, serverContext) tuple, response sent at :87-101) or gain their own owners. Note the single-slot guard at :44-47: a second gameplay confirmation is refused while one is open — mirror retail. Also mirror the DO-NOT-RETRY row from project_chat_digest.md: OpenNextDialog must return early when the queue key is already occupied (RetailDialogFactory.cs:323-334), because a two-stage confirmation (the FA recruit-then-confirm flow is a candidate) can synchronously reopen under the same key.

5.4 String resolution

DatStringResolver (src/AcDream.App/UI/Layout/DatStringResolver.cs:16-83). Two rules:

  • ComputeHash(string) (:68-82) is the exact retail ELF-style hash from compute_str_hash @0x00413110. Labels are resolved as resolveString(tableId, ComputeHash("ID_...")) — never hard-coded English. Template: CharacterOptionsPageController.cs:409-416, which on a failed lookup logs and leaves the caption null, explicitly "rather than invented English."
  • Table ids are family-specific. 0x23000003 is the options family (CharacterOptionsPageController.cs:84); 0x2300000D is the chat text-filter family; 0x23000004 is keyboard refusal (project_settings_options_digest.md DO-NOT-RETRY row). FA must dat-verify which table holds ID_Fellowship_* / ID_Allegiance_* before assuming 0x23000003.
  • Resolution is per-Build, not per-import: the resolver is passed into LayoutImporter.Build as its fifth argument (RetailUiRuntime.cs:2041-2046) AND separately into each page/row controller (:2100). Omitting it from either produces silent blanks — that was half of #375.

5.5 DO-NOT-RETRY rows transcribed from project_settings_options_digest.md

These are the mount-a-new-panel traps Campaign OP paid for. All apply verbatim to an FA panel:

Trap Truth
Lazily creating a fill-anchored child at 0×0 #372. ComputeAnchoredRect captures the degenerate baseline permanently → whole-page blank. Seed the viewport size to the parent extent at creation (UiTemplateListBox). Fixture conformance tests stayed GREEN through this — only mount+activate+switch+assert-drawn tests catch the class.
Assuming an imported tab host is active #375 residual. A tab control must be explicitly activated (controller.ActivateTabs()), and the FA panel's own tab control if it has one. Commit a8ce010d.
Parked template prototypes / missing string resolver #375. A template list whose prototypes were never parented, plus a Build call missing its string resolver, both render as blank rows. Commit 8bd7e3b8.
Flat layout.FindElement for shared element ids Apply/Reset/Defaults share element ids across pages. Always per-page scoped FindDescendant, never a flat layout lookup. (CharacterOptionsPageControllerTests.cs:389-394 documents the collision.)
Open popups losing pointer routing #374. An open dropdown/popup gets first claim on pointer routing in UiRoot. Commit 355c86a6.
Rows straddling the viewport edge vanishing #371. A viewport must ClipsChildren + intersection-cull, not whole-row-cull. Commit a59e077a.
Trusting a BN literal-0 operand for a string id / global Bit Campaign OP four times. Byte-verify from the PDB-paired acclient.exe (check_exe_pdb.py MATCH) before believing a 0.
Re-adding a client copy of a server bit AP-196 is closed. Server bits live ONLY in RuntimeCharacterOptionsState; client-only prefs in the Config-tab store.

6. Headless bot seam for counterpart characters

6.1 What K2 gives you today

IHeadlessBotPolicy (src/AcDream.Headless/Policies/HeadlessBotPolicy.cs:7-12):

internal interface IHeadlessBotPolicy : IRuntimeEventObserver, IDisposable
{
    bool IsComplete { get; }
    void Tick(IGameRuntimeView view, IGameRuntimeCommands commands);
}

Policies are string-selected by config id in HeadlessBotPolicyFactory.Create (:16-27); five exist today: idle, lifecycle-smoke, observer-movement, portal-route-smoke, jump-probe. LifecycleSmokeHeadlessBotPolicy.Tick (:92-142) is the canonical stage-machine template — every command call is generation-gated (commands.Chat.Execute(view.Generation, ...), :103) and its result checked via Require(...).

Config shape: HeadlessSessionDescriptor (src/AcDream.Headless/Configuration/HeadlessConfiguration.cs:38-75) — id, endpoint, account, character, policy, credential, optional characterOptions. Note the MF-1 comment at :32-37: it is a record so with expressions in the direct-CLI path cannot silently drop a property. Multiple sessions in one process is already the shape (Sessions list, :13) and K3/K4 gated 1/5/10/30-session isolation.

Declared-option validation: AllowedCharacterOptions (HeadlessConfigurationLoader.cs:27-57) — all six FA option ids are already tier-1 allow-listed (:30,31,34,36,37,38), plus ListenToAllegianceChat (:46). The MF-2 contradiction check is at :252-274.

Option seeding engine: HeadlessCharacterOptionsSeeder (src/AcDream.Headless/Hosting/HeadlessCharacterOptionsSeeder.cs:58-136) — diff-and-send through the SAME IRuntimeCharacterCommands seam, gated on a two-precondition latch (LoginComplete sent AND HasServerSeed).

Existing bot-vs-ACE gate recipe: docs/research/2026-08-11-campaign-op-test-script.md:766-895 (§OP7). Its structure — declare a config, run acdream-headless run --config X.json, inspect ACE-side persisted state, then re-run for idempotence — is exactly reusable for FA.

6.2 What a bot-recruits-bot fellowship gate needs added

Config / infrastructure:

  1. A second ACE account. The OP7 recipe uses one session (testaccount / +Acdream). A fellowship gate needs two logged-in characters simultaneously. K3 already gated multi-session isolation, and HeadlessConfiguration.Sessions is a list — but the credential model is per-session (HeadlessCredentialReference, :107-114), so two distinct env-var references (e.g. FA_BOT_A_PASSWORD, FA_BOT_B_PASSWORD) suffice if ACE has a second account with a second character. This is the one genuinely external prerequisite — flag it for the user. CLAUDE.md documents only testaccount / +Acdream.
  2. The two bots must be co-located. Recruit requires the target be within range and selectable. Either both characters start at the same spawn, or the policy drives one to the other (the ObserverMovementHeadlessBotPolicy / PortalRouteSmokeHeadlessBotPolicy movement machinery already exists, HeadlessBotPolicy.cs:204-306, :353-593).
  3. A new policy id, e.g. fellowship-smoke, registered in HeadlessBotPolicyFactory.Create (:16-27), with a role discriminator (leader vs recruit). The policy interface has no per-session parameter today beyond the id string — either encode the role in the id (fellowship-smoke-leader / fellowship-smoke-member) or extend HeadlessBotPolicyDescriptor (HeadlessConfiguration.cs:93-97, which currently carries only Id).

Runtime commands the policy needs (new, §1.3/§3.2):

Command Underlying builder
commands.Fellowship.Create(gen, name, open, shareXp) SocialActions.BuildFellowshipCreate [FA1 fix-round addendum, 2026-08-12: the builder is now 3-arg — BuildFellowshipCreate(seq, fellowshipName, shareXp) (SocialActions.cs:144-155). There is no wire "openness" field; drop open from this command's own signature too.]
commands.Fellowship.Recruit(gen, targetGuid) BuildFellowshipRecruit
commands.Fellowship.Quit(gen, disband) BuildFellowshipQuit
commands.Fellowship.Dismiss(gen, targetGuid) BuildFellowshipDismiss
commands.Fellowship.SetOpen(gen, open) BuildFellowshipUpdate [FA1 fix-round addendum, 2026-08-12: BuildFellowshipUpdate no longer exists. Its renamed successor BuildFellowshipUpdateRequest (0x00A6) is PANEL VISIBILITY, not openness — wiring SetOpen to it would re-introduce the exact semantic bug FA1 fixed. The real openness action is BuildFellowshipChangeOpenness (0x0291, SocialActions.cs:222-230).]
commands.Allegiance.Swear(gen, patronGuid) AllegianceRequests.BuildSwear
commands.Allegiance.Break(gen, targetGuid) BuildBreak

Plus target acquisition: the recruit target is another player's server guid. RuntimeHostileTargetQuery.FindClosest (src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs, used at HeadlessGameplayOperations.cs:143) finds hostiles only — FA needs a friendly/player nearest-object query, or the policy reads view.InventoryState/entity deltas for a known character name. The bot already receives OnEntity deltas (GameRuntimeEvents.cs:104), so name-matching off RuntimeEntitySnapshot is the low-friction route.

Views the policy needs to assert on:

  • view.Fellowship.Snapshot — at minimum { Revision, IsInFellowship, Name, MemberCount, LeaderGuid, IsOpen }, plus TryGetMember(guid, out RuntimeFellowMemberSnapshot). Modeled on IRuntimeSocialView (GameRuntimeGameplayViews.cs:105-110).
  • view.Allegiance.Snapshot{ Revision, MonarchGuid, PatronGuid, VassalCount, Rank }, plus TryGetNode.

Gate assertions (what "passed" means):

  1. Bot A creates a fellowship → A's Fellowship.Snapshot.IsInFellowship true, MemberCount == 1.
  2. A recruits B → B's own snapshot flips (proving the 0x02BE FellowshipFullUpdate inbound path reached B's Runtime owner, not just A's local echo). This is the assertion that only a two-bot gate can make.
  3. FellowshipAutoAcceptRequests declared true on B via characterOptions → recruit succeeds without a confirmation; declared false → ACE sends CharacterConfirmationRequest type 4 and the bot must answer it (needs a bot-visible confirmation command, or the policy declares auto-accept to avoid it in v1 — recommend v1 declares auto-accept).
  4. IgnoreFellowshipRequests true on B → recruit refused; asserts the server honors the bit.
  5. B quits → A's MemberCount drops (proving 0x00A3/0x02C0 inbound).
  6. A disbands → both snapshots clear (proving 0x02BF).
  7. Graceful SIGINT → both Runtime roots converge, FA owners' ownership snapshots IsConverged (the K4 terminal-ownership check, src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs).

Allegiance's swear/break is harder to gate: ACE enforces rank/vassal-cap rules and swearing is semi-permanent on a shared test character. Recommend the FA plan gate fellowship with two bots and gate allegiance manually via the graphical client against a throwaway pair, rather than burning +Acdream's allegiance state.


7. Chat / interface-text seam

7.1 The chokepoint

RuntimeCommunicationState.AddText(string text, RetailLogTextType type, uint windowId = 0) (src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs:210-239) is the one seam, the port of ClientSystem::AddTextToScroll @0x00563C50. Behavior: Trim() both ends (:227, and the comment at :214-226 explains why there is deliberately no empty-string guard); ClientLocal 0x1A → SpewBox only (:229-233); everything else → Chat.OnSystemMessage(text, (uint)type) (:238).

Reached from Core.Net through the onInterfaceText delegate hole (GameEventWiring.cs:90-98), supplied by LiveSocialSessionBindings.AddText (LiveSessionEventRouter.cs:77-82) — bound in both hosts (LiveSessionRuntimeFactory.cs:261, HeadlessSessionHost.cs:785).

7.2 Which RetailLogTextType FA messages use

src/AcDream.Core/Chat/RetailLogTextType.cs:

  • Allegiance = 0x12 (:44)
  • Fellowship = 0x13 (:45)

Both already have retail-exact RGBA in RetailChatColorTable (AcDream.UI.Abstractions), conformance-pinned to ChatInterface::BuildChatColorLookupTable @0x4f31c0 (project_chat_digest.md, "Current truth"). No color work needed — just pass the right type.

Rules carried from the chat digest:

  • Do not add chat-color configurability. Retail hard-codes every LogTextType; only per-window type filters are user-settable.
  • ClientLocal 0x1A is SpewBox-only and must never reach the scrolling chat window. Local FA refusals ("You are ignoring fellowship requests.") are the 0x1A class; server-sent roster notices are 0x13/0x12.
  • Refusal text comes from WeenieErrorMessages (344 rows, binary-swept — src/AcDream.Core/Chat/WeenieErrorMessages.cs), not from invented English. FA refusals arriving as WeenieError/WeenieErrorWithString already route correctly through the existing GameEventWiring registration; FA only needs to confirm the specific ids (YouAreNotInAFellowship, FellowshipIsFull, etc.) exist in the table.
  • Register row AP-183 / #363 notes ~10 sites typed 0x00 where retail types 0x1A — do not add more.

7.3 Allegiance logon notifications

AllegianceLoginNotification 0x027A is the one FA message whose display is gated by a character option: DisplayAllegianceLogonNotifications (0x18, Options1 0x08000000, non-auto-save — CharacterOptionTable.cs:135). The gate must read the Runtime option bit, exactly like TurbineChatMembershipGate.cs:105-136 does for the six ListenTo*Chat bits. That gate is the consumer that un-dims the row (§4.3).

Note the chat-digest distinction: Hear*Chat bits are channel membership (retail refuses to even send). DisplayAllegianceLogonNotifications is different — it is a genuine receive-side display filter, so the inbound handler must swallow the notice locally when the bit is off. Lane A/B should confirm this against retail rather than inferring it from ACE.


8. Seam map (summary table)

Subsystem Where new code goes Pattern to follow Precedent citation
Fellowship state owner src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs (new) J4 owner: IDisposable + ResetSession() + CaptureOwnership(); borrow entity table RuntimeInventoryState ctor GameRuntime.cs:192-193; snapshot shape RuntimeCharacterState.cs:9-51
Allegiance state owner src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs (new), wrapping existing AcDream.Core.Allegiance.AllegianceTree [FA1 fix-round addendum, 2026-08-12: AllegianceTree was DELETED by Campaign FA slice FA1 (4281750b) — there is nothing left to wrap. Wrap ClientCommandResponses.AllegianceProfileLookups (the flat AllegianceMemberRecord list + GetData/GetPatron/FindVassals walk that replaced it) instead. FA2 is the very next slice — this is its seam-map row.] Same; survives reconnect — use a HasServerSeed-style latch [FA2 fix-round addendum, 2026-08-12 (docs/research/2026-08-12-fa2-review-mechanism.md MUST-FIX 1): WRONG — retail clears the allegiance profile at OnEndCharacterSession, the very same boundary Fellowship clears at. Allegiance is now a RuntimeGenerationReset stage exactly like Fellowship; the HasServerSeed latch clears AND re-arms every generation (matching the RuntimeCharacterOptionsState precedent's actual clear-and-relatch behavior, not the persist behavior this row previously (mis)cited it for).] RuntimeCharacterOptionsState seed latch; AllegianceTree.cs:56-162 [FA1 fix-round addendum: see ClientCommandResponses.cs:250-295 (AllegianceProfileLookups) instead]
Owner registration GameRuntime.cs ctor + construction.Own + fault point + *Owner prop + view prop + CaptureOwnership + shutdown step GameRuntime.cs:200-214, :419-429, :463-470, :545-567, :703-752
Session reset RuntimeGenerationReset.cs new RuntimeGenerationResetStage ordinal + Drain case; fellowship clears, allegiance re-seeds :21-44, :236-349; shared-stage example :248-261
Ownership rollup RuntimeGameplayOwnership.cs add to record + Capture(...) signature + IsConverged :8-21, :23-44
Typed borrowed views GameRuntimeGameplayViews.cs + GameRuntimeViews.cs:243-272 Snapshot record struct + TryGet*; no allocation IRuntimeSocialView GameRuntimeGameplayViews.cs:90-110
Generation-gated commands GameRuntimeCommands.cs + BOTH adapters new interface + IGameRuntimeCommands member + RuntimeCommandDomain ordinal IRuntimeSocialCommands :291-300; DirectGameRuntimeCommandAdapter.cs:718-750; CurrentGameRuntimeCommandAdapter.cs:690
Inbound parsers src/AcDream.Core.Net/Messages/GameEvents.cs static Parse* → nullable struct, null on malformed ParseChannelBroadcast, ParseTell
Inbound registration src/AcDream.Core.Net/GameEventWiring.cs (inside WireAll) registrar.Register(type, e => ...); state-object OR delegate hole friends/squelch params :80-81; AllegianceInfoResponse :192-198
Host bindings plumbing LiveSessionEventRouter.cs:72-82 + both construction sites trailing optional params LiveSessionRuntimeFactory.cs:256-261; HeadlessSessionHost.cs:780-785
Outbound builders (fellowship) SocialActions.cs (already there, :123-168) keep the family together
Outbound builders (allegiance) AllegianceRequests.cs (extend, :27-56) keep the family together
Outbound send wrappers src/AcDream.Core.Net/WorldSession.cs (MISSING — must add) NextGameActionSequence() + SendGameAction(builder(...)) SendSetSingleCharacterOption :2202-2206
App bus path LiveSessionCommandRouter.cs *RuntimeCmd record + commands.Register<T>(... SendIfActive ...) :74-81, :173-186
Wire tests tests/AcDream.Core.Net.Tests/Messages/ field probes + hand-computed golden vector for any derived layout SocialActionsTests.cs:136-175; AllegianceRequestsTests.cs:10-34
Option un-dim CharacterOptionsPageController.cs:193-201 + tests flip StoreOnlyLive AND edit the literal set + two counts CharacterOptionsPageControllerTests.cs:710-766
Panel catalog RetailPanelCatalog.cs:10-51 + WindowNames.cs byte-verify the panel id from authored property 0x10000029 RetailPanelCatalog.cs:20-29 (the OP3 verification method)
Panel mount RetailUiRuntime.Mount<X>() + Initialize() list Import → Bind → RetailWindowFrame.MountRegisterMainPanel MountCharacter RetailUiRuntime.cs:2663-2706; ordering :385-413
F3/F4 handler RetailUiRuntime.HandleInputAction :548-560 one if (action == ...) { ...; return true; } ToggleSpellbookPanel case :552-556
Confirmations GameplayConfirmationController (types 1/4) or a new owner retain (serverType, serverContext), single-slot guard, respond on close GameplayConfirmationController.cs:32-101 (its :36-40 comment names FA)
Dialogs RetailDialogFactory.MakeConfirmation :125-136 queue key + priority; OpenNextDialog early-return on occupied key :323-334
Strings DatStringResolver.Resolve(tableId, ComputeHash("ID_...")) never invent English; log + leave null CharacterOptionsPageController.cs:409-416
System messages RuntimeCommunicationState.AddText Allegiance 0x12 / Fellowship 0x13; ClientLocal 0x1A = SpewBox only RuntimeCommunicationState.cs:210-239; RetailLogTextType.cs:44-45
Bot gate src/AcDream.Headless/Policies/HeadlessBotPolicy.cs new policy + factory id stage machine, generation-gated commands, IsComplete LifecycleSmokeHeadlessBotPolicy :84-142; factory :16-27
Bot config HeadlessConfiguration.cs / HeadlessConfigurationLoader.cs all 6 FA option names already allow-listed :27-57; MF-2 rejection :252-274
Bot gate script docs/research/2026-08-1X-campaign-fa-test-script.md OP7 §recipe shape 2026-08-11-campaign-op-test-script.md:766-895

9. Open questions for the plan

  1. One owner or two? This audit recommends two (RuntimeFellowshipState, RuntimeAllegianceState) on lifetime grounds. If the plan prefers one RuntimeSocialGroupState, it must still model two independent reset semantics inside one IsConverged.
  2. Does IRuntimeEventObserver gain OnFellowship? Adding a member breaks all 5 bot policies + the trace recorder + App observers. Decide push-vs-poll before writing the owner. (Recommend poll via Snapshot.Revision.)
  3. 0x027C AllegianceInfoResponse already has a handler (GameEventWiring.cs:192-198, chat output for @allegiance info). Does FA extend that lambda, register a second handler, or leave the panel to use a different event? The dispatcher permits multiple owned handlers per type [FA2 fix-round addendum, 2026-08-12 (docs/research/2026-08-12-fa2-review-blast.md SHOULD-FIX 1): wrong — GameEventDispatcher.Dispatch invokes only the single most-recently-registered handler per type; a second Register call REPLACES, it does not co-own. See §2.3's addendum for the full correction. Answered: extend the existing lambda (done), and FA2's own fix round then found the extension itself should not exist — retail's 0x027C handler is print-only, so no owner ever gets fed from it.] — but two handlers writing to two different owners is the "two writable copies" smell.
  4. Which of the 6 dimmed rows actually gain consumers in FA v1? Each un-dim requires three coordinated edits (§4.2). Under-claiming is safe; over-claiming makes the conformance test lie.
  5. Second ACE account for the two-bot fellowship gate. CLAUDE.md documents only testaccount/+Acdream. Confirm with the user whether a second account+character exists, or whether the gate should use two characters on one account (which ACE will not allow simultaneously).
  6. Allegiance gate strategy. Swearing is semi-permanent and rank-gated. Recommend not gating allegiance with bots against +Acdream.
  7. ID_Fellowship_* / ID_Allegiance_* string-table id. Assume nothing — dat-verify. The options family is 0x23000003, but chat filters live in 0x2300000D and keyboard refusal in 0x23000004.
  8. BuildFellowshipCreate trailing-pad rule is currently pinned only by a self-consistent test (SocialActionsTests.cs:53-68), not against a retail/ACE reference. Lane A/B should confirm before FA sends it live. [FA1 fix-round addendum, 2026-08-12: ANSWERED by FA1, close this question rather than re-deriving it. PackString16L (SocialActions.cs:389-403) pads the string record to a 4-byte multiple INCLUDING its own u16 length, so 12 + name.Length is already 4-aligned before the trailing u32 shareXP is appended — the pad the old (now-deleted) builder added after its two invented bools was never the string's own pad. ACE reads exactly ReadString16L() then ReadUInt32() (GameActionFellowshipCreate.cs), and ACE.Common's ReadString16L skips the identical CalculatePadMultiple(2 + length, 4) pad. The golden vector at SocialActionsTests.cs:65-83 ("Team", len 4, 2 pad bytes) is hand-derived against that ACE reader, not merely self-consistent. No further confirmation owed.]
  9. DisplayAllegianceLogonNotifications semantics: receive-side display filter (swallow locally) or membership-style suppression? Unlike Hear*Chat this is almost certainly display-only — but it must be confirmed against retail, not inferred.
  10. Panel ids for RetailPanelCatalog. Must be byte-verified from the authored 0x10000029 property, not guessed; the BN literal-0 fold artifact bit Campaign OP four times.
  11. Does the FA panel participate in RegisterMainPanel's shared geometry? That gives retail's single-active-child behavior (opening Fellowship closes Allegiance) automatically. Confirm that is retail behavior for F3/F4 before adopting it.
  12. Divergence register rows. Any FA-introduced approximation needs its row in docs/architecture/retail-divergence-register.md in the same commit; any row FA retires (e.g. #L.4's panel gap) is deleted in the same commit.

Executive summary

  1. FA is half-scaffolded already: all 11 event ids, all 7 outbound builders, the AllegianceTree model, both chat LogTextTypes, the F3/F4 input actions with retail ActionMap ids, and the MF-2 option mutual-exclusion exist — but the builders have zero production callers, there are no WorldSession.Send* wrappers, no parsers, no inbound registrations, and no F3/F4 handler.
  2. Ownership: two new sibling J-owners under GameRuntime (RuntimeFellowshipState, RuntimeAllegianceState), NOT children of RuntimeCommunicationStatethe lifetimes differ (fellowship is session-scoped, allegiance survives reconnect) [FA2 fix-round addendum, 2026-08-12: both are now session-scoped, cleared at every generation reset — see §1.3's addendum. They remain separate owners because they are independent retail systems, not because their lifetimes differ] and J4's own precedent is one owner per coupled graph. Each costs eight mechanical edits, listed with line-cited templates in §1.1.
  3. Inbound has exactly one registration site (GameEventWiring.WireAll, called only from Runtime's LiveSessionEventRouter.cs:179), so both hosts are served by one wiring — provided the sink is Runtime-owned; both LiveSocialSessionBindings construction sites (App LiveSessionRuntimeFactory.cs:256, Headless HeadlessSessionHost.cs:780) must be updated together.
  4. Exactly six dimmed rows are in FA's blast radius (IgnoreAllegianceRequests, IgnoreFellowshipRequests, DisplayAllegianceLogonNotifications, FellowshipShareXP, FellowshipShareLoot, FellowshipAutoAcceptRequests, CharacterOptionsPageController.cs:195-200); un-dimming any of them requires three coordinated edits pinned by StoreOnlyRows_MatchTheDerivationTableExactly and Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows.
  5. A bot-recruits-bot gate needs a second ACE account (the only external prerequisite), a new HeadlessBotPolicy id with a role discriminator, a friendly-player target query, and 7 named assertions — the decisive one being that the recruited bot's own snapshot flips, which only two live sessions can prove.