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>
50 KiB
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:
- a
Runtime<X>Stateclass withIDisposable,ResetSession(),CaptureOwnership(); - a
Runtime<X>OwnershipSnapshotrecord struct with anIsConvergedexpression (template:RuntimeCharacterState.cs:9-51); - construction +
construction.Own(...)+ a newGameRuntimeConstructionPointfault point (GameRuntime.cs:200-214is the two-line template); - the
*Ownerproperty and itsIRuntime<X>Viewprojection (GameRuntime.cs:419-429,:463-470); - a field + a new
RuntimeGenerationResetStageordinal + aDraincase inRuntimeGenerationReset(src/AcDream.Runtime/RuntimeGenerationReset.cs:21-44,:97-121,:236-349); - inclusion in
RuntimeGameplayOwnershipSnapshot(RuntimeGameplayOwnership.cs:8-21); - a shutdown-ledger step in
GameRuntime's ordered teardown (GameRuntime.cs:703-752— the numberedswitch+=>pair must stay in lockstep); - a field on
RuntimeStateCheckpoint(src/AcDream.Runtime/GameRuntimeViews.cs:223-241) and the matchingRuntimeTraceRecorder.AddCheckpointstring (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 makesRuntimeCommunicationOwnershipSnapshot(RuntimeCommunicationState.cs:20-48) a 20-field grab bag whoseIsConvergedno 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
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 fromPlayerDescription/AllegianceUpdate(same class asRuntimeCharacterOptionsState'sHasServerSeedlatch,project_settings_options_digest.mdDO-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 canonicalClientObjectTablethe wayRuntimeInventoryStatedoes (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: aSnapshotrecord struct plusTryGet*accessors, no collection allocation. AddIRuntimeFellowshipView/IRuntimeAllegianceViewtoGameRuntimeGameplayViews.csand hang them offIGameRuntimeView(GameRuntimeViews.cs:243-272) so headless bots can assert roster state without touching presentation. - Generation-gated commands. Add
IRuntimeFellowshipCommands/IRuntimeAllegianceCommandstoGameRuntimeCommands.csalongsideIRuntimeSocialCommands(:291-300), register them onIGameRuntimeCommands(:302-325), and add twoRuntimeCommandDomainordinals (GameRuntimeEvents.cs:11-24— currentlyMagic = 10, soFellowship = 11,Allegiance = 12). Both host adapters must implement:DirectGameRuntimeCommandAdapter(src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:20-31interface list,:718-750friend-command template) andCurrentGameRuntimeCommandAdapter(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 atGameRuntime.cs:304-307from entity/communication/action owners) feedsIRuntimeEventObserver(GameRuntimeEvents.cs:98-115). AOnFellowship(in RuntimeFellowshipDelta)member is a breaking change to every implementer (5 bot policies insrc/AcDream.Headless/Policies/HeadlessBotPolicy.cs, the trace recorder atGameRuntimeEvents.cs:148-311, App observers). Prefer not adding an observer member unless the bot gate (§6) genuinely needs push semantics; aSnapshot.Revisionpoll off the borrowed view is the cheaper contract and matchesIRuntimeSocialView.
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 inAcDream.Core.*, Runtime owns the instance, Core.Net writes into it.FriendsState/SquelchStatelive inAcDream.Core.Social;AllegianceTreealready lives inAcDream.Core.Allegiance— so the allegiance tree can be passed directly as a state parameter, exactly likefriends. - Delegate hole (like
onCharacterOptions,onInterfaceText,onUseDone): aAction<...>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.
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.
3.2 The missing link: WorldSession.Send* wrappers
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-104covers all five fellowship builders;AllegianceRequestsTests.cs:10-34covers 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-142states 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.csin 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:
ExpectedStoreOnlyIdsliteral set — the 35-id hand-transcribed authority (:710-753). The six FA ids are at:728-733under the comment// Group 3 (Grouping) — all 6.StoreOnlyRows_MatchTheDerivationTableExactly(:755-766) — asserts set equality withGroups, plusAssert.Equal(35, ...)andAssert.Equal(15, 50 - ...). Un-dimming N rows requires editing three numbers here: remove from the literal set, and change35→35-Nand15→15+N.Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows(:768-819) — builds the real fixture tree and compares each checkbox'sLabelColoragainstUiRenderContext.StoreOnlyCaptionColorvsVector4.One, row-index-aligned. Derived fromGroups, so it follows automatically onceGroupsflips.
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'sOnChangedmutual exclusion (MF-2), ported atsrc/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:818-834with 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 theAllegianceLoginNotification 0x027Ahandler 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, takesshareXpas a parameter — that IS a client-side read).FellowshipShareXPis 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:
Import(0x2100002Eu)→ImportedLayout?, log-and-return on null (:2665-2670);- build/subscribe the data provider (
:2671-2674); <X>Controller.Bind(layout, ...)(:2675-2682);RetailWindowFrame.Mount(Host.Root, layout.Root, resolveSprite, new RetailWindowFrame.Options { WindowName = ..., Chrome = NineSlice, ..., Visible = false, ... })→RetailWindowHandle(:2683-2700);_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, andRetailDialogPropertyflags.
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 fromcompute_str_hash @0x00413110. Labels are resolved asresolveString(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.
0x23000003is the options family (CharacterOptionsPageController.cs:84);0x2300000Dis the chat text-filter family;0x23000004is keyboard refusal (project_settings_options_digest.mdDO-NOT-RETRY row). FA must dat-verify which table holdsID_Fellowship_*/ID_Allegiance_*before assuming0x23000003. - Resolution is per-
Build, not per-import: the resolver is passed intoLayoutImporter.Buildas 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:
- 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, andHeadlessConfiguration.Sessionsis 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 onlytestaccount/+Acdream. - 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/PortalRouteSmokeHeadlessBotPolicymovement machinery already exists,HeadlessBotPolicy.cs:204-306,:353-593). - A new policy id, e.g.
fellowship-smoke, registered inHeadlessBotPolicyFactory.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 extendHeadlessBotPolicyDescriptor(HeadlessConfiguration.cs:93-97, which currently carries onlyId).
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 }, plusTryGetMember(guid, out RuntimeFellowMemberSnapshot). Modeled onIRuntimeSocialView(GameRuntimeGameplayViews.cs:105-110).view.Allegiance.Snapshot—{ Revision, MonarchGuid, PatronGuid, VassalCount, Rank }, plusTryGetNode.
Gate assertions (what "passed" means):
- Bot A creates a fellowship → A's
Fellowship.Snapshot.IsInFellowshiptrue,MemberCount == 1. - A recruits B → B's own snapshot flips (proving the
0x02BEFellowshipFullUpdateinbound path reached B's Runtime owner, not just A's local echo). This is the assertion that only a two-bot gate can make. FellowshipAutoAcceptRequestsdeclared true on B viacharacterOptions→ recruit succeeds without a confirmation; declared false → ACE sendsCharacterConfirmationRequesttype 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).IgnoreFellowshipRequeststrue on B → recruit refused; asserts the server honors the bit.- B quits → A's
MemberCountdrops (proving0x00A3/0x02C0inbound). - A disbands → both snapshots clear (proving
0x02BF). - 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 0x1Ais SpewBox-only and must never reach the scrolling chat window. Local FA refusals ("You are ignoring fellowship requests.") are the0x1Aclass; server-sent roster notices are0x13/0x12.- Refusal text comes from
WeenieErrorMessages(344 rows, binary-swept —src/AcDream.Core/Chat/WeenieErrorMessages.cs), not from invented English. FA refusals arriving asWeenieError/WeenieErrorWithStringalready route correctly through the existingGameEventWiringregistration; FA only needs to confirm the specific ids (YouAreNotInAFellowship,FellowshipIsFull, etc.) exist in the table. - Register row AP-183 / #363 notes ~10 sites typed
0x00where retail types0x1A— 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 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<X>() + 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
- One owner or two? This audit recommends two
(
RuntimeFellowshipState,RuntimeAllegianceState) on lifetime grounds. If the plan prefers oneRuntimeSocialGroupState, it must still model two independent reset semantics inside oneIsConverged. - Does
IRuntimeEventObservergainOnFellowship? Adding a member breaks all 5 bot policies + the trace recorder + App observers. Decide push-vs-poll before writing the owner. (Recommend poll viaSnapshot.Revision.) 0x027C AllegianceInfoResponsealready 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.- 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.
- 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). - Allegiance gate strategy. Swearing is semi-permanent and rank-gated.
Recommend not gating allegiance with bots against
+Acdream. ID_Fellowship_*/ID_Allegiance_*string-table id. Assume nothing — dat-verify. The options family is0x23000003, but chat filters live in0x2300000Dand keyboard refusal in0x23000004.BuildFellowshipCreatetrailing-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.DisplayAllegianceLogonNotificationssemantics: receive-side display filter (swallow locally) or membership-style suppression? UnlikeHear*Chatthis is almost certainly display-only — but it must be confirmed against retail, not inferred.- Panel ids for
RetailPanelCatalog. Must be byte-verified from the authored0x10000029property, not guessed; the BN literal-0 fold artifact bit Campaign OP four times. - 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. - Divergence register rows. Any FA-introduced approximation needs its
row in
docs/architecture/retail-divergence-register.mdin the same commit; any row FA retires (e.g. #L.4's panel gap) is deleted in the same commit.
Executive summary
- FA is half-scaffolded already: all 11 event ids, all 7 outbound
builders, the
AllegianceTreemodel, both chatLogTextTypes, 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 noWorldSession.Send*wrappers, no parsers, no inbound registrations, and no F3/F4 handler. - Ownership: two new sibling J-owners under
GameRuntime(RuntimeFellowshipState,RuntimeAllegianceState), NOT children ofRuntimeCommunicationState— 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. - Inbound has exactly one registration site
(
GameEventWiring.WireAll, called only from Runtime'sLiveSessionEventRouter.cs:179), so both hosts are served by one wiring — provided the sink is Runtime-owned; bothLiveSocialSessionBindingsconstruction sites (AppLiveSessionRuntimeFactory.cs:256, HeadlessHeadlessSessionHost.cs:780) must be updated together. - 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 byStoreOnlyRows_MatchTheDerivationTableExactlyandBind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows. - A bot-recruits-bot gate needs a second ACE account (the only
external prerequisite), a new
HeadlessBotPolicyid 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.