# 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 A–C). 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 handler** — `RetailUiRuntime.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 `RuntimeState` class with `IDisposable`, `ResetSession()`, `CaptureOwnership()`; 2. a `RuntimeOwnershipSnapshot` 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 `IRuntimeView` 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. ### 1.3 Recommended ownership shape **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` | `:85` — **already 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-198` → `ClientCommandResponses.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` in, `Nullable` 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`. ### 3.2 The missing link: `WorldSession.Send*` wrappers Every outbound family reaches the wire through a `WorldSession.Send()` 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:229` — **already `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 `35`→`35-N` and `15`→`15+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` / `FellowshipAutoAcceptRequests` — **already 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 `` 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. `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()` 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`): ```csharp 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(... 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 `StoreOnly`→`Live` 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()` + `Initialize()` list | Import → Bind → `RetailWindowFrame.Mount` → `RegisterMainPanel` | `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 `LogTextType`s, 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 `RuntimeCommunicationState` — ~~the 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.