Plan (docs/plans/2026-08-11-fellowship-allegiance-campaign.md): - D2 gets the D9-style dated strike/addendum recording the corrected allegiance reset semantics (clears at every generation reset; the HasServerSeed latch only gates pre-seed rendering WITHIN a session) with the three-way evidence citation: the retail OnEndCharacterSession hook, the RuntimeCharacterOptionsState precedent's actual clear-and-relatch behavior, and the no-character-selector connect path (SessionPlayerComposition.cs:1127). - The architecture blurb and FA2's slice-map contract row get matching strike/addendum corrections so the "fellowship session-scoped, allegiance survives reconnect" claim does not survive uncorrected anywhere in the plan. - FA2's ledger row: fix-round commit SHAs, corrected delegate-hole/ wrapper counts (blast SHOULD-FIX 3: 10 not 15, 11 not 12), the allegiance register-row re-evaluation conclusion (blast SHOULD-FIX 6 -- no row needed, MF-1's fix retires the deviation entirely), and the reconciled fix-round test totals (13,201/4/0 -> 13,215/4/0, +14, arithmetic exact per file). Seam map (docs/research/2026-08-11-fa-acdream-seams.md), per the FA1 fix-round's established in-place-correction convention: - SS1.3 and SS9's dispatcher-replaces-not-chains correction is now dated and cites the actual GameEventDispatcher.Dispatch behavior, matching the code comment already landed in GameEventWiring.cs. - SS2.3 gets the 0x01C9/0x01CA disposition it was missing (correctly left unregistered -- dead COMDAT-fold no-ops per FA1) so FA3 does not have to re-derive it or "fix" the gap. - The SS8 seam-map table's Allegiance-owner row and the executive-summary ownership bullet both get the "survives reconnect" claim struck with a dated correction to "session-scoped, clears at every generation reset". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
995 lines
57 KiB
Markdown
995 lines
57 KiB
Markdown
# Campaign FA — lane D: acdream seams for Fellowship & Allegiance
|
||
|
||
**Status:** RESEARCH ONLY (2026-08-11). Read-only audit of our own tree; no
|
||
retail decomp claims here (that is lanes A–C). Every claim is cited
|
||
`file:line` against the worktree
|
||
`.claude/worktrees/eloquent-hugle-42119e`.
|
||
|
||
**Purpose:** so the FA plan places new code correctly the first time.
|
||
|
||
---
|
||
|
||
## 0. Executive orientation — what already exists
|
||
|
||
FA is NOT greenfield. The tree already carries a partial, unwired
|
||
foundation laid in H.2 (2026-04-18) and extended incidentally by
|
||
Campaigns CH and OP:
|
||
|
||
| Already present | Where | Wired to production? |
|
||
|---|---|---|
|
||
| All 11 S→C event ids | `src/AcDream.Core.Net/Messages/GameEventType.cs:16,19,29,30,55,56,57,84,85,100,101,102` | **NO parsers, NO handlers** |
|
||
| Fellowship C→S builders (5) | `src/AcDream.Core.Net/Messages/SocialActions.cs:123-168` | **NO `WorldSession.Send*` wrapper, NO caller** |
|
||
| Allegiance C→S builders (2) | `src/AcDream.Core.Net/Messages/AllegianceRequests.cs:34-46` | **NO `WorldSession.Send*` wrapper, NO caller** |
|
||
| Client allegiance tree model | `src/AcDream.Core/Allegiance/AllegianceTree.cs:56-162` + XP passup `:172-183` | **NO writer; zero references outside its own file** |
|
||
| `RetailLogTextType.Allegiance 0x12` / `.Fellowship 0x13` | `src/AcDream.Core/Chat/RetailLogTextType.cs:44-45` | Colors already conformance-pinned (CH) |
|
||
| `RuntimeChatChannel.Fellowship/.Allegiance/.Vassals/.Patron/.Monarch/.CoVassals` | `src/AcDream.Runtime/GameRuntimeCommands.cs:63-90` | **YES** — outbound chat works today (`DirectGameRuntimeCommandAdapter.cs:1014-1062`) |
|
||
| `InputAction.ToggleAllegiancePanel` (F3) / `.ToggleFellowshipPanel` (F4) | `src/AcDream.UI.Abstractions/Input/InputAction.cs:103-106`, defaults `KeyBindings.cs:209-210`, retail ActionMap ids `RetailActionIdentityTable.cs:193-194` | **NO handler** — `RetailUiRuntime.HandleInputAction` (`RetailUiRuntime.cs:548-560`) has no case for either |
|
||
| 6 dimmed Grouping-group option rows | `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs:193-201` | Store-only (AD-78) |
|
||
| Retail mutual exclusion (MF-2) | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:818-834` | **YES** — already live |
|
||
| Confirmation types 1 and 4 (allegiance/fellowship) placeholder | `src/AcDream.App/UI/GameplayConfirmationController.cs:36-40` | Generic dialog only; explicitly awaiting FA panels |
|
||
|
||
`AllegianceRequests.BuildSwear/BuildBreak` and every
|
||
`SocialActions.BuildFellowship*` have **zero production callers** — a
|
||
whole-tree grep returns only their own definitions and their tests. FA
|
||
must add the `WorldSession.Send*` wrappers (§3) before anything can reach
|
||
the wire.
|
||
|
||
Open issue ledger: `docs/ISSUES.md:12961` (**#L.4**, "Allegiance,
|
||
Fellowship, and World remain separate open panel features") and
|
||
`docs/ISSUES.md:524` (**#360**, the ~22 unported `@allegiance`/`@house`
|
||
subcommands).
|
||
|
||
---
|
||
|
||
## 1. Runtime ownership — where fellowship/allegiance state belongs
|
||
|
||
### 1.1 The J-pattern, as actually implemented
|
||
|
||
`GameRuntime` builds every gameplay owner in one ordered, fault-injectable
|
||
construction transaction (`src/AcDream.Runtime/GameRuntime.cs:156-320`),
|
||
exposes each as a public `*Owner` property (`:419-429`), and exposes a
|
||
**read-only typed borrowed view** per owner (`:463-470`). Convergence is
|
||
proved by a per-owner `CaptureOwnership()` rolled up through
|
||
`RuntimeGameplayOwnership.Capture`
|
||
(`src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs:23-44`) →
|
||
`RuntimeSimulationOwnership.Capture`
|
||
(`src/AcDream.Runtime/RuntimeSimulationOwnership.cs:23-44`) →
|
||
`GameRuntime.CaptureOwnership` (`GameRuntime.cs:545-567`).
|
||
|
||
Every new J-owner therefore costs **eight** mechanical edits:
|
||
|
||
1. a `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.
|
||
|
||
### 1.3 Recommended ownership shape
|
||
|
||
**Two sibling owners under `GameRuntime`, not one, and not a child of
|
||
Communication:**
|
||
|
||
```
|
||
GameRuntime
|
||
├── FellowshipOwner : RuntimeFellowshipState (new)
|
||
│ └── borrows RuntimeEntityObjectLifetime (member guid → ClientObject)
|
||
└── AllegianceOwner : RuntimeAllegianceState (new)
|
||
└── owns the ported AcDream.Core.Allegiance.AllegianceTree
|
||
^^^ [FA1 fix-round addendum 2026-08-12, re-review CF-1:
|
||
AllegianceTree was DELETED at 4281750b (lane C verdict —
|
||
wrong passup transcription, wire-contradicting tree model).
|
||
RuntimeAllegianceState owns the PARSED profile records from
|
||
ClientCommandResponses.ReadAllegianceProfileBody instead —
|
||
the flat vassal list + monarch/patron/self blocks, already
|
||
assembled with retail's four rejection rules. Do not recreate
|
||
a tree type.]
|
||
```
|
||
|
||
Rationale and precedent:
|
||
|
||
- **Separate owners** because the reset semantics differ: fellowship is
|
||
session-scoped (a disconnect drops you from the fellowship server-side —
|
||
clear it at reset, same class as `_inventory.ResetExternalContainer`,
|
||
`RuntimeGenerationReset.cs:248-261`), whereas allegiance survives
|
||
reconnect and is re-seeded from `PlayerDescription`/`AllegianceUpdate`
|
||
(same class as `RuntimeCharacterOptionsState`'s `HasServerSeed` latch,
|
||
`project_settings_options_digest.md` DO-NOT-RETRY row "Applying a
|
||
pre-seed Reset/local snapshot over server truth").
|
||
**[FA2 fix-round addendum, 2026-08-12
|
||
(docs/research/2026-08-12-fa2-review-mechanism.md MUST-FIX 1): this
|
||
citation was INVERTED. `RuntimeCharacterOptionsState`'s `HasServerSeed`
|
||
latch does NOT survive a reset — `ResetSession`
|
||
(`RuntimeCharacterState.cs:1007-1017`) explicitly zeroes the options AND
|
||
clears `_hasServerSeed`, with its own doc comment naming the exact hazard
|
||
this row then walked into: "a stale seed surviving a session boundary
|
||
could let a flush ship the PRIOR character's words over the new one's."
|
||
Retail itself clears allegiance at the same per-character-session
|
||
boundary Fellowship already clears at
|
||
(`ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0` tail-calls
|
||
`AllegianceProfile::Clear`, mirroring
|
||
`ClientFellowshipSystem::OnEndCharacterSession @0x005690A0`). Corrected:
|
||
allegiance is now ALSO a reset stage
|
||
(`RuntimeGenerationResetStage.Allegiance`) with the identical
|
||
clear-and-relatch shape as the cited precedent. The two owners remain
|
||
separate classes because fellowship and allegiance are independent
|
||
retail systems with independent wire families, not because their
|
||
lifetimes differ — they no longer do.]**
|
||
- **Borrow, never copy, the entity table.** Fellowship member rows must
|
||
read live names/vitals from `RuntimeEntityObjectLifetime`'s canonical
|
||
`ClientObjectTable` the way `RuntimeInventoryState` does
|
||
(`GameRuntime.cs:192-193`) — a second copy of member names is exactly the
|
||
"two writable copies" divergence OP9 deleted
|
||
(`project_settings_options_digest.md`, AP-196 row).
|
||
- **Typed borrowed views, not events, for the panels.**
|
||
`IRuntimeSocialView` (`GameRuntimeGameplayViews.cs:105-110`) is the exact
|
||
template: a `Snapshot` record struct plus `TryGet*` accessors, no
|
||
collection allocation. Add `IRuntimeFellowshipView` /
|
||
`IRuntimeAllegianceView` to `GameRuntimeGameplayViews.cs` and hang them
|
||
off `IGameRuntimeView` (`GameRuntimeViews.cs:243-272`) so headless bots
|
||
can assert roster state without touching presentation.
|
||
- **Generation-gated commands.** Add
|
||
`IRuntimeFellowshipCommands` / `IRuntimeAllegianceCommands` to
|
||
`GameRuntimeCommands.cs` alongside `IRuntimeSocialCommands` (`:291-300`),
|
||
register them on `IGameRuntimeCommands` (`:302-325`), and add two
|
||
`RuntimeCommandDomain` ordinals (`GameRuntimeEvents.cs:11-24` — currently
|
||
`Magic = 10`, so `Fellowship = 11`, `Allegiance = 12`). Both host
|
||
adapters must implement: `DirectGameRuntimeCommandAdapter`
|
||
(`src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:20-31`
|
||
interface list, `:718-750` friend-command template) and
|
||
`CurrentGameRuntimeCommandAdapter`
|
||
(`src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs:690,703` —
|
||
it publishes onto the App command bus instead of touching the session).
|
||
- **Ordered deltas only if a bot needs them.** The J-pattern's event hub
|
||
(`GameRuntimeEventHub`, constructed at `GameRuntime.cs:304-307` from
|
||
entity/communication/action owners) feeds `IRuntimeEventObserver`
|
||
(`GameRuntimeEvents.cs:98-115`). A `OnFellowship(in
|
||
RuntimeFellowshipDelta)` member is a **breaking change to every
|
||
implementer** (5 bot policies in
|
||
`src/AcDream.Headless/Policies/HeadlessBotPolicy.cs`, the trace recorder
|
||
at `GameRuntimeEvents.cs:148-311`, App observers). Prefer **not** adding
|
||
an observer member unless the bot gate (§6) genuinely needs push
|
||
semantics; a `Snapshot.Revision` poll off the borrowed view is the
|
||
cheaper contract and matches `IRuntimeSocialView`.
|
||
|
||
### 1.4 The one thing that *should* live in Communication
|
||
|
||
Fellowship/allegiance **system message text** (recruits, quits, logon
|
||
notices) is interface text, not fellowship state — it goes through
|
||
`RuntimeCommunicationState.AddText`
|
||
(`RuntimeCommunicationState.cs:210-239`). See §7.
|
||
|
||
---
|
||
|
||
## 2. Inbound wire routing — the pattern a new event family must follow
|
||
|
||
### 2.1 There is exactly ONE registration site, and it is in Runtime
|
||
|
||
`GameEventWiring.WireAll`
|
||
(`src/AcDream.Core.Net/GameEventWiring.cs:36-99`) is called from exactly
|
||
one place in `src/`: `LiveSessionEventRouter.Attach`
|
||
(`src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:179-231`). A
|
||
whole-`src` grep for `WireAll` returns those two hits only. **This is the
|
||
K-slice unification: both hosts share the router.** So a new event family
|
||
is wired once and both hosts get it — but only if the *sink* is reachable
|
||
from both, which is why the sink must be a Runtime owner (§1) and not an
|
||
App object.
|
||
|
||
### 2.2 The mechanical shape
|
||
|
||
`GameEventWiring` is a static method taking one positional core
|
||
(`dispatcher, items, combat, spellbook, chat`) plus ~20 optional
|
||
delegate/state "holes" (`GameEventWiring.cs:41-99`). Core.Net **cannot
|
||
reference `AcDream.Runtime`** — the CH2 comment at
|
||
`GameEventWiring.cs:90-98` states this explicitly ("Core.Net cannot
|
||
reference AcDream.Runtime directly, so this is a delegate hole exactly
|
||
like every other Runtime-owned sink above"). Two legal shapes:
|
||
|
||
- **State object** (like `friends`, `squelch`, `itemMana`, `vendor`,
|
||
`externalContainers`): the type lives in `AcDream.Core.*`, Runtime owns
|
||
the instance, Core.Net writes into it. `FriendsState`/`SquelchState` live
|
||
in `AcDream.Core.Social`; ~~`AllegianceTree` already lives in
|
||
`AcDream.Core.Allegiance` — so the allegiance tree can be passed
|
||
directly as a state parameter, exactly like `friends`~~ [FA1 fix-round
|
||
addendum 2026-08-12, re-review CF-1: `AllegianceTree` was DELETED at
|
||
`4281750b`. The pattern itself stands — the PARSED allegiance profile
|
||
records (from `ReadAllegianceProfileBody`, in `AcDream.Core.Net`) are
|
||
what gets passed/held; FA2 defines the owned snapshot type in
|
||
`AcDream.Core.*` if a Core-side holder is needed, or holds the parsed
|
||
records directly in the Runtime owner].
|
||
- **Delegate hole** (like `onCharacterOptions`, `onInterfaceText`,
|
||
`onUseDone`): a `Action<...>` the router closes over.
|
||
|
||
Registration itself: `registrar.Register(GameEventType.X, e => {...})`
|
||
against the owned registrar (`GameEventWiring.cs:923-943`), which wraps
|
||
every handler in the `accepting` gate so a retiring generation cannot
|
||
receive. Do not register outside `WireAll` — the `RegistrationBuildScope`
|
||
(`:945-961`) guarantees all-or-nothing ownership.
|
||
|
||
### 2.3 The 11 events FA must register
|
||
|
||
From `GameEventType.cs`:
|
||
|
||
| Id | Name | Line |
|
||
|---|---|---|
|
||
| `0x0003` | `AllegianceUpdateAborted` | `:16` |
|
||
| `0x0020` | `AllegianceUpdate` | `:19` |
|
||
| `0x00A3` | `FellowshipQuit` | `:29` |
|
||
| `0x00A4` | `FellowshipDismiss` | `:30` |
|
||
| `0x01C8` | `AllegianceUpdateDone` | `:55` |
|
||
| `0x01C9` | `FellowshipFellowUpdateDone` | `:56` |
|
||
| `0x01CA` | `FellowshipFellowStatsDone` | `:57` |
|
||
| `0x027A` | `AllegianceLoginNotification` | `:84` |
|
||
| `0x027C` | `AllegianceInfoResponse` | `:85` — **already registered** (`GameEventWiring.cs:192-198`, TS-70 fix) |
|
||
| `0x02BE` | `FellowshipFullUpdate` | `:100` |
|
||
| `0x02BF` | `FellowshipDisband` | `:101` |
|
||
| `0x02C0` | `FellowshipUpdateFellow` | `:102` |
|
||
|
||
Note `0x027C` is already consumed for `@allegiance info` chat output
|
||
(`GameEventWiring.cs:192-198` → `ClientCommandResponses.ParseAllegianceInfoResponse`).
|
||
FA must **not** register a second handler for it; either extend the
|
||
existing lambda or add a second `registrar.Register` for the same type
|
||
(the dispatcher supports multiple owned handlers per type — see
|
||
`OwnedGameEventRegistrar.Register`, `:929-940`) and accept that both fire.
|
||
Decide this in the plan, explicitly.
|
||
|
||
**[FA2 fix-round addendum, 2026-08-12
|
||
(docs/research/2026-08-12-fa2-review-blast.md SHOULD-FIX 1): the
|
||
parenthetical above is WRONG and was disproved during FA2 implementation.
|
||
`GameEventDispatcher.Dispatch`
|
||
(`src/AcDream.Core.Net/Messages/GameEventDispatcher.cs:95-117`) does a
|
||
single `_handlers.TryGetValue(...)` and invokes the ONE stored
|
||
`RegistrationNode`; `OwnedGameEventRegistrar.Register` REPLACES the prior
|
||
registration for that `GameEventType`, it does not add a second co-owned
|
||
handler. A second `registrar.Register(GameEventType.AllegianceInfoResponse,
|
||
...)` call would have silently killed the already-live `@allegiance info`
|
||
chat-text output the instant a caller supplied an allegiance-owner
|
||
callback. FA2 folded both behaviors into the ONE existing registration
|
||
instead (`GameEventWiring.cs`'s `AllegianceInfoResponse` handler) — see
|
||
that method's own code comment for the corrected claim. This was also the
|
||
right call independent of the correction: FA2's fix round (MUST-FIX 2)
|
||
subsequently found that `0x027C` should not feed a Runtime callback at
|
||
all, since retail's own handler for it is print-only over a stack-local
|
||
profile — so the "second handler" question this section poses is now moot
|
||
either way.]**
|
||
|
||
`0x01C9 FellowshipFellowUpdateDone` and `0x01CA FellowshipFellowStatsDone`
|
||
from the table above are **[FA2 fix-round addendum, 2026-08-12
|
||
(docs/research/2026-08-12-fa2-review-blast.md SHOULD-FIX 2): correctly
|
||
left unregistered by FA2.** FA1 established that both COMDAT-fold onto the
|
||
identical no-op body in the Sept-2013 client and are parse-and-ignore only
|
||
(`src/AcDream.Core.Net/Messages/GameEvents.cs:779-803`) — there is nothing
|
||
for a Runtime owner to consume. This is the disposition for the two rows
|
||
this section's own title ("The 11 events FA must register") implied FA
|
||
owed a registration for; FA2 registered the other 9 plus the `0x027C`
|
||
fold, 10 total.]**
|
||
|
||
### 2.4 Bindings-record plumbing (both hosts)
|
||
|
||
`LiveSessionEventRouter`'s five binding records are the host-facing
|
||
contract (`LiveSessionEventRouter.cs:15-82`). FA adds either a field to
|
||
`LiveSocialSessionBindings` (`:72-82`) or a **new**
|
||
`LiveFellowshipSessionBindings`/`LiveAllegianceSessionBindings` record.
|
||
Prefer extending `LiveSocialSessionBindings` with **trailing optional
|
||
parameters** — the established compatibility convention, stated at
|
||
`:42-44` ("Trailing/optional so every existing positional caller
|
||
(Headless) compiles unchanged").
|
||
|
||
Both construction sites must be updated (**blast-radius rule: the no-window
|
||
host is a first-class consumer**):
|
||
|
||
- Graphical: `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:248-261`
|
||
(`new LiveSocialSessionBindings(_domain.Communication.Chat, ...)`).
|
||
- Headless: `src/AcDream.Headless/Hosting/HeadlessSessionHost.cs:780-785`
|
||
(`new LiveSocialSessionBindings(Runtime.CommunicationOwner.Chat, ...)`).
|
||
|
||
Each host then wraps the router: `GraphicalSessionEventRoute`
|
||
(`src/AcDream.App/Net/GraphicalSessionEventRoute.cs`) and
|
||
`HeadlessSessionEventRoute`
|
||
(`src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs:14-53`). Both
|
||
delegate `Attach`/`Dispose` to the shared router, so **no FA change is
|
||
needed in the wrappers** provided the sink is Runtime-owned.
|
||
|
||
### 2.5 Parsers
|
||
|
||
New parsers go in `src/AcDream.Core.Net/Messages/GameEvents.cs` (609
|
||
lines, static `Parse*` returning nullable structs — the file currently has
|
||
zero fellowship parsers; the only `Allegiance` token is
|
||
`AllegianceMonarchId` at `:589`, a `PlayerDescription` field). Follow the
|
||
`ParseChannelBroadcast` / `ParseTell` shape: `ReadOnlySpan<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-104` covers all five fellowship builders;
|
||
`AllegianceRequestsTests.cs:10-34` covers swear/break). Cheap, and what
|
||
the existing FA builders have.
|
||
- **Hand-computed golden byte vector** — the OP1 convention, and the
|
||
stronger one. `SocialActionsTests.cs:136-142` states the rule verbatim:
|
||
*"The golden vector below is HAND-COMPUTED, field by field, from that
|
||
layout — not generated by calling the builder under test. The CH3 builder
|
||
(deleted 2026-08-09) died of ten green tests pinning a wrong shape."*
|
||
Example at `:144-175`. **Any FA builder whose layout lane A/B/C had to
|
||
derive (rather than copy from an existing green test) must get a
|
||
hand-computed golden vector.** `AceWireWriter.cs` in the same directory
|
||
is the test-only ACE-mirror writer for inbound goldens.
|
||
|
||
`git`-visible caution: `BuildFellowshipCreate`'s padding math
|
||
(`SocialActions.cs:128-137`) computes `pad` but never uses it in the
|
||
`body` length beyond `new byte[12 + name.Length + boolBlock + pad]` — the
|
||
existing test (`SocialActionsTests.cs:53-68`) asserts bool offsets 20/21
|
||
for a 4-char name, i.e. it pins the current shape but **not against a
|
||
retail/ACE reference**. Lane A/B should confirm the trailing-pad rule
|
||
before FA relies on it.
|
||
|
||
---
|
||
|
||
## 4. The AD-78-dimmed rows that gain consumers
|
||
|
||
### 4.1 Exact inventory — the six Grouping-group rows
|
||
|
||
All six live in `CharacterOptionsPageController.Groups[2]`
|
||
(`src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs:193-201`),
|
||
header `ID_CharacterOption_Grouping_Section`, and all six are `StoreOnly`:
|
||
|
||
| `CharacterOptionId` | Value | Word/mask | AutoSave | ClientDefault | Controller line | Table line |
|
||
|---|---|---|---|---|---|---|
|
||
| `IgnoreAllegianceRequests` | `0x01` | Options1 `0x00000004` | yes | false | `:195` | `CharacterOptionTable.cs:112` |
|
||
| `IgnoreFellowshipRequests` | `0x02` | Options1 `0x00000008` | yes | **true** | `:196` | `:113` |
|
||
| `DisplayAllegianceLogonNotifications` | `0x18` | Options1 `0x08000000` | no | false | `:197` | `:135` |
|
||
| `FellowshipShareXP` | `0x0F` | Options1 `0x00040000` | yes | **true** | `:198` | `:126` |
|
||
| `FellowshipShareLoot` | `0x11` | Options1 `0x00100000` | yes | false | `:199` | `:128` |
|
||
| `FellowshipAutoAcceptRequests` | `0x12` | Options1 `0x20000000` | yes | false | `:200` | `:129` |
|
||
|
||
A seventh row is FA-adjacent but Chat-group:
|
||
`ListenToAllegianceChat` (`0x1B`, Options1 `0x40000000`) at
|
||
`CharacterOptionsPageController.cs:229` — **already `Live`**, consumed by
|
||
`TurbineChatMembershipGate.cs:107-110`. Do not touch it.
|
||
|
||
### 4.2 The tests that pin the dimmed list — un-dim goes through these
|
||
|
||
`tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs`:
|
||
|
||
- `ExpectedStoreOnlyIds` literal set — the 35-id hand-transcribed
|
||
authority (`:710-753`). The six FA ids are at `:728-733` under the
|
||
comment `// Group 3 (Grouping) — all 6`.
|
||
- **`StoreOnlyRows_MatchTheDerivationTableExactly`** (`:755-766`) —
|
||
asserts set equality with `Groups`, plus `Assert.Equal(35, ...)` and
|
||
`Assert.Equal(15, 50 - ...)`. **Un-dimming N rows requires editing three
|
||
numbers here**: remove from the literal set, and change `35`→`35-N` and
|
||
`15`→`15+N`.
|
||
- **`Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows`**
|
||
(`:768-819`) — builds the real fixture tree and compares each checkbox's
|
||
`LabelColor` against `UiRenderContext.StoreOnlyCaptionColor` vs
|
||
`Vector4.One`, row-index-aligned. Derived from `Groups`, so it follows
|
||
automatically once `Groups` flips.
|
||
|
||
The class doc states the contract explicitly at `:700-709`: *"Wiring a
|
||
future consumer for any of these means removing it from this literal set
|
||
AND flipping its `Groups` table entry from `StoreOnly` to `Live`
|
||
consciously — leaving either one stale fails this test."*
|
||
|
||
The dim mechanism itself is `CharacterOptionsPageController.cs:418-423`
|
||
(`checkbox.LabelColor = spec.StoreOnly ? UiRenderContext.StoreOnlyCaptionColor
|
||
: Vector4.One`). Rows stay fully interactive either way — only the caption
|
||
dims.
|
||
|
||
### 4.3 Which rows *should* un-dim, and which should not
|
||
|
||
The plan must decide per-row, but the code evidence says:
|
||
|
||
- `IgnoreFellowshipRequests` / `FellowshipAutoAcceptRequests` — **already
|
||
have a genuine coded client-side consumer**: retail's `OnChanged` mutual
|
||
exclusion (MF-2), ported at
|
||
`src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:818-834` with the
|
||
full rationale in the `<remarks>` at `:790-805`. The OP4 author flagged
|
||
this as a known ambiguity and deliberately left them dimmed
|
||
(`CharacterOptionsPageController.cs:147-154`: *"have a genuine coded
|
||
mutual-exclusion side effect … kept dimmed per the doc's explicit Group A
|
||
listing … a future un-dim is a one-line judgment call, not a
|
||
re-investigation."*). **FA gives them a real second consumer** (the
|
||
incoming-fellowship-request path), so these two are the clearest un-dim
|
||
candidates.
|
||
- `DisplayAllegianceLogonNotifications` — un-dims only if FA implements the
|
||
`AllegianceLoginNotification 0x027A` handler AND gates its chat output on
|
||
the bit. That gate is the consumer.
|
||
- `FellowshipShareXP` / `FellowshipShareLoot` — these are **server-side**
|
||
policy (ACE decides the split). They un-dim only if FA reads them to
|
||
drive the create-fellowship dialog's initial checkbox state
|
||
(~~`BuildFellowshipCreate(seq, name, openness, shareXp)`,
|
||
`SocialActions.cs:123-138`~~ **[FA1 fix-round addendum, 2026-08-12: FA1
|
||
repaired this builder — there is no "openness" field on
|
||
`FellowshipCreateOpcode`'s wire (the prior 4-arg builder invented one and
|
||
silently sent it as the low byte of shareXP, which ACE would read as an
|
||
INVERTED shareXP value). The current signature is
|
||
`BuildFellowshipCreate(seq, fellowshipName, shareXp)`,
|
||
`SocialActions.cs:144-155`.]**, takes `shareXp` as a parameter — that IS a
|
||
client-side read). `FellowshipShareXP` is additionally tagged Group D
|
||
(deferred) in the derivation table (`:137`).
|
||
- `IgnoreAllegianceRequests` — un-dims if FA gates the incoming-swear
|
||
confirmation on it.
|
||
|
||
**MF-2 is binding on any un-dim.** `HeadlessConfigurationLoader.cs:252-274`
|
||
already rejects a bot config declaring both `IgnoreFellowshipRequests` and
|
||
`FellowshipAutoAcceptRequests` true, because retail's recursive clear makes
|
||
that unsatisfiable. Any FA UI that surfaces both toggles inherits the same
|
||
constraint — the panel must reflect the auto-clear, since `TrySetOption`
|
||
performs it locally before the send (`RuntimeCharacterState.cs:812-841`).
|
||
|
||
---
|
||
|
||
## 5. Panel mounting, dialogs, strings
|
||
|
||
### 5.1 Cataloging a new `gmPanelUI` panel
|
||
|
||
`src/AcDream.App/UI/RetailPanelCatalog.cs` holds the DAT property
|
||
`0x10000029` panel-id constants (`:10-18`) plus two tables: `Mounted`
|
||
(`:31-43`) and `Toolbar` (`:45-51`). FA adds `Allegiance` and `Fellowship`
|
||
constants **byte-verified from the authored toolbar/panel property**, the
|
||
way OP3 verified `Options = 10u` (`:20-29` documents that exact method:
|
||
read property `0x10000029` off the toolbar button in the committed fixture
|
||
AND cross-check the decompiled slot-key table). Do not guess the ids.
|
||
|
||
Window names go in `src/AcDream.App/UI/WindowNames.cs:5-32` (one `const
|
||
string` each) — the file's own doc says it exists "so the mount, the window
|
||
registry, and the toggle keybind all agree on one literal."
|
||
|
||
### 5.2 Mount method template
|
||
|
||
`RetailUiRuntime.MountCharacter`
|
||
(`src/AcDream.App/UI/RetailUiRuntime.cs:2663-2706`) is the cleanest
|
||
template — 44 lines, five steps:
|
||
|
||
1. `Import(0x2100002Eu)` → `ImportedLayout?`, log-and-return on null
|
||
(`:2665-2670`);
|
||
2. build/subscribe the data provider (`:2671-2674`);
|
||
3. `<X>Controller.Bind(layout, ...)` (`:2675-2682`);
|
||
4. `RetailWindowFrame.Mount(Host.Root, layout.Root, resolveSprite, new
|
||
RetailWindowFrame.Options { WindowName = ..., Chrome = NineSlice, ...,
|
||
Visible = false, ... })` → `RetailWindowHandle` (`:2683-2700`);
|
||
5. `_panelUi.RegisterMainPanel(RetailPanelCatalog.X, WindowNames.X,
|
||
handle)` (`:2701-2704`).
|
||
|
||
Then add the `Mount<X>()` call to `Initialize()`
|
||
(`RetailUiRuntime.cs:385-413` — the ordered list; note `MountDialogFactory`
|
||
at `:404` runs **before** `MountCharacter` at `:405`, and
|
||
`MountCharacter`'s own `HandleCharacterRaise` throws if that ordering is
|
||
violated, `:2720-2723`). Any FA panel that raises a confirmation must be
|
||
mounted after `MountDialogFactory`.
|
||
|
||
`RegisterMainPanel` (`src/AcDream.App/UI/Layout/RetailPanelUiController.cs:47-65`)
|
||
enforces name==handle.Name and shares one parent geometry across all
|
||
registered main panels — that is retail's `gmPanelUI` single-active-child
|
||
behavior, so FA panels get "opening Fellowship closes Allegiance" for free.
|
||
|
||
`RetailWindowManager.Register`
|
||
(`src/AcDream.App/UI/RetailWindowManager.cs:57-105`) is called by
|
||
`RetailWindowFrame.Mount` — FA does not call it directly. It requires the
|
||
outer frame be a **direct `UiRoot` child** (`:70-72`) and fires
|
||
`WindowRegistered` (`:44`) so the opacity controller picks the window up
|
||
automatically.
|
||
|
||
Bindings: add a `FellowshipRuntimeBindings` / `AllegianceRuntimeBindings`
|
||
group to the `RetailUiRuntimeBindings` record
|
||
(`RetailUiRuntime.cs:300-325`) — one record per subsystem is the
|
||
established grouping.
|
||
|
||
Keybinds: wire `InputAction.ToggleAllegiancePanel` /
|
||
`.ToggleFellowshipPanel` into `RetailUiRuntime.HandleInputAction`
|
||
(`:548-560` — the `ToggleSpellbookPanel` case at `:552-556` is the
|
||
one-liner template). The enum members, retail ActionMap ids, and F3/F4
|
||
defaults already exist (§0) — **only the handler is missing.**
|
||
|
||
Persistence: if the panel should keep its own visibility across sessions,
|
||
it must NOT be listed in `stateManagedVisibilityWindows`
|
||
(`RetailUiRuntime.cs:422-428`).
|
||
|
||
### 5.3 The confirmation seam — recruit / swear
|
||
|
||
`RetailDialogFactory`
|
||
(`src/AcDream.App/UI/Layout/RetailDialogFactory.cs:8-38`) is the port of
|
||
retail's `DialogFactory @0x004773C0`. Two entry points:
|
||
|
||
- `MakeConfirmation(message, callback, queueKey, priority)`
|
||
(`:125-136`) — the simple seam.
|
||
- `MakeDialog(RetailDialogData, callback)` (`:74-123`) — full control over
|
||
queue key, priority preemption, and `RetailDialogProperty` flags.
|
||
|
||
**Server-driven** confirmations (which is what an incoming
|
||
recruit/swear request is) already have a semantic owner:
|
||
`GameplayConfirmationController`
|
||
(`src/AcDream.App/UI/GameplayConfirmationController.cs:12-102`). Its
|
||
`HandleRequest` comment names FA explicitly at `:36-40`:
|
||
|
||
> *"Types 1 and 4 have allegiance/fellowship semantic owners but use the
|
||
> same response tuple, so this controller retains that tuple until those
|
||
> panels exist."*
|
||
|
||
So the FA plan should decide whether types 1/4 stay on the generic
|
||
controller (retaining the `(serverType, serverContext)` tuple, response
|
||
sent at `:87-101`) or gain their own owners. Note the single-slot guard at
|
||
`:44-47`: a second gameplay confirmation is **refused** while one is open —
|
||
mirror retail. Also mirror the DO-NOT-RETRY row from
|
||
`project_chat_digest.md`: `OpenNextDialog` must return early when the queue
|
||
key is already occupied (`RetailDialogFactory.cs:323-334`), because a
|
||
two-stage confirmation (the FA recruit-then-confirm flow is a candidate)
|
||
can synchronously reopen under the same key.
|
||
|
||
### 5.4 String resolution
|
||
|
||
`DatStringResolver`
|
||
(`src/AcDream.App/UI/Layout/DatStringResolver.cs:16-83`). Two rules:
|
||
|
||
- `ComputeHash(string)` (`:68-82`) is the exact retail ELF-style hash from
|
||
`compute_str_hash @0x00413110`. Labels are resolved as
|
||
`resolveString(tableId, ComputeHash("ID_..."))` — never hard-coded
|
||
English. Template: `CharacterOptionsPageController.cs:409-416`, which on
|
||
a failed lookup logs and leaves the caption **null**, explicitly "rather
|
||
than invented English."
|
||
- Table ids are family-specific. `0x23000003` is the options family
|
||
(`CharacterOptionsPageController.cs:84`); `0x2300000D` is the chat
|
||
text-filter family; `0x23000004` is keyboard refusal
|
||
(`project_settings_options_digest.md` DO-NOT-RETRY row). **FA must
|
||
dat-verify which table holds `ID_Fellowship_*` / `ID_Allegiance_*` before
|
||
assuming `0x23000003`.**
|
||
- Resolution is **per-`Build`, not per-import**: the resolver is passed
|
||
into `LayoutImporter.Build` as its fifth argument
|
||
(`RetailUiRuntime.cs:2041-2046`) AND separately into each page/row
|
||
controller (`:2100`). Omitting it from either produces silent blanks —
|
||
that was half of #375.
|
||
|
||
### 5.5 DO-NOT-RETRY rows transcribed from `project_settings_options_digest.md`
|
||
|
||
These are the mount-a-new-panel traps Campaign OP paid for. All apply
|
||
verbatim to an FA panel:
|
||
|
||
| Trap | Truth |
|
||
|---|---|
|
||
| Lazily creating a fill-anchored child at 0×0 | **#372.** `ComputeAnchoredRect` captures the degenerate baseline **permanently** → whole-page blank. Seed the viewport size to the parent extent at creation (`UiTemplateListBox`). *Fixture conformance tests stayed GREEN through this* — only mount+activate+switch+assert-drawn tests catch the class. |
|
||
| Assuming an imported tab host is active | **#375 residual.** A tab control must be explicitly activated (`controller.ActivateTabs()`), and the FA panel's own tab control if it has one. Commit `a8ce010d`. |
|
||
| Parked template prototypes / missing string resolver | **#375.** A template list whose prototypes were never parented, plus a `Build` call missing its string resolver, both render as blank rows. Commit `8bd7e3b8`. |
|
||
| Flat `layout.FindElement` for shared element ids | Apply/Reset/Defaults share element ids **across pages**. Always per-page scoped `FindDescendant`, never a flat layout lookup. (`CharacterOptionsPageControllerTests.cs:389-394` documents the collision.) |
|
||
| Open popups losing pointer routing | **#374.** An open dropdown/popup gets **first claim** on pointer routing in `UiRoot`. Commit `355c86a6`. |
|
||
| Rows straddling the viewport edge vanishing | **#371.** A viewport must `ClipsChildren` + intersection-cull, not whole-row-cull. Commit `a59e077a`. |
|
||
| Trusting a BN literal-0 operand for a string id / global | Bit Campaign OP **four times**. Byte-verify from the PDB-paired `acclient.exe` (`check_exe_pdb.py` MATCH) before believing a 0. |
|
||
| Re-adding a client copy of a server bit | AP-196 is closed. Server bits live ONLY in `RuntimeCharacterOptionsState`; client-only prefs in the Config-tab store. |
|
||
|
||
---
|
||
|
||
## 6. Headless bot seam for counterpart characters
|
||
|
||
### 6.1 What K2 gives you today
|
||
|
||
`IHeadlessBotPolicy`
|
||
(`src/AcDream.Headless/Policies/HeadlessBotPolicy.cs:7-12`):
|
||
|
||
```csharp
|
||
internal interface IHeadlessBotPolicy : IRuntimeEventObserver, IDisposable
|
||
{
|
||
bool IsComplete { get; }
|
||
void Tick(IGameRuntimeView view, IGameRuntimeCommands commands);
|
||
}
|
||
```
|
||
|
||
Policies are string-selected by config id in
|
||
`HeadlessBotPolicyFactory.Create` (`:16-27`); five exist today: `idle`,
|
||
`lifecycle-smoke`, `observer-movement`, `portal-route-smoke`,
|
||
`jump-probe`. `LifecycleSmokeHeadlessBotPolicy.Tick` (`:92-142`) is the
|
||
canonical stage-machine template — every command call is
|
||
generation-gated (`commands.Chat.Execute(view.Generation, ...)`, `:103`)
|
||
and its result checked via `Require(...)`.
|
||
|
||
Config shape: `HeadlessSessionDescriptor`
|
||
(`src/AcDream.Headless/Configuration/HeadlessConfiguration.cs:38-75`) —
|
||
`id`, `endpoint`, `account`, `character`, `policy`, `credential`, optional
|
||
`characterOptions`. Note the MF-1 comment at `:32-37`: it is a **record**
|
||
so `with` expressions in the direct-CLI path cannot silently drop a
|
||
property. Multiple sessions in one process is already the shape
|
||
(`Sessions` list, `:13`) and K3/K4 gated 1/5/10/30-session isolation.
|
||
|
||
Declared-option validation: `AllowedCharacterOptions`
|
||
(`HeadlessConfigurationLoader.cs:27-57`) — **all six FA option ids are
|
||
already tier-1 allow-listed** (`:30,31,34,36,37,38`), plus
|
||
`ListenToAllegianceChat` (`:46`). The MF-2 contradiction check is at
|
||
`:252-274`.
|
||
|
||
Option seeding engine: `HeadlessCharacterOptionsSeeder`
|
||
(`src/AcDream.Headless/Hosting/HeadlessCharacterOptionsSeeder.cs:58-136`)
|
||
— diff-and-send through the SAME `IRuntimeCharacterCommands` seam, gated
|
||
on a two-precondition latch (`LoginComplete` sent AND `HasServerSeed`).
|
||
|
||
Existing bot-vs-ACE gate recipe: `docs/research/2026-08-11-campaign-op-test-script.md:766-895`
|
||
(§OP7). Its structure — declare a config, run `acdream-headless run
|
||
--config X.json`, inspect ACE-side persisted state, then re-run for
|
||
idempotence — is exactly reusable for FA.
|
||
|
||
### 6.2 What a bot-recruits-bot fellowship gate needs added
|
||
|
||
**Config / infrastructure:**
|
||
|
||
1. **A second ACE account.** The OP7 recipe uses one session
|
||
(`testaccount` / `+Acdream`). A fellowship gate needs two logged-in
|
||
characters simultaneously. K3 already gated multi-session isolation, and
|
||
`HeadlessConfiguration.Sessions` is a list — but the **credential**
|
||
model is per-session (`HeadlessCredentialReference`, `:107-114`), so two
|
||
distinct env-var references (e.g. `FA_BOT_A_PASSWORD`,
|
||
`FA_BOT_B_PASSWORD`) suffice **if** ACE has a second account with a
|
||
second character. *This is the one genuinely external prerequisite —
|
||
flag it for the user.* CLAUDE.md documents only `testaccount` /
|
||
`+Acdream`.
|
||
2. **The two bots must be co-located.** Recruit requires the target be
|
||
within range and selectable. Either both characters start at the same
|
||
spawn, or the policy drives one to the other (the
|
||
`ObserverMovementHeadlessBotPolicy` / `PortalRouteSmokeHeadlessBotPolicy`
|
||
movement machinery already exists, `HeadlessBotPolicy.cs:204-306`,
|
||
`:353-593`).
|
||
3. **A new policy id**, e.g. `fellowship-smoke`, registered in
|
||
`HeadlessBotPolicyFactory.Create` (`:16-27`), with a role discriminator
|
||
(leader vs recruit). The policy interface has no per-session parameter
|
||
today beyond the id string — either encode the role in the id
|
||
(`fellowship-smoke-leader` / `fellowship-smoke-member`) or extend
|
||
`HeadlessBotPolicyDescriptor` (`HeadlessConfiguration.cs:93-97`, which
|
||
currently carries only `Id`).
|
||
|
||
**Runtime commands the policy needs (new, §1.3/§3.2):**
|
||
|
||
| Command | Underlying builder |
|
||
|---|---|
|
||
| `commands.Fellowship.Create(gen, name, open, shareXp)` | `SocialActions.BuildFellowshipCreate` **[FA1 fix-round addendum, 2026-08-12: the builder is now 3-arg — `BuildFellowshipCreate(seq, fellowshipName, shareXp)` (`SocialActions.cs:144-155`). There is no wire "openness" field; drop `open` from this command's own signature too.]** |
|
||
| `commands.Fellowship.Recruit(gen, targetGuid)` | `BuildFellowshipRecruit` |
|
||
| `commands.Fellowship.Quit(gen, disband)` | `BuildFellowshipQuit` |
|
||
| `commands.Fellowship.Dismiss(gen, targetGuid)` | `BuildFellowshipDismiss` |
|
||
| `commands.Fellowship.SetOpen(gen, open)` | ~~`BuildFellowshipUpdate`~~ **[FA1 fix-round addendum, 2026-08-12: `BuildFellowshipUpdate` no longer exists. Its renamed successor `BuildFellowshipUpdateRequest` (`0x00A6`) is PANEL VISIBILITY, not openness — wiring `SetOpen` to it would re-introduce the exact semantic bug FA1 fixed. The real openness action is `BuildFellowshipChangeOpenness` (`0x0291`, `SocialActions.cs:222-230`).]** |
|
||
| `commands.Allegiance.Swear(gen, patronGuid)` | `AllegianceRequests.BuildSwear` |
|
||
| `commands.Allegiance.Break(gen, targetGuid)` | `BuildBreak` |
|
||
|
||
Plus target acquisition: the recruit target is another player's server
|
||
guid. `RuntimeHostileTargetQuery.FindClosest`
|
||
(`src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs`, used at
|
||
`HeadlessGameplayOperations.cs:143`) finds hostiles only — FA needs a
|
||
**friendly/player** nearest-object query, or the policy reads
|
||
`view.InventoryState`/entity deltas for a known character name. The bot
|
||
already receives `OnEntity` deltas
|
||
(`GameRuntimeEvents.cs:104`), so name-matching off `RuntimeEntitySnapshot`
|
||
is the low-friction route.
|
||
|
||
**Views the policy needs to assert on:**
|
||
|
||
- `view.Fellowship.Snapshot` — at minimum `{ Revision, IsInFellowship,
|
||
Name, MemberCount, LeaderGuid, IsOpen }`, plus
|
||
`TryGetMember(guid, out RuntimeFellowMemberSnapshot)`. Modeled on
|
||
`IRuntimeSocialView` (`GameRuntimeGameplayViews.cs:105-110`).
|
||
- `view.Allegiance.Snapshot` — `{ Revision, MonarchGuid, PatronGuid,
|
||
VassalCount, Rank }`, plus `TryGetNode`.
|
||
|
||
**Gate assertions (what "passed" means):**
|
||
|
||
1. Bot A creates a fellowship → A's `Fellowship.Snapshot.IsInFellowship`
|
||
true, `MemberCount == 1`.
|
||
2. A recruits B → B's own snapshot flips (proving the `0x02BE`
|
||
`FellowshipFullUpdate` inbound path reached B's Runtime owner, not just
|
||
A's local echo). **This is the assertion that only a two-bot gate can
|
||
make.**
|
||
3. `FellowshipAutoAcceptRequests` declared true on B via
|
||
`characterOptions` → recruit succeeds without a confirmation; declared
|
||
false → ACE sends `CharacterConfirmationRequest` type 4 and the bot must
|
||
answer it (needs a bot-visible confirmation command, or the policy
|
||
declares auto-accept to avoid it in v1 — **recommend v1 declares
|
||
auto-accept**).
|
||
4. `IgnoreFellowshipRequests` true on B → recruit refused; asserts the
|
||
server honors the bit.
|
||
5. B quits → A's `MemberCount` drops (proving `0x00A3`/`0x02C0` inbound).
|
||
6. A disbands → both snapshots clear (proving `0x02BF`).
|
||
7. Graceful SIGINT → both Runtime roots converge, FA owners' ownership
|
||
snapshots `IsConverged` (the K4 terminal-ownership check,
|
||
`src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs`).
|
||
|
||
Allegiance's swear/break is harder to gate: ACE enforces rank/vassal-cap
|
||
rules and swearing is semi-permanent on a shared test character. Recommend
|
||
the FA plan gate **fellowship with two bots** and gate **allegiance
|
||
manually via the graphical client** against a throwaway pair, rather than
|
||
burning `+Acdream`'s allegiance state.
|
||
|
||
---
|
||
|
||
## 7. Chat / interface-text seam
|
||
|
||
### 7.1 The chokepoint
|
||
|
||
`RuntimeCommunicationState.AddText(string text, RetailLogTextType type,
|
||
uint windowId = 0)`
|
||
(`src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs:210-239`) is
|
||
the one seam, the port of `ClientSystem::AddTextToScroll @0x00563C50`.
|
||
Behavior: `Trim()` both ends (`:227`, and the comment at `:214-226`
|
||
explains why there is deliberately **no** empty-string guard);
|
||
`ClientLocal 0x1A` → SpewBox only (`:229-233`); everything else →
|
||
`Chat.OnSystemMessage(text, (uint)type)` (`:238`).
|
||
|
||
Reached from Core.Net through the `onInterfaceText` delegate hole
|
||
(`GameEventWiring.cs:90-98`), supplied by
|
||
`LiveSocialSessionBindings.AddText`
|
||
(`LiveSessionEventRouter.cs:77-82`) — bound in **both** hosts
|
||
(`LiveSessionRuntimeFactory.cs:261`, `HeadlessSessionHost.cs:785`).
|
||
|
||
### 7.2 Which `RetailLogTextType` FA messages use
|
||
|
||
`src/AcDream.Core/Chat/RetailLogTextType.cs`:
|
||
|
||
- `Allegiance = 0x12` (`:44`)
|
||
- `Fellowship = 0x13` (`:45`)
|
||
|
||
Both already have retail-exact RGBA in `RetailChatColorTable`
|
||
(`AcDream.UI.Abstractions`), conformance-pinned to
|
||
`ChatInterface::BuildChatColorLookupTable @0x4f31c0`
|
||
(`project_chat_digest.md`, "Current truth"). **No color work needed —
|
||
just pass the right type.**
|
||
|
||
Rules carried from the chat digest:
|
||
|
||
- **Do not add chat-color configurability.** Retail hard-codes every
|
||
`LogTextType`; only per-window type *filters* are user-settable.
|
||
- **`ClientLocal 0x1A` is SpewBox-only** and must never reach the
|
||
scrolling chat window. Local FA refusals ("You are ignoring fellowship
|
||
requests.") are the `0x1A` class; server-sent roster notices are
|
||
`0x13`/`0x12`.
|
||
- **Refusal text comes from `WeenieErrorMessages`** (344 rows, binary-swept
|
||
— `src/AcDream.Core/Chat/WeenieErrorMessages.cs`), not from invented
|
||
English. FA refusals arriving as `WeenieError`/`WeenieErrorWithString`
|
||
already route correctly through the existing `GameEventWiring`
|
||
registration; FA only needs to confirm the specific ids
|
||
(`YouAreNotInAFellowship`, `FellowshipIsFull`, etc.) exist in the table.
|
||
- Register row **AP-183 / #363** notes ~10 sites typed `0x00` where retail
|
||
types `0x1A` — do not add more.
|
||
|
||
### 7.3 Allegiance logon notifications
|
||
|
||
`AllegianceLoginNotification 0x027A` is the one FA message whose *display*
|
||
is gated by a character option:
|
||
`DisplayAllegianceLogonNotifications` (`0x18`, Options1 `0x08000000`,
|
||
non-auto-save — `CharacterOptionTable.cs:135`). The gate must read the
|
||
Runtime option bit, exactly like `TurbineChatMembershipGate.cs:105-136`
|
||
does for the six `ListenTo*Chat` bits. **That gate is the consumer that
|
||
un-dims the row** (§4.3).
|
||
|
||
Note the chat-digest distinction: `Hear*Chat` bits are channel
|
||
*membership* (retail refuses to even send). `DisplayAllegianceLogonNotifications`
|
||
is different — it is a genuine *receive-side display* filter, so the
|
||
inbound handler must swallow the notice locally when the bit is off. Lane
|
||
A/B should confirm this against retail rather than inferring it from ACE.
|
||
|
||
---
|
||
|
||
## 8. Seam map (summary table)
|
||
|
||
| Subsystem | Where new code goes | Pattern to follow | Precedent citation |
|
||
|---|---|---|---|
|
||
| Fellowship state owner | `src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs` (new) | J4 owner: `IDisposable` + `ResetSession()` + `CaptureOwnership()`; borrow entity table | `RuntimeInventoryState` ctor `GameRuntime.cs:192-193`; snapshot shape `RuntimeCharacterState.cs:9-51` |
|
||
| Allegiance state owner | `src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs` (new), ~~wrapping existing `AcDream.Core.Allegiance.AllegianceTree`~~ **[FA1 fix-round addendum, 2026-08-12: `AllegianceTree` was DELETED by Campaign FA slice FA1 (`4281750b`) — there is nothing left to wrap. Wrap `ClientCommandResponses.AllegianceProfileLookups` (the flat `AllegianceMemberRecord` list + `GetData`/`GetPatron`/`FindVassals` walk that replaced it) instead. FA2 is the very next slice — this is its seam-map row.]** | Same; ~~**survives reconnect** — use a `HasServerSeed`-style latch~~ **[FA2 fix-round addendum, 2026-08-12 (docs/research/2026-08-12-fa2-review-mechanism.md MUST-FIX 1): WRONG — retail clears the allegiance profile at `OnEndCharacterSession`, the very same boundary Fellowship clears at. Allegiance is now a `RuntimeGenerationReset` stage exactly like Fellowship; the `HasServerSeed` latch clears AND re-arms every generation (matching the `RuntimeCharacterOptionsState` precedent's actual clear-and-relatch behavior, not the persist behavior this row previously (mis)cited it for).]** | `RuntimeCharacterOptionsState` seed latch; ~~`AllegianceTree.cs:56-162`~~ **[FA1 fix-round addendum: see `ClientCommandResponses.cs:250-295` (`AllegianceProfileLookups`) instead]** |
|
||
| Owner registration | `GameRuntime.cs` | ctor + `construction.Own` + fault point + `*Owner` prop + view prop + `CaptureOwnership` + shutdown step | `GameRuntime.cs:200-214`, `:419-429`, `:463-470`, `:545-567`, `:703-752` |
|
||
| Session reset | `RuntimeGenerationReset.cs` | new `RuntimeGenerationResetStage` ordinal + `Drain` case; **fellowship clears, allegiance re-seeds** | `:21-44`, `:236-349`; shared-stage example `:248-261` |
|
||
| Ownership rollup | `RuntimeGameplayOwnership.cs` | add to record + `Capture(...)` signature + `IsConverged` | `:8-21`, `:23-44` |
|
||
| Typed borrowed views | `GameRuntimeGameplayViews.cs` + `GameRuntimeViews.cs:243-272` | `Snapshot` record struct + `TryGet*`; no allocation | `IRuntimeSocialView` `GameRuntimeGameplayViews.cs:90-110` |
|
||
| Generation-gated commands | `GameRuntimeCommands.cs` + BOTH adapters | new interface + `IGameRuntimeCommands` member + `RuntimeCommandDomain` ordinal | `IRuntimeSocialCommands` `:291-300`; `DirectGameRuntimeCommandAdapter.cs:718-750`; `CurrentGameRuntimeCommandAdapter.cs:690` |
|
||
| Inbound parsers | `src/AcDream.Core.Net/Messages/GameEvents.cs` | static `Parse*` → nullable struct, `null` on malformed | `ParseChannelBroadcast`, `ParseTell` |
|
||
| Inbound registration | `src/AcDream.Core.Net/GameEventWiring.cs` (inside `WireAll`) | `registrar.Register(type, e => ...)`; state-object OR delegate hole | `friends`/`squelch` params `:80-81`; `AllegianceInfoResponse` `:192-198` |
|
||
| Host bindings plumbing | `LiveSessionEventRouter.cs:72-82` + **both** construction sites | trailing optional params | `LiveSessionRuntimeFactory.cs:256-261`; `HeadlessSessionHost.cs:780-785` |
|
||
| Outbound builders (fellowship) | `SocialActions.cs` (already there, `:123-168`) | keep the family together | — |
|
||
| Outbound builders (allegiance) | `AllegianceRequests.cs` (extend, `:27-56`) | keep the family together | — |
|
||
| Outbound send wrappers | `src/AcDream.Core.Net/WorldSession.cs` (**MISSING — must add**) | `NextGameActionSequence()` + `SendGameAction(builder(...))` | `SendSetSingleCharacterOption` `:2202-2206` |
|
||
| App bus path | `LiveSessionCommandRouter.cs` | `*RuntimeCmd` record + `commands.Register<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
|
||
|
||
1. **One owner or two?** This audit recommends two
|
||
(`RuntimeFellowshipState`, `RuntimeAllegianceState`) on lifetime
|
||
grounds. If the plan prefers one `RuntimeSocialGroupState`, it must
|
||
still model two independent reset semantics inside one
|
||
`IsConverged`.
|
||
2. **Does `IRuntimeEventObserver` gain `OnFellowship`?** Adding a member
|
||
breaks all 5 bot policies + the trace recorder + App observers. Decide
|
||
push-vs-poll before writing the owner. (Recommend poll via
|
||
`Snapshot.Revision`.)
|
||
3. **`0x027C AllegianceInfoResponse` already has a handler**
|
||
(`GameEventWiring.cs:192-198`, chat output for `@allegiance info`).
|
||
Does FA extend that lambda, register a second handler, or leave the
|
||
panel to use a different event? ~~The dispatcher permits multiple owned
|
||
handlers per type~~ **[FA2 fix-round addendum, 2026-08-12
|
||
(docs/research/2026-08-12-fa2-review-blast.md SHOULD-FIX 1): wrong —
|
||
`GameEventDispatcher.Dispatch` invokes only the single
|
||
most-recently-registered handler per type; a second `Register` call
|
||
REPLACES, it does not co-own. See §2.3's addendum for the full
|
||
correction. Answered: extend the existing lambda (done), and FA2's own
|
||
fix round then found the extension itself should not exist — retail's
|
||
0x027C handler is print-only, so no owner ever gets fed from it.]** —
|
||
but two handlers writing to two different owners is
|
||
the "two writable copies" smell.
|
||
4. **Which of the 6 dimmed rows actually gain consumers in FA v1?** Each
|
||
un-dim requires three coordinated edits (§4.2). Under-claiming is safe;
|
||
over-claiming makes the conformance test lie.
|
||
5. **Second ACE account for the two-bot fellowship gate.** CLAUDE.md
|
||
documents only `testaccount`/`+Acdream`. Confirm with the user whether a
|
||
second account+character exists, or whether the gate should use two
|
||
characters on one account (which ACE will not allow simultaneously).
|
||
6. **Allegiance gate strategy.** Swearing is semi-permanent and rank-gated.
|
||
Recommend not gating allegiance with bots against `+Acdream`.
|
||
7. **`ID_Fellowship_*` / `ID_Allegiance_*` string-table id.** Assume
|
||
nothing — dat-verify. The options family is `0x23000003`, but chat
|
||
filters live in `0x2300000D` and keyboard refusal in `0x23000004`.
|
||
8. ~~**`BuildFellowshipCreate` trailing-pad rule** is currently pinned only
|
||
by a self-consistent test (`SocialActionsTests.cs:53-68`), not against a
|
||
retail/ACE reference. Lane A/B should confirm before FA sends it live.~~
|
||
**[FA1 fix-round addendum, 2026-08-12: ANSWERED by FA1, close this
|
||
question rather than re-deriving it. `PackString16L`
|
||
(`SocialActions.cs:389-403`) pads the string record to a 4-byte multiple
|
||
INCLUDING its own u16 length, so `12 + name.Length` is already
|
||
4-aligned before the trailing `u32 shareXP` is appended — the pad the
|
||
old (now-deleted) builder added after its two invented bools was never
|
||
the string's own pad. ACE reads exactly `ReadString16L()` then
|
||
`ReadUInt32()` (`GameActionFellowshipCreate.cs`), and `ACE.Common`'s
|
||
`ReadString16L` skips the identical `CalculatePadMultiple(2 + length,
|
||
4)` pad. The golden vector at `SocialActionsTests.cs:65-83` ("Team",
|
||
len 4, 2 pad bytes) is hand-derived against that ACE reader, not merely
|
||
self-consistent. No further confirmation owed.]**
|
||
9. **`DisplayAllegianceLogonNotifications` semantics**: receive-side
|
||
display filter (swallow locally) or membership-style suppression? Unlike
|
||
`Hear*Chat` this is almost certainly display-only — but it must be
|
||
confirmed against retail, not inferred.
|
||
10. **Panel ids for `RetailPanelCatalog`.** Must be byte-verified from the
|
||
authored `0x10000029` property, not guessed; the BN literal-0 fold
|
||
artifact bit Campaign OP four times.
|
||
11. **Does the FA panel participate in `RegisterMainPanel`'s shared
|
||
geometry?** That gives retail's single-active-child behavior (opening
|
||
Fellowship closes Allegiance) automatically. Confirm that is retail
|
||
behavior for F3/F4 before adopting it.
|
||
12. **Divergence register rows.** Any FA-introduced approximation needs its
|
||
row in `docs/architecture/retail-divergence-register.md` **in the same
|
||
commit**; any row FA retires (e.g. #L.4's panel gap) is deleted in the
|
||
same commit.
|
||
|
||
---
|
||
|
||
## Executive summary
|
||
|
||
1. **FA is half-scaffolded already:** all 11 event ids, all 7 outbound
|
||
builders, the `AllegianceTree` model, both chat `LogTextType`s, the
|
||
F3/F4 input actions with retail ActionMap ids, and the MF-2 option
|
||
mutual-exclusion exist — but the builders have **zero production
|
||
callers**, there are **no `WorldSession.Send*` wrappers**, **no
|
||
parsers**, **no inbound registrations**, and **no F3/F4 handler**.
|
||
2. **Ownership: two new sibling J-owners under `GameRuntime`**
|
||
(`RuntimeFellowshipState`, `RuntimeAllegianceState`), NOT children of
|
||
`RuntimeCommunicationState` — ~~the lifetimes differ (fellowship is
|
||
session-scoped, allegiance survives reconnect)~~ **[FA2 fix-round
|
||
addendum, 2026-08-12: both are now session-scoped, cleared at every
|
||
generation reset — see §1.3's addendum. They remain separate owners
|
||
because they are independent retail systems, not because their
|
||
lifetimes differ]** and J4's own precedent is
|
||
one owner per coupled graph. Each costs eight mechanical edits, listed
|
||
with line-cited templates in §1.1.
|
||
3. **Inbound has exactly one registration site**
|
||
(`GameEventWiring.WireAll`, called only from Runtime's
|
||
`LiveSessionEventRouter.cs:179`), so both hosts are served by one wiring
|
||
— provided the sink is Runtime-owned; both `LiveSocialSessionBindings`
|
||
construction sites (App `LiveSessionRuntimeFactory.cs:256`, Headless
|
||
`HeadlessSessionHost.cs:780`) must be updated together.
|
||
4. **Exactly six dimmed rows are in FA's blast radius**
|
||
(`IgnoreAllegianceRequests`, `IgnoreFellowshipRequests`,
|
||
`DisplayAllegianceLogonNotifications`, `FellowshipShareXP`,
|
||
`FellowshipShareLoot`, `FellowshipAutoAcceptRequests`,
|
||
`CharacterOptionsPageController.cs:195-200`); un-dimming any of them
|
||
requires three coordinated edits pinned by
|
||
`StoreOnlyRows_MatchTheDerivationTableExactly` and
|
||
`Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows`.
|
||
5. **A bot-recruits-bot gate needs a second ACE account** (the only
|
||
external prerequisite), a new `HeadlessBotPolicy` id with a role
|
||
discriminator, a friendly-player target query, and 7 named assertions —
|
||
the decisive one being that the *recruited* bot's own snapshot flips,
|
||
which only two live sessions can prove.
|