acdream/docs/research/2026-08-11-fa-acdream-seams.md
Erik 1226f289f3 research: Campaign FA lanes A-D + the U2 slot-table probe (social panel found)
Four Opus research lanes for the Fellowship & Allegiance campaign:
panel structure, fellowship wire (two BN zero-folds broken by byte
decode: IsFull >= 9, the x87 XP-share table capping at 2.8x), allegiance
wire (27+5 messages binary-verified; tree assembly discard/reversal
rules; ACE zeroed-field caveats), and the acdream seams audit (H.2
scaffolding inventory, J-owner recommendation, AD-78 dimmed-row
inventory, bot-gate requirements).

Coordinator U2 closure (FaPanelSlotProbeTests, live DATs): Fellowship
and Allegiance are two of FOUR pages of ONE tabbed social panel — slot
0x1000018F, panel id 12, Type-8 host — alongside gmFriendsUI and
gmSquelchUI; lane A's separate-siblings mounting call is corrected in
its addendum, and the full 16-slot dump closes every unidentified
RetailPanelCatalog entry (Abuse/Book/LinkStatus/MiniGame/UA/Vitae/
Map+House/Journal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 23:42:19 +02:00

50 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

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").
  • 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.Allegianceso the allegiance tree can be passed directly as a state parameter, exactly like friends.
  • 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.

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, 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
commands.Fellowship.Recruit(gen, targetGuid) BuildFellowshipRecruit
commands.Fellowship.Quit(gen, disband) BuildFellowshipQuit
commands.Fellowship.Dismiss(gen, targetGuid) BuildFellowshipDismiss
commands.Fellowship.SetOpen(gen, open) BuildFellowshipUpdate
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 Same; survives reconnect — use a HasServerSeed-style latch RuntimeCharacterOptionsState seed latch; AllegianceTree.cs:56-162
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 — 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.
  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 RuntimeCommunicationState — the lifetimes differ (fellowship is session-scoped, allegiance survives reconnect) 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.