research: Campaign FA lanes A-D + the U2 slot-table probe (social panel found)
Four Opus research lanes for the Fellowship & Allegiance campaign: panel structure, fellowship wire (two BN zero-folds broken by byte decode: IsFull >= 9, the x87 XP-share table capping at 2.8x), allegiance wire (27+5 messages binary-verified; tree assembly discard/reversal rules; ACE zeroed-field caveats), and the acdream seams audit (H.2 scaffolding inventory, J-owner recommendation, AD-78 dimmed-row inventory, bot-gate requirements). Coordinator U2 closure (FaPanelSlotProbeTests, live DATs): Fellowship and Allegiance are two of FOUR pages of ONE tabbed social panel — slot 0x1000018F, panel id 12, Type-8 host — alongside gmFriendsUI and gmSquelchUI; lane A's separate-siblings mounting call is corrected in its addendum, and the full 16-slot dump closes every unidentified RetailPanelCatalog entry (Abuse/Book/LinkStatus/MiniGame/UA/Vitae/ Map+House/Journal). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5eca35b706
commit
1226f289f3
5 changed files with 4009 additions and 0 deletions
901
docs/research/2026-08-11-fa-acdream-seams.md
Normal file
901
docs/research/2026-08-11-fa-acdream-seams.md
Normal file
|
|
@ -0,0 +1,901 @@
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale and precedent:
|
||||||
|
|
||||||
|
- **Separate owners** because the reset semantics differ: fellowship is
|
||||||
|
session-scoped (a disconnect drops you from the fellowship server-side —
|
||||||
|
clear it at reset, same class as `_inventory.ResetExternalContainer`,
|
||||||
|
`RuntimeGenerationReset.cs:248-261`), whereas allegiance survives
|
||||||
|
reconnect and is re-seeded from `PlayerDescription`/`AllegianceUpdate`
|
||||||
|
(same class as `RuntimeCharacterOptionsState`'s `HasServerSeed` latch,
|
||||||
|
`project_settings_options_digest.md` DO-NOT-RETRY row "Applying a
|
||||||
|
pre-seed Reset/local snapshot over server truth").
|
||||||
|
- **Borrow, never copy, the entity table.** Fellowship member rows must
|
||||||
|
read live names/vitals from `RuntimeEntityObjectLifetime`'s canonical
|
||||||
|
`ClientObjectTable` the way `RuntimeInventoryState` does
|
||||||
|
(`GameRuntime.cs:192-193`) — a second copy of member names is exactly the
|
||||||
|
"two writable copies" divergence OP9 deleted
|
||||||
|
(`project_settings_options_digest.md`, AP-196 row).
|
||||||
|
- **Typed borrowed views, not events, for the panels.**
|
||||||
|
`IRuntimeSocialView` (`GameRuntimeGameplayViews.cs:105-110`) is the exact
|
||||||
|
template: a `Snapshot` record struct plus `TryGet*` accessors, no
|
||||||
|
collection allocation. Add `IRuntimeFellowshipView` /
|
||||||
|
`IRuntimeAllegianceView` to `GameRuntimeGameplayViews.cs` and hang them
|
||||||
|
off `IGameRuntimeView` (`GameRuntimeViews.cs:243-272`) so headless bots
|
||||||
|
can assert roster state without touching presentation.
|
||||||
|
- **Generation-gated commands.** Add
|
||||||
|
`IRuntimeFellowshipCommands` / `IRuntimeAllegianceCommands` to
|
||||||
|
`GameRuntimeCommands.cs` alongside `IRuntimeSocialCommands` (`:291-300`),
|
||||||
|
register them on `IGameRuntimeCommands` (`:302-325`), and add two
|
||||||
|
`RuntimeCommandDomain` ordinals (`GameRuntimeEvents.cs:11-24` — currently
|
||||||
|
`Magic = 10`, so `Fellowship = 11`, `Allegiance = 12`). Both host
|
||||||
|
adapters must implement: `DirectGameRuntimeCommandAdapter`
|
||||||
|
(`src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:20-31`
|
||||||
|
interface list, `:718-750` friend-command template) and
|
||||||
|
`CurrentGameRuntimeCommandAdapter`
|
||||||
|
(`src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs:690,703` —
|
||||||
|
it publishes onto the App command bus instead of touching the session).
|
||||||
|
- **Ordered deltas only if a bot needs them.** The J-pattern's event hub
|
||||||
|
(`GameRuntimeEventHub`, constructed at `GameRuntime.cs:304-307` from
|
||||||
|
entity/communication/action owners) feeds `IRuntimeEventObserver`
|
||||||
|
(`GameRuntimeEvents.cs:98-115`). A `OnFellowship(in
|
||||||
|
RuntimeFellowshipDelta)` member is a **breaking change to every
|
||||||
|
implementer** (5 bot policies in
|
||||||
|
`src/AcDream.Headless/Policies/HeadlessBotPolicy.cs`, the trace recorder
|
||||||
|
at `GameRuntimeEvents.cs:148-311`, App observers). Prefer **not** adding
|
||||||
|
an observer member unless the bot gate (§6) genuinely needs push
|
||||||
|
semantics; a `Snapshot.Revision` poll off the borrowed view is the
|
||||||
|
cheaper contract and matches `IRuntimeSocialView`.
|
||||||
|
|
||||||
|
### 1.4 The one thing that *should* live in Communication
|
||||||
|
|
||||||
|
Fellowship/allegiance **system message text** (recruits, quits, logon
|
||||||
|
notices) is interface text, not fellowship state — it goes through
|
||||||
|
`RuntimeCommunicationState.AddText`
|
||||||
|
(`RuntimeCommunicationState.cs:210-239`). See §7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Inbound wire routing — the pattern a new event family must follow
|
||||||
|
|
||||||
|
### 2.1 There is exactly ONE registration site, and it is in Runtime
|
||||||
|
|
||||||
|
`GameEventWiring.WireAll`
|
||||||
|
(`src/AcDream.Core.Net/GameEventWiring.cs:36-99`) is called from exactly
|
||||||
|
one place in `src/`: `LiveSessionEventRouter.Attach`
|
||||||
|
(`src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:179-231`). A
|
||||||
|
whole-`src` grep for `WireAll` returns those two hits only. **This is the
|
||||||
|
K-slice unification: both hosts share the router.** So a new event family
|
||||||
|
is wired once and both hosts get it — but only if the *sink* is reachable
|
||||||
|
from both, which is why the sink must be a Runtime owner (§1) and not an
|
||||||
|
App object.
|
||||||
|
|
||||||
|
### 2.2 The mechanical shape
|
||||||
|
|
||||||
|
`GameEventWiring` is a static method taking one positional core
|
||||||
|
(`dispatcher, items, combat, spellbook, chat`) plus ~20 optional
|
||||||
|
delegate/state "holes" (`GameEventWiring.cs:41-99`). Core.Net **cannot
|
||||||
|
reference `AcDream.Runtime`** — the CH2 comment at
|
||||||
|
`GameEventWiring.cs:90-98` states this explicitly ("Core.Net cannot
|
||||||
|
reference AcDream.Runtime directly, so this is a delegate hole exactly
|
||||||
|
like every other Runtime-owned sink above"). Two legal shapes:
|
||||||
|
|
||||||
|
- **State object** (like `friends`, `squelch`, `itemMana`, `vendor`,
|
||||||
|
`externalContainers`): the type lives in `AcDream.Core.*`, Runtime owns
|
||||||
|
the instance, Core.Net writes into it. `FriendsState`/`SquelchState` live
|
||||||
|
in `AcDream.Core.Social`; `AllegianceTree` already lives in
|
||||||
|
`AcDream.Core.Allegiance` — **so the allegiance tree can be passed
|
||||||
|
directly as a state parameter**, exactly like `friends`.
|
||||||
|
- **Delegate hole** (like `onCharacterOptions`, `onInterfaceText`,
|
||||||
|
`onUseDone`): a `Action<...>` the router closes over.
|
||||||
|
|
||||||
|
Registration itself: `registrar.Register(GameEventType.X, e => {...})`
|
||||||
|
against the owned registrar (`GameEventWiring.cs:923-943`), which wraps
|
||||||
|
every handler in the `accepting` gate so a retiring generation cannot
|
||||||
|
receive. Do not register outside `WireAll` — the `RegistrationBuildScope`
|
||||||
|
(`:945-961`) guarantees all-or-nothing ownership.
|
||||||
|
|
||||||
|
### 2.3 The 11 events FA must register
|
||||||
|
|
||||||
|
From `GameEventType.cs`:
|
||||||
|
|
||||||
|
| Id | Name | Line |
|
||||||
|
|---|---|---|
|
||||||
|
| `0x0003` | `AllegianceUpdateAborted` | `:16` |
|
||||||
|
| `0x0020` | `AllegianceUpdate` | `:19` |
|
||||||
|
| `0x00A3` | `FellowshipQuit` | `:29` |
|
||||||
|
| `0x00A4` | `FellowshipDismiss` | `:30` |
|
||||||
|
| `0x01C8` | `AllegianceUpdateDone` | `:55` |
|
||||||
|
| `0x01C9` | `FellowshipFellowUpdateDone` | `:56` |
|
||||||
|
| `0x01CA` | `FellowshipFellowStatsDone` | `:57` |
|
||||||
|
| `0x027A` | `AllegianceLoginNotification` | `:84` |
|
||||||
|
| `0x027C` | `AllegianceInfoResponse` | `: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-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`, 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` |
|
||||||
|
| `commands.Fellowship.Recruit(gen, targetGuid)` | `BuildFellowshipRecruit` |
|
||||||
|
| `commands.Fellowship.Quit(gen, disband)` | `BuildFellowshipQuit` |
|
||||||
|
| `commands.Fellowship.Dismiss(gen, targetGuid)` | `BuildFellowshipDismiss` |
|
||||||
|
| `commands.Fellowship.SetOpen(gen, open)` | `BuildFellowshipUpdate` |
|
||||||
|
| `commands.Allegiance.Swear(gen, patronGuid)` | `AllegianceRequests.BuildSwear` |
|
||||||
|
| `commands.Allegiance.Break(gen, targetGuid)` | `BuildBreak` |
|
||||||
|
|
||||||
|
Plus target acquisition: the recruit target is another player's server
|
||||||
|
guid. `RuntimeHostileTargetQuery.FindClosest`
|
||||||
|
(`src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs`, used at
|
||||||
|
`HeadlessGameplayOperations.cs:143`) finds hostiles only — FA needs a
|
||||||
|
**friendly/player** nearest-object query, or the policy reads
|
||||||
|
`view.InventoryState`/entity deltas for a known character name. The bot
|
||||||
|
already receives `OnEntity` deltas
|
||||||
|
(`GameRuntimeEvents.cs:104`), so name-matching off `RuntimeEntitySnapshot`
|
||||||
|
is the low-friction route.
|
||||||
|
|
||||||
|
**Views the policy needs to assert on:**
|
||||||
|
|
||||||
|
- `view.Fellowship.Snapshot` — at minimum `{ Revision, IsInFellowship,
|
||||||
|
Name, MemberCount, LeaderGuid, IsOpen }`, plus
|
||||||
|
`TryGetMember(guid, out RuntimeFellowMemberSnapshot)`. Modeled on
|
||||||
|
`IRuntimeSocialView` (`GameRuntimeGameplayViews.cs:105-110`).
|
||||||
|
- `view.Allegiance.Snapshot` — `{ Revision, MonarchGuid, PatronGuid,
|
||||||
|
VassalCount, Rank }`, plus `TryGetNode`.
|
||||||
|
|
||||||
|
**Gate assertions (what "passed" means):**
|
||||||
|
|
||||||
|
1. Bot A creates a fellowship → A's `Fellowship.Snapshot.IsInFellowship`
|
||||||
|
true, `MemberCount == 1`.
|
||||||
|
2. A recruits B → B's own snapshot flips (proving the `0x02BE`
|
||||||
|
`FellowshipFullUpdate` inbound path reached B's Runtime owner, not just
|
||||||
|
A's local echo). **This is the assertion that only a two-bot gate can
|
||||||
|
make.**
|
||||||
|
3. `FellowshipAutoAcceptRequests` declared true on B via
|
||||||
|
`characterOptions` → recruit succeeds without a confirmation; declared
|
||||||
|
false → ACE sends `CharacterConfirmationRequest` type 4 and the bot must
|
||||||
|
answer it (needs a bot-visible confirmation command, or the policy
|
||||||
|
declares auto-accept to avoid it in v1 — **recommend v1 declares
|
||||||
|
auto-accept**).
|
||||||
|
4. `IgnoreFellowshipRequests` true on B → recruit refused; asserts the
|
||||||
|
server honors the bit.
|
||||||
|
5. B quits → A's `MemberCount` drops (proving `0x00A3`/`0x02C0` inbound).
|
||||||
|
6. A disbands → both snapshots clear (proving `0x02BF`).
|
||||||
|
7. Graceful SIGINT → both Runtime roots converge, FA owners' ownership
|
||||||
|
snapshots `IsConverged` (the K4 terminal-ownership check,
|
||||||
|
`src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs`).
|
||||||
|
|
||||||
|
Allegiance's swear/break is harder to gate: ACE enforces rank/vassal-cap
|
||||||
|
rules and swearing is semi-permanent on a shared test character. Recommend
|
||||||
|
the FA plan gate **fellowship with two bots** and gate **allegiance
|
||||||
|
manually via the graphical client** against a throwaway pair, rather than
|
||||||
|
burning `+Acdream`'s allegiance state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Chat / interface-text seam
|
||||||
|
|
||||||
|
### 7.1 The chokepoint
|
||||||
|
|
||||||
|
`RuntimeCommunicationState.AddText(string text, RetailLogTextType type,
|
||||||
|
uint windowId = 0)`
|
||||||
|
(`src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs:210-239`) is
|
||||||
|
the one seam, the port of `ClientSystem::AddTextToScroll @0x00563C50`.
|
||||||
|
Behavior: `Trim()` both ends (`:227`, and the comment at `:214-226`
|
||||||
|
explains why there is deliberately **no** empty-string guard);
|
||||||
|
`ClientLocal 0x1A` → SpewBox only (`:229-233`); everything else →
|
||||||
|
`Chat.OnSystemMessage(text, (uint)type)` (`:238`).
|
||||||
|
|
||||||
|
Reached from Core.Net through the `onInterfaceText` delegate hole
|
||||||
|
(`GameEventWiring.cs:90-98`), supplied by
|
||||||
|
`LiveSocialSessionBindings.AddText`
|
||||||
|
(`LiveSessionEventRouter.cs:77-82`) — bound in **both** hosts
|
||||||
|
(`LiveSessionRuntimeFactory.cs:261`, `HeadlessSessionHost.cs:785`).
|
||||||
|
|
||||||
|
### 7.2 Which `RetailLogTextType` FA messages use
|
||||||
|
|
||||||
|
`src/AcDream.Core/Chat/RetailLogTextType.cs`:
|
||||||
|
|
||||||
|
- `Allegiance = 0x12` (`:44`)
|
||||||
|
- `Fellowship = 0x13` (`:45`)
|
||||||
|
|
||||||
|
Both already have retail-exact RGBA in `RetailChatColorTable`
|
||||||
|
(`AcDream.UI.Abstractions`), conformance-pinned to
|
||||||
|
`ChatInterface::BuildChatColorLookupTable @0x4f31c0`
|
||||||
|
(`project_chat_digest.md`, "Current truth"). **No color work needed —
|
||||||
|
just pass the right type.**
|
||||||
|
|
||||||
|
Rules carried from the chat digest:
|
||||||
|
|
||||||
|
- **Do not add chat-color configurability.** Retail hard-codes every
|
||||||
|
`LogTextType`; only per-window type *filters* are user-settable.
|
||||||
|
- **`ClientLocal 0x1A` is SpewBox-only** and must never reach the
|
||||||
|
scrolling chat window. Local FA refusals ("You are ignoring fellowship
|
||||||
|
requests.") are the `0x1A` class; server-sent roster notices are
|
||||||
|
`0x13`/`0x12`.
|
||||||
|
- **Refusal text comes from `WeenieErrorMessages`** (344 rows, binary-swept
|
||||||
|
— `src/AcDream.Core/Chat/WeenieErrorMessages.cs`), not from invented
|
||||||
|
English. FA refusals arriving as `WeenieError`/`WeenieErrorWithString`
|
||||||
|
already route correctly through the existing `GameEventWiring`
|
||||||
|
registration; FA only needs to confirm the specific ids
|
||||||
|
(`YouAreNotInAFellowship`, `FellowshipIsFull`, etc.) exist in the table.
|
||||||
|
- Register row **AP-183 / #363** notes ~10 sites typed `0x00` where retail
|
||||||
|
types `0x1A` — do not add more.
|
||||||
|
|
||||||
|
### 7.3 Allegiance logon notifications
|
||||||
|
|
||||||
|
`AllegianceLoginNotification 0x027A` is the one FA message whose *display*
|
||||||
|
is gated by a character option:
|
||||||
|
`DisplayAllegianceLogonNotifications` (`0x18`, Options1 `0x08000000`,
|
||||||
|
non-auto-save — `CharacterOptionTable.cs:135`). The gate must read the
|
||||||
|
Runtime option bit, exactly like `TurbineChatMembershipGate.cs:105-136`
|
||||||
|
does for the six `ListenTo*Chat` bits. **That gate is the consumer that
|
||||||
|
un-dims the row** (§4.3).
|
||||||
|
|
||||||
|
Note the chat-digest distinction: `Hear*Chat` bits are channel
|
||||||
|
*membership* (retail refuses to even send). `DisplayAllegianceLogonNotifications`
|
||||||
|
is different — it is a genuine *receive-side display* filter, so the
|
||||||
|
inbound handler must swallow the notice locally when the bit is off. Lane
|
||||||
|
A/B should confirm this against retail rather than inferring it from ACE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Seam map (summary table)
|
||||||
|
|
||||||
|
| Subsystem | Where new code goes | Pattern to follow | Precedent citation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Fellowship state owner | `src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs` (new) | J4 owner: `IDisposable` + `ResetSession()` + `CaptureOwnership()`; borrow entity table | `RuntimeInventoryState` ctor `GameRuntime.cs:192-193`; snapshot shape `RuntimeCharacterState.cs:9-51` |
|
||||||
|
| Allegiance state owner | `src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs` (new), wrapping existing `AcDream.Core.Allegiance.AllegianceTree` | Same; **survives reconnect** — use a `HasServerSeed`-style latch | `RuntimeCharacterOptionsState` seed latch; `AllegianceTree.cs:56-162` |
|
||||||
|
| Owner registration | `GameRuntime.cs` | ctor + `construction.Own` + fault point + `*Owner` prop + view prop + `CaptureOwnership` + shutdown step | `GameRuntime.cs:200-214`, `:419-429`, `:463-470`, `:545-567`, `:703-752` |
|
||||||
|
| Session reset | `RuntimeGenerationReset.cs` | new `RuntimeGenerationResetStage` ordinal + `Drain` case; **fellowship clears, allegiance re-seeds** | `:21-44`, `:236-349`; shared-stage example `:248-261` |
|
||||||
|
| Ownership rollup | `RuntimeGameplayOwnership.cs` | add to record + `Capture(...)` signature + `IsConverged` | `:8-21`, `:23-44` |
|
||||||
|
| Typed borrowed views | `GameRuntimeGameplayViews.cs` + `GameRuntimeViews.cs:243-272` | `Snapshot` record struct + `TryGet*`; no allocation | `IRuntimeSocialView` `GameRuntimeGameplayViews.cs:90-110` |
|
||||||
|
| Generation-gated commands | `GameRuntimeCommands.cs` + BOTH adapters | new interface + `IGameRuntimeCommands` member + `RuntimeCommandDomain` ordinal | `IRuntimeSocialCommands` `:291-300`; `DirectGameRuntimeCommandAdapter.cs:718-750`; `CurrentGameRuntimeCommandAdapter.cs:690` |
|
||||||
|
| Inbound parsers | `src/AcDream.Core.Net/Messages/GameEvents.cs` | static `Parse*` → nullable struct, `null` on malformed | `ParseChannelBroadcast`, `ParseTell` |
|
||||||
|
| Inbound registration | `src/AcDream.Core.Net/GameEventWiring.cs` (inside `WireAll`) | `registrar.Register(type, e => ...)`; state-object OR delegate hole | `friends`/`squelch` params `:80-81`; `AllegianceInfoResponse` `:192-198` |
|
||||||
|
| Host bindings plumbing | `LiveSessionEventRouter.cs:72-82` + **both** construction sites | trailing optional params | `LiveSessionRuntimeFactory.cs:256-261`; `HeadlessSessionHost.cs:780-785` |
|
||||||
|
| Outbound builders (fellowship) | `SocialActions.cs` (already there, `:123-168`) | keep the family together | — |
|
||||||
|
| Outbound builders (allegiance) | `AllegianceRequests.cs` (extend, `:27-56`) | keep the family together | — |
|
||||||
|
| Outbound send wrappers | `src/AcDream.Core.Net/WorldSession.cs` (**MISSING — must add**) | `NextGameActionSequence()` + `SendGameAction(builder(...))` | `SendSetSingleCharacterOption` `:2202-2206` |
|
||||||
|
| App bus path | `LiveSessionCommandRouter.cs` | `*RuntimeCmd` record + `commands.Register<T>(... SendIfActive ...)` | `:74-81`, `:173-186` |
|
||||||
|
| Wire tests | `tests/AcDream.Core.Net.Tests/Messages/` | field probes + **hand-computed** golden vector for any derived layout | `SocialActionsTests.cs:136-175`; `AllegianceRequestsTests.cs:10-34` |
|
||||||
|
| Option un-dim | `CharacterOptionsPageController.cs:193-201` + tests | flip `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 — but two handlers writing to two different owners is
|
||||||
|
the "two writable copies" smell.
|
||||||
|
4. **Which of the 6 dimmed rows actually gain consumers in FA v1?** Each
|
||||||
|
un-dim requires three coordinated edits (§4.2). Under-claiming is safe;
|
||||||
|
over-claiming makes the conformance test lie.
|
||||||
|
5. **Second ACE account for the two-bot fellowship gate.** CLAUDE.md
|
||||||
|
documents only `testaccount`/`+Acdream`. Confirm with the user whether a
|
||||||
|
second account+character exists, or whether the gate should use two
|
||||||
|
characters on one account (which ACE will not allow simultaneously).
|
||||||
|
6. **Allegiance gate strategy.** Swearing is semi-permanent and rank-gated.
|
||||||
|
Recommend not gating allegiance with bots against `+Acdream`.
|
||||||
|
7. **`ID_Fellowship_*` / `ID_Allegiance_*` string-table id.** Assume
|
||||||
|
nothing — dat-verify. The options family is `0x23000003`, but chat
|
||||||
|
filters live in `0x2300000D` and keyboard refusal in `0x23000004`.
|
||||||
|
8. **`BuildFellowshipCreate` trailing-pad rule** is currently pinned only
|
||||||
|
by a self-consistent test (`SocialActionsTests.cs:53-68`), not against a
|
||||||
|
retail/ACE reference. Lane A/B should confirm before FA sends it live.
|
||||||
|
9. **`DisplayAllegianceLogonNotifications` semantics**: receive-side
|
||||||
|
display filter (swallow locally) or membership-style suppression? Unlike
|
||||||
|
`Hear*Chat` this is almost certainly display-only — but it must be
|
||||||
|
confirmed against retail, not inferred.
|
||||||
|
10. **Panel ids for `RetailPanelCatalog`.** Must be byte-verified from the
|
||||||
|
authored `0x10000029` property, not guessed; the BN literal-0 fold
|
||||||
|
artifact bit Campaign OP four times.
|
||||||
|
11. **Does the FA panel participate in `RegisterMainPanel`'s shared
|
||||||
|
geometry?** That gives retail's single-active-child behavior (opening
|
||||||
|
Fellowship closes Allegiance) automatically. Confirm that is retail
|
||||||
|
behavior for F3/F4 before adopting it.
|
||||||
|
12. **Divergence register rows.** Any FA-introduced approximation needs its
|
||||||
|
row in `docs/architecture/retail-divergence-register.md` **in the same
|
||||||
|
commit**; any row FA retires (e.g. #L.4's panel gap) is deleted in the
|
||||||
|
same commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive summary
|
||||||
|
|
||||||
|
1. **FA is half-scaffolded already:** all 11 event ids, all 7 outbound
|
||||||
|
builders, the `AllegianceTree` model, both chat `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) 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.
|
||||||
1015
docs/research/2026-08-11-fa-allegiance-wire.md
Normal file
1015
docs/research/2026-08-11-fa-allegiance-wire.md
Normal file
File diff suppressed because it is too large
Load diff
1077
docs/research/2026-08-11-fa-fellowship-wire.md
Normal file
1077
docs/research/2026-08-11-fa-fellowship-wire.md
Normal file
File diff suppressed because it is too large
Load diff
921
docs/research/2026-08-11-fa-panel-structure.md
Normal file
921
docs/research/2026-08-11-fa-panel-structure.md
Normal file
|
|
@ -0,0 +1,921 @@
|
||||||
|
# Retail Fellowship & Allegiance panels — class families, mount, element inventory, dialogs, open paths
|
||||||
|
|
||||||
|
**Date:** 2026-08-11
|
||||||
|
**Lane:** Campaign FA **RESEARCH LANE A** — retail panel STRUCTURE.
|
||||||
|
**Status:** RESEARCH ONLY — no production code changed, nothing built, nothing launched,
|
||||||
|
no client attached, no DATs read.
|
||||||
|
**Quality bar / format model:** `docs/research/2026-08-10-options-panel-structure.md`
|
||||||
|
(especially its §10.1 structural-inventory style). Where that doc is the authority for a
|
||||||
|
shared mechanism (the `0x2100006E` floaty host, `P0x10000029` slot keys, the
|
||||||
|
`AddItemFromTemplateList` row mechanism, the `UIOption_Checkbox` row shape) this doc cites
|
||||||
|
it rather than re-deriving it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Primary sources and evidence discipline
|
||||||
|
|
||||||
|
| Source | Use |
|
||||||
|
|---|---|
|
||||||
|
| `docs/research/named-retail/acclient_2013_pseudo_c.txt` | Binary Ninja pseudo-C, Sept 2013 EoR build, PDB names. Cited as `pseudo_c:<line>`. |
|
||||||
|
| `docs/research/named-retail/acclient.h` | **Verbatim retail header struct definitions** — the member lists in §1.2 are copied from it, not inferred. |
|
||||||
|
| `docs/research/named-retail/symbols.json` | Address ↔ name, cited as VAs. |
|
||||||
|
| **Ghidra MCP on `127.0.0.1:8081`** (`patchmem.gpr`, same 2013 build + full PDB) | `GET /decompile_function?address=0x…`. **Ghidra resolves the named string-id globals and element-id constants that Binary Ninja folded to `0`.** Every `SetStringIDandTableEnum(&si, 0, 0x10000001)` in the BN text below was re-read here and came back with a real `ID_*` symbol. Cited as `ghidra@0x…`. |
|
||||||
|
| `docs/research/named-retail/retail-default.keymap.txt` / `keymap-default.txt` | The two open actions and their default keys. |
|
||||||
|
| acdream tree (`src/AcDream.App/UI/**`, `tests/.../fixtures/*.json`) | Seam comparison + the collision sweep in §6.3. |
|
||||||
|
|
||||||
|
**Not used, and therefore an explicit limit on this doc:** the installed DATs. Every
|
||||||
|
authored fact (LayoutDesc DIDs, geometry, media, base references, template arrays, the
|
||||||
|
`P0x10000029` slot keys, the static labels code never touches) is listed in
|
||||||
|
**§8 Unknowns for the coordinator** with an exact verification recipe. Nothing in this doc
|
||||||
|
is guessed; where the decomp only *strongly indicates* something, it says so.
|
||||||
|
|
||||||
|
### 0.1 BN zero-fold ledger (per the brief's warning)
|
||||||
|
|
||||||
|
Binary Ninja rendered **21 distinct** `StringInfo::SetStringIDandTableEnum(&si, 0, <tableEnum>)`
|
||||||
|
call sites across the two classes with a literal `0` first operand. All 21 are **resolved,
|
||||||
|
not asserted** — Ghidra's decompiler of the same binary names the global at each site
|
||||||
|
(§4.1/§4.2 tables). Additionally:
|
||||||
|
|
||||||
|
* The `ID_*` globals themselves are `= 0x0` in the pseudo-C data dump
|
||||||
|
(`pseudo_c:1147705-1147793`). That is **not** a fold — they are genuinely zero at
|
||||||
|
link time and filled at startup by
|
||||||
|
`ID_Fellowship_FellowName = compute_str_hash("ID_Fellowship_FellowName")`
|
||||||
|
(`pseudo_c:767586-767956`). See §4.3 — this changes how acdream must resolve them.
|
||||||
|
* Two BN `neg/sbb` zero-idioms appear in `gmFellowshipUI::UpdateButtons`
|
||||||
|
(`((eax - eax) & 0xc) + 1` and `((ebx - 1) & 0xfffffff4) + 0xd`). Both are decoded in
|
||||||
|
§3.4; Ghidra renders the first honestly as `(-(iVar4 != 0) & 0xcU) + 1`
|
||||||
|
(`ghidra@0x0048E6C0:97`), which corroborates the decode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The class family (Q1)
|
||||||
|
|
||||||
|
### 1.1 Headline
|
||||||
|
|
||||||
|
**There is no `gmSocialUI`, and Fellowship and Allegiance are not tabs of one panel.**
|
||||||
|
They are two independent `UIElement` subclasses, each registered under its own element
|
||||||
|
class id, each authored in its own LayoutDesc, each mounted as its own sibling in the
|
||||||
|
shared `gmPanelUI` page stack — exactly the Character-Info / Options pattern.
|
||||||
|
|
||||||
|
| Retail class | Element class id | `Register` | `Create` | `PostInit` | Base classes (`acclient.h`) |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `gmAllegianceUI` | **`0x1000002C`** | `0x004911F0` | `0x00490D10` | `0x00490E90` | `UIElement_Field`, `gmNoticeHandler`, `QualityChangeHandler` |
|
||||||
|
| `gmFellowshipUI` | **`0x1000002D`** | `0x0048E6A0` | `0x0048E400` | `0x0048FD40` | `UIElement_Field`, `gmNoticeHandler` |
|
||||||
|
|
||||||
|
* `UIElement::RegisterElementClass(0x1000002c, gmAllegianceUI::Create)` — `pseudo_c:157423`
|
||||||
|
* `UIElement::RegisterElementClass(0x1000002d, gmFellowshipUI::Create)` — `pseudo_c:154913`
|
||||||
|
* Both are registered from the same startup block as every other panel class
|
||||||
|
(`pseudo_c:135145-135146`).
|
||||||
|
* Struct definitions: `acclient.h:56038` (`gmAllegianceUI`), `acclient.h:56065`
|
||||||
|
(`gmFellowshipUI`).
|
||||||
|
|
||||||
|
**Neighbourhood in the class-id space** (full `RegisterElementClass` sweep of the binary):
|
||||||
|
`0x1000002A gmAttributeUI`, `0x1000002B gmSkillUI`, **`0x1000002C gmAllegianceUI`**,
|
||||||
|
**`0x1000002D gmFellowshipUI`**, `0x1000002E gmSpellbookUI`, `0x1000002F gmSpellComponentUI`.
|
||||||
|
The other social-ish surfaces are also separate classes and separate windows — they are
|
||||||
|
*not* siblings inside a social panel: `0x10000045 gmFriendsUI`, `0x10000046
|
||||||
|
gmCharacterTitleUI`, `0x10000047 gmSquelchUI`, `0x10000048 gmJournalUI`,
|
||||||
|
`0x10000049 gmPageListUI`. Their struct definitions sit immediately after Fellowship's in
|
||||||
|
the header (`acclient.h:56085` `gmFriendsUI`, `:56098` `gmSquelchUI`), which is why a
|
||||||
|
"social host" is easy to hypothesise and wrong.
|
||||||
|
|
||||||
|
### 1.2 Member fields — verbatim from `acclient.h`
|
||||||
|
|
||||||
|
These member lists **are** the element inventory: every `UIElement*` member is one authored
|
||||||
|
element the panel binds in `PostInit`.
|
||||||
|
|
||||||
|
`gmAllegianceUI` (`acclient.h:56038-56061`):
|
||||||
|
|
||||||
|
```
|
||||||
|
bool m_bAwaitingUpdate;
|
||||||
|
unsigned int m_iidSelectedVassal;
|
||||||
|
unsigned int m_iidPossibleNewPatron;
|
||||||
|
unsigned int m_iidPossibleKickedVassal;
|
||||||
|
unsigned int m_uiAcceptSwearServerContextID;
|
||||||
|
UIElement_Text *m_pAllegianceName;
|
||||||
|
UIElement_Text *m_pPlayerFollowers;
|
||||||
|
UIElement_Text *m_pPlayerRank;
|
||||||
|
UIElement *m_pMonarchField;
|
||||||
|
UIElement_Text *m_pMonarchLabel;
|
||||||
|
UIElement_Text *m_pMonarchName;
|
||||||
|
UIElement_Text *m_pMonarchFollowers;
|
||||||
|
UIElement *m_pPatronField;
|
||||||
|
UIElement_Text *m_pPatronName;
|
||||||
|
UIElement_ListBox *m_pVassalListBox;
|
||||||
|
UIElement_Button *m_pSwearButton;
|
||||||
|
UIElement_Button *m_pBreakButton;
|
||||||
|
UIElement_Button *m_pKickButton;
|
||||||
|
unsigned int m_swearContext;
|
||||||
|
unsigned int m_acceptSwearContext;
|
||||||
|
unsigned int m_breakContext;
|
||||||
|
unsigned int m_kickContext;
|
||||||
|
```
|
||||||
|
|
||||||
|
`gmFellowshipUI` (`acclient.h:56065-56082`):
|
||||||
|
|
||||||
|
```
|
||||||
|
CFellowship *m_pFellowship;
|
||||||
|
unsigned int m_iidSelectedFellow;
|
||||||
|
unsigned int m_uiAcceptFellowRequestServerContextID;
|
||||||
|
unsigned int m_fellowRequestContext;
|
||||||
|
UIElement *m_pNotInAFellowshipFrame;
|
||||||
|
UIElement *m_pInAFellowshipFrame;
|
||||||
|
UIElement_Text *m_pFellowshipNameEntryBox;
|
||||||
|
UIElement_Button *m_pCreateFellowshipButton;
|
||||||
|
UIElement_Text *m_pFellowshipName;
|
||||||
|
UIElement_ListBox *m_pFellowsListBox;
|
||||||
|
UIElement_Button *m_pFellowLeaderButton;
|
||||||
|
UIElement_Button *m_pFellowQuitButton;
|
||||||
|
UIElement_Button *m_pFellowOpenButton;
|
||||||
|
UIElement_Button *m_pFellowRecruitButton;
|
||||||
|
UIElement_Button *m_pFellowDismissButton;
|
||||||
|
UIElement_Button *m_pFellowDisbandButton;
|
||||||
|
```
|
||||||
|
|
||||||
|
Note **four** dialog-context slots on Allegiance vs **one** on Fellowship — Allegiance runs
|
||||||
|
four distinct confirmation dialogs (§5), Fellowship runs one.
|
||||||
|
|
||||||
|
### 1.3 Method inventory (all VAs from `symbols.json`)
|
||||||
|
|
||||||
|
| `gmFellowshipUI` | VA | `gmAllegianceUI` | VA |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `PostInit` | `0x0048FD40` | `PostInit` | `0x00490E90` |
|
||||||
|
| `OnVisibilityChanged` | `0x0048E460` | `OnVisibilityChanged` | `0x00491240` |
|
||||||
|
| `ListenToElementMessage` | `0x004901C0` | `ListenToElementMessage` | `0x00493030` |
|
||||||
|
| `ListenToGlobalMessage` | `0x004F5860` *(folded no-op — see §7.1)* | `ListenToGlobalMessage` | `0x004908B0` |
|
||||||
|
| `Update` | `0x0048F440` | `Update` | `0x00492670` |
|
||||||
|
| `UpdateButtons` | `0x0048E6C0` | `UpdateSwearButton` / `UpdateBreakButton` | `0x004908E0` / `0x004909D0` |
|
||||||
|
| `UpdateFellowStats` | `0x0048EB20` | `UpdatePlayerData` | `0x00491330` |
|
||||||
|
| `UpdateFellowVitals` | `0x0048ED60` | `UpdatePatronData` | `0x004917C0` |
|
||||||
|
| `UpdateFellowSelection` | `0x0048F0F0` | `UpdateMonarchData` | `0x00491B40` |
|
||||||
|
| `CreateFellowship` | `0x0048F730` | `UpdateVassalsData` | `0x00492340` |
|
||||||
|
| `RecruitFellow` / `DismissFellow` | `0x0048E530` / `0x0048E470` | `MakeSwearConfirmationDialog` | `0x004927B0` |
|
||||||
|
| `AssignLeadershipToFellow` | `0x0048F1E0` | `MakeAcceptSwearConfirmationDialog` | `0x00492990` |
|
||||||
|
| `MakeFellowRequestDialog` | `0x00490620` | `MakeBreakConfirmationDialog` | `0x00492BF0` |
|
||||||
|
| `RecvNotice_CloseDialog` | `0x0048F2C0` | `MakeKickConfirmationDialog` | `0x00492E10` |
|
||||||
|
| `RecvNotice_AbortConfirmationRequest` | `0x0048E430` | `Close{AcceptSwear,Break,Kick}ConfirmationDialog` | `0x00490A50` / `0x00490A90` / `0x00490B00` |
|
||||||
|
| `FellowshipDisbanded` / `FellowDismissed` / `FellowQuit` / `FellowAdded` / `FellowUpdated` | `0x0048F8A0` / `0x0048F970` / `0x0048FAE0` / `0x0048FC20` / `0x0048FCD0` | `RecvNotice_CloseDialog` / `_AbortConfirmationRequest` / `_AllegianceUpdate` / `_AllegianceUpdateAborted` / `_AllegianceLogin` / `_SwearAllegianceRequest` / `_PlayerDescReceived` / `_EnchantmentsChanged` / `_SelectionChanged` | `0x00492060` / `0x00490D80` / `0x00492780` / `0x00492790` / `0x00492220` / `0x00493110` / `0x00490D40` / `0x00492050` / `0x00490D70` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. LayoutDesc resolution and mounting (Q2)
|
||||||
|
|
||||||
|
### 2.1 How each panel's layout is resolved — **not** by a DID lookup in code
|
||||||
|
|
||||||
|
Neither class calls `DBCache::GetDIDFromEnumStatic` (`0x00413910`) or any literal-DID load.
|
||||||
|
Both constructors take `(LayoutDesc const*, ElementDesc const*)`
|
||||||
|
(`pseudo_c:154716`, `pseudo_c:157087`) — i.e. the element factory instantiates them from an
|
||||||
|
**authored** element whose `Type` property equals the registered class id. The binding is
|
||||||
|
therefore **entirely data-side**: some LayoutDesc contains an element with
|
||||||
|
`Type = 0x1000002D` (Fellowship) / `Type = 0x1000002C` (Allegiance). This is the same
|
||||||
|
mechanism the Options tab pages use (`docs/research/2026-08-10-options-panel-structure.md`
|
||||||
|
§1.2 — page roots typed `0x10000027`/`0x10000028`/`0x10000029`/`0x10000042`).
|
||||||
|
|
||||||
|
**Consequence:** the two LayoutDesc DIDs cannot be recovered from the decomp at all. They
|
||||||
|
are §8 item **U1**.
|
||||||
|
|
||||||
|
### 2.2 Where they mount — the shared `gmPanelUI` page stack, byte-exact
|
||||||
|
|
||||||
|
`gmPanelUI::SetupChildren @ 0x004BC9E0` (`ghidra@0x004BC9E0`) is the decisive evidence. It
|
||||||
|
hard-codes **16** page-stack slot element ids, and for each one reads that slot's own
|
||||||
|
authored `P0x10000029` enum into a `PanelChildInfo { UIElement* child; ulong panelID; }`:
|
||||||
|
|
||||||
|
```
|
||||||
|
this_01 = UIElement::GetChildRecursive(this, 0x1000018b);
|
||||||
|
UIElement::GetAttribute_Enum(this_01, 0x10000029, &local_84);
|
||||||
|
… append {child, panelID} … ← repeated 16×
|
||||||
|
…
|
||||||
|
for each entry: entry.child->SetVisible(false); ← every panel starts hidden
|
||||||
|
```
|
||||||
|
|
||||||
|
The 16 slot ids **in authored call order**:
|
||||||
|
|
||||||
|
| # | Slot element | `P0x10000029` panel id | Identified as |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `0x1000018B` | **7** | Inventory |
|
||||||
|
| 2 | `0x1000018F` | ? | — |
|
||||||
|
| 3 | `0x1000018E` | **11** | Character / Skills |
|
||||||
|
| 4 | `0x10000559` | ? | — |
|
||||||
|
| 5 | `0x1000018C` | ? | — |
|
||||||
|
| 6 | `0x10000182` | ? | — |
|
||||||
|
| 7 | `0x1000018D` | **10** | Options |
|
||||||
|
| 8 | `0x10000190` | **13** | Magic |
|
||||||
|
| 9 | `0x10000184` | **4** | Helpful Effects |
|
||||||
|
| 10 | `0x10000185` | **5** | Harmful Effects |
|
||||||
|
| 11 | `0x10000181` | ? | — |
|
||||||
|
| 12 | `0x10000189` | ? | — |
|
||||||
|
| 13 | `0x10000183` | **3** | Character Information |
|
||||||
|
| 14 | `0x1000018A` | ? | — |
|
||||||
|
| 15 | `0x10000187` | ? | — |
|
||||||
|
| 16 | `0x10000188` | ? | — |
|
||||||
|
|
||||||
|
The identified rows come from `docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md:88-96`
|
||||||
|
(a prior DAT-verified dump of `0x2100006E`) plus
|
||||||
|
`docs/research/2026-08-10-options-panel-structure.md` §1.4 for Options.
|
||||||
|
`0x10000186` is conspicuously **absent** from `SetupChildren` even though it falls inside
|
||||||
|
the contiguous run — flag, not fact.
|
||||||
|
|
||||||
|
**So: Fellowship and Allegiance are two of the ten unidentified slots above** (`0x10000181`,
|
||||||
|
`0x10000182`, `0x10000187`, `0x10000188`, `0x10000189`, `0x1000018A`, `0x1000018C`,
|
||||||
|
`0x1000018F`, `0x10000559`). `RetailPanelCatalog`
|
||||||
|
(`src/AcDream.App/UI/RetailPanelCatalog.cs:10-29`) already claims panel ids
|
||||||
|
3, 4, 5, 7, 8, 9, 10, 11, 13, 15; the unclaimed ids in that space are **1, 2, 6, 12, 14**.
|
||||||
|
Two of those five are Fellowship and Allegiance.
|
||||||
|
|
||||||
|
> **I deliberately did not interpolate.** The slot-id → panel-id mapping *looks* monotonic
|
||||||
|
> over the identified rows (`0x183`→3, `0x184`→4, `0x185`→5, `0x18B`→7, `0x18D`→10,
|
||||||
|
> `0x18E`→11, `0x190`→13), which would put Fellowship/Allegiance at `0x10000181`/`0x10000182`
|
||||||
|
> (panel ids 1 and 2). But four unidentified slots (`0x187`–`0x18A`) sit between panel 5 and
|
||||||
|
> panel 7, where only panel id 6 is free — so monotonicity **provably fails somewhere**, and
|
||||||
|
> an interpolated answer would be a guess. §8 item **U2**.
|
||||||
|
|
||||||
|
### 2.3 One-at-a-time visibility — the behavioural contract the mount inherits
|
||||||
|
|
||||||
|
`gmPanelUI::RecvNotice_SetPanelVisibility @ 0x004BC6F0` (`ghidra@0x004BC6F0`):
|
||||||
|
showing panel *P* looks *P* up in `m_childrenInfoArray`, and if a *different* child is
|
||||||
|
currently shown it reads **that** child's `P0x10000029`, sends
|
||||||
|
`CM_UI::SendNotice_SetPanelVisibility(previousPanelId, false)`, then shows *P* and the
|
||||||
|
host frame. Hiding the active child either falls back to a remembered previous child or
|
||||||
|
hides the host frame entirely. acdream already ports this as
|
||||||
|
`RetailPanelUiController.RegisterMainPanel`
|
||||||
|
(`src/AcDream.App/UI/Layout/RetailPanelUiController.cs:47`), which is what
|
||||||
|
`RetailUiRuntime.MountOptionsPanel` and `MountCharacterInformationPanel` call.
|
||||||
|
|
||||||
|
### 2.4 The acdream seam — what a Fellowship/Allegiance mount will look like
|
||||||
|
|
||||||
|
Two existing mounts are the template, and they are structurally identical:
|
||||||
|
|
||||||
|
| Panel | Import call | acdream source |
|
||||||
|
|---|---|---|
|
||||||
|
| Character Information | `LayoutImporter.ImportInfos(dats, 0x2100006E, 0x10000183)` → `LayoutImporter.Build` → `CharacterController.Bind` → `RegisterIndicatorDetailPanel(RetailPanelCatalog.CharacterInformation, …)` | `src/AcDream.App/UI/RetailUiRuntime.cs:1634-1671`; ids at `src/AcDream.App/UI/Layout/CharacterController.cs:14-15` |
|
||||||
|
| Options | `LayoutImporter.ImportInfos(dats, 0x2100006E, 0x1000018D)` → `Build` → `OptionsPanelController.Bind` → `RegisterMainPanel` | `src/AcDream.App/UI/RetailUiRuntime.cs:2028-2075`; ids at `src/AcDream.App/UI/Layout/OptionsPanelController.cs:40-45` |
|
||||||
|
|
||||||
|
`ImportInfos(host, slotElementId)` returns the **fully base-merged** subtree at that slot
|
||||||
|
(`src/AcDream.App/UI/Layout/OptionsPanelController.cs:9-28` documents the empirical
|
||||||
|
verification), so a Fellowship/Allegiance mount needs **only the slot element id** — not the
|
||||||
|
panel's own LayoutDesc DID. That makes §8 item **U2** the single blocking unknown for
|
||||||
|
mounting, and **U1** merely nice-to-have.
|
||||||
|
|
||||||
|
Contrast: most other panels (Vitae `0x21000020/0x100001C1`, LinkStatus
|
||||||
|
`0x2100001D/0x10000167`, MiniGame `0x2100001E/0x1000016A`, Spellbook
|
||||||
|
`0x21000034/0x100002A8`) are imported from their **own** LayoutDesc rather than through the
|
||||||
|
host. Either route is available; the Character-Info/Options route is the one that gives the
|
||||||
|
retail "one active sibling" behaviour for free.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Element inventories (Q3)
|
||||||
|
|
||||||
|
Every id below is a code-observed `GetChildRecursive` / `GetChildRecursiveTemplate` /
|
||||||
|
`SetAttribute_*` operand. **Geometry (x/y/w/h), media, fonts, and base references are DAT
|
||||||
|
facts and are NOT in this doc** — §8 item **U3**. The DynamicCast id column is retail's own
|
||||||
|
`UIElement::DynamicCast` argument, which is the element `Type`.
|
||||||
|
|
||||||
|
### 3.1 Structural inventory — `gmFellowshipUI` (`0x1000002D`)
|
||||||
|
|
||||||
|
| Element id | Cast/Type | Bound member | Role | Anchor |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `0x1000026B` | *(none)* | `m_pNotInAFellowshipFrame` | **Empty-state frame** — shown when you have no fellowship | `pseudo_c:156290` (`0x0048FF88`) |
|
||||||
|
| `0x1000026F` | `0xC` `UIElement_Text` | `m_pFellowshipNameEntryBox` | **Editable fellowship-name field** (inline, not a dialog) | `pseudo_c:156292,156300` (`0x0048FFA0`) |
|
||||||
|
| `0x10000270` | `0x10000035` `UIOption_Checkbox` | *(local)* | Toggle → `PlayerOption 2 IgnoreFellowshipRequests` | `pseudo_c:156208-156222` (`0x0048FD60`); `ghidra@0x0048FD40:35-52` |
|
||||||
|
| `0x10000271` | `0x10000035` | *(local)* | Toggle → `PlayerOption 0x12 FellowshipAutoAcceptRequests` | `ghidra@0x0048FD40:54-72` |
|
||||||
|
| `0x10000272` | `0x10000035` | *(local)* | Toggle → `PlayerOption 0x0F FellowshipShareXP` | `ghidra@0x0048FD40:74-92` |
|
||||||
|
| `0x10000273` | `0x10000035` | *(local)* | Toggle → `PlayerOption 0x11 FellowshipShareLoot` | `ghidra@0x0048FD40:94-116` |
|
||||||
|
| `0x10000274` | `1` `UIElement_Button` | `m_pCreateFellowshipButton` | **Create Fellowship** | `pseudo_c:156301,156309` (`0x0048FFC6`) |
|
||||||
|
| `0x10000275` | *(none)* | `m_pInAFellowshipFrame` | **Populated-state frame** | `pseudo_c:156291` (`0x0048FF9A`) |
|
||||||
|
| `0x10000276` | `0xC` | `m_pFellowshipName` | Current fellowship's name (display) | `pseudo_c:156310,156318` (`0x0048FFEC`) |
|
||||||
|
| `0x10000277`, `0x10000278` | ? | — | **Not referenced by code** — static labels/column headers. §8 **U4** | — |
|
||||||
|
| `0x10000279` | `5` `UIElement_ListBox` | `m_pFellowsListBox` | **Member list** | `pseudo_c:156319,156327` (`0x00490012`) |
|
||||||
|
| `0x1000027A` | ? | — | **Not referenced by code** — very likely the list's scrollbar. §8 **U4** | — |
|
||||||
|
| `0x1000027B` | `1` | `m_pFellowLeaderButton` | Assign leadership to selected fellow | `pseudo_c:156328,156336` (`0x00490038`) |
|
||||||
|
| `0x1000027C` | `1` | `m_pFellowQuitButton` | Quit fellowship | `pseudo_c:156345` (`0x0049007E`) |
|
||||||
|
| `0x1000027D` | `1` | `m_pFellowOpenButton` | **Open / Close fellowship toggle** (label swaps at runtime, §4.1) | `pseudo_c:156354` (`0x004900A4`) |
|
||||||
|
| `0x1000027E` | `1` | `m_pFellowRecruitButton` | Recruit currently-selected world object | `pseudo_c:156363` (`0x004900CA`) |
|
||||||
|
| `0x1000027F` | `1` | `m_pFellowDismissButton` | Dismiss selected fellow | `pseudo_c:156372` (`0x004900F0`) |
|
||||||
|
| `0x10000280` | `1` | `m_pFellowDisbandButton` | Disband fellowship | `pseudo_c:156381` (`0x00490113`) |
|
||||||
|
| `0x10000281`, `0x10000282` | ? | — | **Not referenced by code**. §8 **U4** | — |
|
||||||
|
|
||||||
|
**Fellow row template** — one row per `Fellow`, built by
|
||||||
|
`UIElement_ListBox::AddItemFromTemplateList(m_pFellowsListBox, 0, nullptr)`
|
||||||
|
(template index **0**), then decorated:
|
||||||
|
|
||||||
|
| Row element | Cast/Type | Content | Anchor |
|
||||||
|
|---|---|---|---|
|
||||||
|
| *(row root)* | — | `SetAttribute_InstanceID(row, 0x1000000D, fellowIid)` — the row's identity key | `ghidra@0x0048F440:95` |
|
||||||
|
| `0x10000283` | `0xC` | Fellow name — `ID_Fellowship_FellowName` + var `ID_Name` | `ghidra@0x0048F440:96-115` |
|
||||||
|
| `0x10000284` | `0xC` | Level + XP-share % — `ID_Fellowship_FellowStats` + vars `ID_Level`, `ID_Experience` | `ghidra@0x0048EB20:51-80` |
|
||||||
|
| `0x10000285` | `7` `UIElement_Meter` | **Health bar** — `SetAttribute_Float(bar, 0x69, cur/max)` | `ghidra@0x0048ED60:50-57` |
|
||||||
|
| `0x10000286` | `0xC` | Health text — `ID_Fellowship_FellowHealthStatus` + `ID_Cur`, `ID_Max` (child **of the meter**) | `ghidra@0x0048ED60:59-73` |
|
||||||
|
| `0x10000287` | `7` | **Stamina bar** | `ghidra@0x0048ED60:80-87` |
|
||||||
|
| `0x10000288` | `0xC` | Stamina text — `ID_Fellowship_FellowStaminaStatus` | `ghidra@0x0048ED60:89-102` |
|
||||||
|
| `0x10000289` | `7` | **Mana bar** | `ghidra@0x0048ED60:108-116` |
|
||||||
|
| `0x1000028A` | `0xC` | Mana text — `ID_Fellowship_FellowManaStatus` | `ghidra@0x0048ED60:117-131` |
|
||||||
|
|
||||||
|
The health/stamina/mana ordering is byte-exact from the `Fellow` field offsets each bar
|
||||||
|
reads: `[0xA]/[7]`, `[0xB]/[8]`, `[0xC]/[9]` (cur/max), in that source order.
|
||||||
|
|
||||||
|
### 3.2 Structural inventory — `gmAllegianceUI` (`0x1000002C`)
|
||||||
|
|
||||||
|
| Element id | Cast/Type | Bound member | Role | Anchor |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `0x10000251` | `0xC` | `m_pAllegianceName` | Your allegiance/character line — `ID_Allegiance_CharacterName` + `ID_Name` | `pseudo_c:157301` (`0x00490F37`/`0x00490F57`) |
|
||||||
|
| `0x10000252` | `0xC` | `m_pPlayerFollowers` | Your follower count — `ID_Allegiance_Followers` + `ID_Followers` | `pseudo_c:157310` (`0x00490F7D`) |
|
||||||
|
| `0x10000253` | `0xC` | `m_pPlayerRank` | Your rank/title — `ID_Allegiance_Rank` or `ID_Allegiance_RankBuffed` | `pseudo_c:157319` (`0x00490FA3`) |
|
||||||
|
| `0x10000254` | ? | — | Not referenced by code. §8 **U4** | — |
|
||||||
|
| `0x10000255` | *(none)* | `m_pMonarchField` | **Monarch block container** — hidden when there is no monarch, or you *are* the monarch | `pseudo_c:157320` (`0x00490FB5`); `ghidra@0x00491B40:78-101` |
|
||||||
|
| `0x10000256` | `0xC` | `m_pMonarchLabel` | Monarch block's caption — text swaps, §4.2 | `pseudo_c:157329` (`0x00490FDB`) |
|
||||||
|
| `0x10000257` | `0xC` | `m_pMonarchName` | Monarch name (or `L" "` when absent) | `pseudo_c:157338` (`0x00491001`) |
|
||||||
|
| `0x10000258` | `0xC` | `m_pMonarchFollowers` | Monarch's follower count | `pseudo_c:157347` (`0x00491027`) |
|
||||||
|
| `0x10000259` | ? | — | Not referenced by code. §8 **U4** | — |
|
||||||
|
| `0x1000025A` | *(none)* | `m_pPatronField` | **Patron block container** — hidden when you have no patron (or your patron == the monarch) | `pseudo_c:157348` (`0x00491039`); `ghidra@0x004917C0` |
|
||||||
|
| `0x1000025B` | ? | — | Not referenced by code. §8 **U4** | — |
|
||||||
|
| `0x1000025C` | `0xC` | `m_pPatronName` | Patron name (or `L" "`) | `pseudo_c:157357` (`0x0049105F`) |
|
||||||
|
| `0x1000025D`–`0x1000025F` | ? | — | Not referenced by code. §8 **U4** | — |
|
||||||
|
| `0x10000260` | `5` `UIElement_ListBox` | `m_pVassalListBox` | **Vassal list** | `pseudo_c:157366` (`0x00491085`) |
|
||||||
|
| `0x10000261` | ? | — | Not referenced by code — very likely the list's scrollbar. §8 **U4** | — |
|
||||||
|
| `0x10000262` | `0x10000035` `UIOption_Checkbox` | *(local)* | Toggle → `PlayerOption 1 IgnoreAllegianceRequests` | `pseudo_c:157274-157292` (`0x00490EB0`) |
|
||||||
|
| `0x10000263` | `1` `UIElement_Button` | `m_pSwearButton` | **Swear Allegiance** to the selected world object | `pseudo_c:157375` (`0x004910AB`) |
|
||||||
|
| `0x10000264` | `1` | `m_pBreakButton` | **Break Allegiance** (from your patron) | `pseudo_c:157384` (`0x004910D1`) |
|
||||||
|
| `0x10000265` | `1` | `m_pKickButton` | **Break Allegiance** of the selected vassal (kick) | `pseudo_c:157393` (`0x004910F7`) |
|
||||||
|
| `0x10000490` | *(none)* | *(child of `m_pMonarchField`)* | Sub-block shown **only when your patron IS the monarch** — carries the XP-passed-up line | `ghidra@0x00491B40:121-123,153-154` |
|
||||||
|
| `0x10000492` | `0xC` | *(child of `0x10000490` / of `m_pPatronField`)* | "Experience passed up" text — `ID_Allegiance_VassalExperiencePassedUp` + `ID_Value`. **Looked up under BOTH `m_pMonarchField/0x10000490` and `m_pPatronField`** | `ghidra@0x00491B40:136-139`; `ghidra@0x004917C0` |
|
||||||
|
|
||||||
|
**Vassal row template** — one row per **direct** vassal, built by
|
||||||
|
`AddItemFromTemplateList(m_pVassalListBox, 0, nullptr)` (template index **0**):
|
||||||
|
|
||||||
|
| Row element | Cast/Type | Content | Anchor |
|
||||||
|
|---|---|---|---|
|
||||||
|
| *(row root)* | — | `SetAttribute_InstanceID(row, 0x10000001, vassalIid)` — **note: a different attribute key from Fellowship's `0x1000000D`** | `ghidra@0x00492340` |
|
||||||
|
| `0x10000268` | `0xC` | Vassal name (`AllegianceData::GetFullName`, set as literal text — **no** StringInfo template) | `ghidra@0x00492340` |
|
||||||
|
| `0x10000269` | `0xC` | XP passed up — `ID_Allegiance_VassalExperiencePassedUp` + `ID_Value` | `ghidra@0x00492340` |
|
||||||
|
| `0x100004AA` | `0xC` | **Offline marker** — `SetVisible(1)` when `!IsLoggedIn`, `SetVisible(0)` when logged in | `ghidra@0x00492340` |
|
||||||
|
|
||||||
|
**The vassal list is FLAT, not a tree.** `UpdateVassalsData` iterates
|
||||||
|
`AllegianceProfile::GetFirstVassal(profile, playerId, &data)` →
|
||||||
|
`GetNextVassal(profile, prevId, &data)` — the player's **direct** vassals only. There is no
|
||||||
|
recursion, no indent level, no expand/collapse. Retail's Allegiance panel shows four things:
|
||||||
|
you, your monarch, your patron, and your direct vassals.
|
||||||
|
|
||||||
|
### 3.3 What is *not* in either panel
|
||||||
|
|
||||||
|
* **No text-entry field on Allegiance.** Swear targets the currently-selected world object;
|
||||||
|
there is no name box.
|
||||||
|
* **No "open fellowship" checkbox.** The open/closed state is a **button** whose caption
|
||||||
|
swaps (`0x1000027D`, §4.1) — asked in the brief; answered: it is not a checkbox.
|
||||||
|
* **No Apply/Reset/Defaults on either panel.** Both fire their wire events immediately.
|
||||||
|
* **No tab control on either panel.** Neither authors a `Type 8` root.
|
||||||
|
* **No allegiance MOTD / officer / ban / hometown UI.** Those `CM_Allegiance` events exist
|
||||||
|
(`Event_SetMotd 0x006A7E70`, `Event_SetAllegianceOfficer 0x006A7C80`,
|
||||||
|
`Event_AddAllegianceBan 0x006A7520`, `Event_RecallAllegianceHometown 0x006A7100`, …) but
|
||||||
|
**no `gmAllegianceUI` code path calls any of them** — they are chat-command-only in 2013.
|
||||||
|
acdream already routes several of them through `ClientCommandController`
|
||||||
|
(`src/AcDream.App/UI/ClientCommandController.cs:334-341`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Runtime text, button state, and the empty state (Q3/Q5)
|
||||||
|
|
||||||
|
### 4.1 `gmFellowshipUI` — every string site
|
||||||
|
|
||||||
|
| Site | VA | String global | Variables |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `UpdateButtons` — `_open_fellow == 0` | `0x0048E96B` | `ID_Fellowship_OpenFellowshipButtonText` | — |
|
||||||
|
| `UpdateButtons` — `_open_fellow != 0` | `0x0048E903` | `ID_Fellowship_CloseFellowshipButtonText` | — |
|
||||||
|
| `Update` (row name) | `0x0048F60E` | `ID_Fellowship_FellowName` | `ID_Name` |
|
||||||
|
| `UpdateFellowStats` | `0x0048ECAF` | `ID_Fellowship_FellowStats` | `ID_Level`, `ID_Experience` |
|
||||||
|
| `UpdateFellowVitals` ×3 | `0x0048EEB7` / `0x0048EF9D` / `0x0048F083` | `ID_Fellowship_FellowHealthStatus` / `…FellowStaminaStatus` / `…FellowManaStatus` | `ID_Cur`, `ID_Max` |
|
||||||
|
| `RecruitFellow` | `0x0048E5E3` / `0x0048E5C2` | `ID_Fellowship_Error_CantRecruitSelf` / `ID_Fellowship_Error_PlayerAlreadyInFellowship` | — |
|
||||||
|
| `DismissFellow` | `0x0048E4FD` / `0x0048E4AE` | `ID_Fellowship_Error_CantDismissSelf` / `ID_Fellowship_Error_DismisseeNotInFellowship` | — |
|
||||||
|
| `AssignLeadershipToFellow` | `0x0048F2xx` | `ID_Fellowship_Error_SelfAlreadyLeader` | — |
|
||||||
|
| `MakeFellowRequestDialog` | `0x00490674` | `ID_Fellowship_FellowshipRequest` | `ID_Player` |
|
||||||
|
|
||||||
|
Thirteen `ID_Fellowship_*` globals exist (`pseudo_c:1147705-1147717`), and all thirteen are
|
||||||
|
consumed above — the family is complete.
|
||||||
|
|
||||||
|
> **The Open/Close button reads as the ACTION, not the state.** `_open_fellow == 0` (a
|
||||||
|
> *closed* fellowship) shows **"Open Fellowship"**. Verified in
|
||||||
|
> `ghidra@0x0048E6C0:109-127`.
|
||||||
|
|
||||||
|
The three error strings go to the chat scroll via
|
||||||
|
`ECM_UI::SendNotice_DisplayStringInfo(0x1A, &si)` — **client-side only**, no wire traffic
|
||||||
|
(`pseudo_c:154820`, `:154867`). *(The `0x1A` channel/LogTextType is not established here —
|
||||||
|
cross-check against `claude-memory/project_chat_digest.md`.)*
|
||||||
|
|
||||||
|
### 4.2 `gmAllegianceUI` — every string site
|
||||||
|
|
||||||
|
| Site | String global | Variables |
|
||||||
|
|---|---|---|
|
||||||
|
| `UpdatePlayerData` | `ID_Allegiance_CharacterName` | `ID_Name` |
|
||||||
|
| `UpdatePlayerData` | `ID_Allegiance_Followers` | `ID_Followers` |
|
||||||
|
| `UpdatePlayerData` (unbuffed rank) | `ID_Allegiance_Rank` | `ID_Title`, `ID_Rank` |
|
||||||
|
| `UpdatePlayerData` (buffed rank) | `ID_Allegiance_RankBuffed` | `ID_Title`, `ID_Rank`, `ID_RankBuff` |
|
||||||
|
| `UpdateMonarchData` (patron **is** monarch) | `ID_Allegiance_PatronSlashMonarchLabel` | — |
|
||||||
|
| `UpdateMonarchData` (patron is not monarch) | `ID_Allegiance_MonarchLabel` | — |
|
||||||
|
| `UpdateMonarchData` / `UpdatePatronData` / `UpdateVassalsData` | `ID_Allegiance_VassalExperiencePassedUp` | `ID_Value` |
|
||||||
|
| `MakeSwearConfirmationDialog` | `ID_Allegiance_SwearConfirmation` | `ID_Player` |
|
||||||
|
| `MakeAcceptSwearConfirmationDialog` | `ID_Allegiance_AcceptSwearConfirmation` | `ID_Player` |
|
||||||
|
| `MakeBreakConfirmationDialog` | `ID_Allegiance_BreakConfirmation` | `ID_Player` |
|
||||||
|
| `MakeKickConfirmationDialog` | `ID_Allegiance_KickConfirmation` | `ID_Player` |
|
||||||
|
|
||||||
|
Eleven `ID_Allegiance_*` globals exist (`pseudo_c:1147783-1147793`); all eleven are consumed.
|
||||||
|
|
||||||
|
The rank-buffed branch is selected by `CBaseQualities::InqInt(qualities, 0x1E, …)` compared
|
||||||
|
against the profile rank (`ghidra@0x00491330:113-117`).
|
||||||
|
|
||||||
|
**The allegiance login/logout chat lines are hard-coded English literals, not string-table
|
||||||
|
entries:** `RecvNotice_AllegianceLogin @ 0x00492220` builds
|
||||||
|
`<name>` + `" is logged in.\n"` / `" has logged out.\n"` and calls
|
||||||
|
`ClientSystem::AddTextToScroll(text, 0, true, 0)` (`ghidra@0x00492220:29-52`). Port note:
|
||||||
|
that is a retail-faithfulness *and* localisation fact worth a divergence-register row if
|
||||||
|
acdream chooses to table-ise it.
|
||||||
|
|
||||||
|
### 4.3 String tables (Q4)
|
||||||
|
|
||||||
|
Every one of the 21 sites passes **table enum `0x10000001`**, which resolves to StringTable
|
||||||
|
DID **`0x23000001`** by the same `enum + 0x13000000` relation the Options work established
|
||||||
|
(`docs/research/2026-08-10-options-panel-structure.md` §1.5 pinned enum `0x10000003` →
|
||||||
|
`0x23000003`). acdream already uses `0x23000001` for Character-Info, Combat, LinkStatus and
|
||||||
|
FPS strings (`src/AcDream.App/UI/RetailUiRuntime.cs:1655,1696-1700`;
|
||||||
|
`src/AcDream.App/UI/Layout/CombatUiController.cs:267`).
|
||||||
|
|
||||||
|
The **two `UIOption_Checkbox` label/tooltip pairs** are different: they pass table enum
|
||||||
|
`0x10000003` → **`0x23000003`** (`pseudo_c:156210`, `:156212`; `pseudo_c:157286`, `:157288`),
|
||||||
|
which is the same table `CharacterOptionsPageController` already resolves
|
||||||
|
(`src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs:84`).
|
||||||
|
|
||||||
|
**How the ids are produced — this is the load-bearing part.** Unlike the Options tab
|
||||||
|
*labels* (authored StringIds in the LayoutDesc), every `ID_*` above is a **runtime name
|
||||||
|
hash**: the globals are `= 0x0` at link time and filled at startup by
|
||||||
|
`ID_Fellowship_FellowName = compute_str_hash("ID_Fellowship_FellowName")`
|
||||||
|
(`pseudo_c:767586-767682` for Fellowship, `:767876-767956` for Allegiance). acdream's
|
||||||
|
`DatStringResolver.ComputeHash(key)` is the exact seam — the same one
|
||||||
|
`MountCharacterInformationPanel` uses (`src/AcDream.App/UI/RetailUiRuntime.cs:1654-1655`).
|
||||||
|
**No byte-verification of these ids is needed or possible; resolve by name.**
|
||||||
|
|
||||||
|
> **Campaign carry-over caveat (the `0x2300000D` lesson).** Campaign CH found that the
|
||||||
|
> TextFilter label family resolved *only* in `0x2300000D` despite the code's table enum
|
||||||
|
> (`src/AcDream.App/UI/Layout/ChatOptionsPageController.cs:99-105`). The enum here says
|
||||||
|
> `0x23000001` / `0x23000003`; **which table these 24 names actually resolve in is §8 item
|
||||||
|
> U5** and must be swept, not assumed.
|
||||||
|
|
||||||
|
### 4.4 Button enable/disable — `gmFellowshipUI::UpdateButtons @ 0x0048E6C0`
|
||||||
|
|
||||||
|
`SetState(1)` = enabled, `SetState(0xD)` = disabled (retail's own convention, matching
|
||||||
|
`docs/research/2026-08-10-options-panel-structure.md` §3.4).
|
||||||
|
|
||||||
|
| Button | Rule |
|
||||||
|
|---|---|
|
||||||
|
| Quit `0x1000027C` | **Always enabled** whenever `m_pFellowship != 0` |
|
||||||
|
| Disband `0x10000280` | Enabled **iff you are the leader** |
|
||||||
|
| Open/Close `0x1000027D` | Enabled **iff you are the leader** |
|
||||||
|
| Leader `0x1000027B` | Leader-only, **and** disabled when the selected fellow *is you* |
|
||||||
|
| Dismiss `0x1000027F` | Leader-only, **and** disabled when the selected fellow *is you*; disabled when nothing is selected |
|
||||||
|
| Recruit `0x1000027E` | Enabled iff the currently-selected **world object** is a player, is not already a fellow, and the fellowship is **not full**. When you are *not* the leader it additionally requires `_open_fellow != 0` |
|
||||||
|
| Create `0x10000274` | Disabled while the name-entry box is empty; enabled otherwise (`ghidra@0x004901C0`, message `0x12`/`0x44`) |
|
||||||
|
|
||||||
|
Both BN zero-idioms decode cleanly:
|
||||||
|
* `SetState(((eax - eax) & 0xc) + 1)` — real sequence `neg/sbb/and 3.. /add` around
|
||||||
|
`Fellowship::IsFull`: **full → `0xD` (disabled), not full → `1` (enabled)**. Ghidra renders
|
||||||
|
it honestly as `(-(iVar4 != 0) & 0xcU) + 1` (`ghidra@0x0048E6C0:97`).
|
||||||
|
* `SetState(((ebx - 1) & 0xfffffff4) + 0xd)` with `ebx = (selectedFellow == you)`:
|
||||||
|
**selected == you → `0xD`, otherwise → `1`**.
|
||||||
|
|
||||||
|
`gmAllegianceUI` (`UpdateSwearButton @ 0x004908E0`, `UpdateBreakButton @ 0x004909D0`,
|
||||||
|
`Update @ 0x00492670`):
|
||||||
|
|
||||||
|
| Button | Rule |
|
||||||
|
|---|---|
|
||||||
|
| Swear `0x10000263` | Enabled **iff** you have **no** patron **and** a world object is selected **and** it is not you **and** it is a player **and** `AllegianceProfile::GetData(selected)` fails (i.e. they are not already in your allegiance). Otherwise `0xD` |
|
||||||
|
| Break `0x10000264` | Enabled **iff** you have a patron |
|
||||||
|
| Kick `0x10000265` | Enabled **iff** a vassal row is selected (`m_iidSelectedVassal != 0`); re-evaluated on every list-selection message |
|
||||||
|
|
||||||
|
### 4.5 Empty-state presentation (Q5)
|
||||||
|
|
||||||
|
**Fellowship — an explicit two-frame swap.** `gmFellowshipUI::Update @ 0x0048F440`:
|
||||||
|
|
||||||
|
```
|
||||||
|
if (ClientFellowshipSystem::GetFellowshipSystem()->m_pFellowship == 0)
|
||||||
|
delete this->m_pFellowship; this->m_pFellowship = null
|
||||||
|
else
|
||||||
|
copy the live CFellowship into this->m_pFellowship
|
||||||
|
|
||||||
|
if (m_pFellowship == null) {
|
||||||
|
UIListener::RegisterForGlobalMessage(this, 3) // per-frame tick, §7.1
|
||||||
|
m_pInAFellowshipFrame ->SetVisible(false) // 0x10000275
|
||||||
|
m_pNotInAFellowshipFrame->SetVisible(true) // 0x1000026B
|
||||||
|
return // nothing else runs
|
||||||
|
}
|
||||||
|
UIListener::UnRegisterForGlobalMessage(this, 3)
|
||||||
|
m_pNotInAFellowshipFrame->SetVisible(false)
|
||||||
|
m_pInAFellowshipFrame ->SetVisible(true)
|
||||||
|
… set m_pFellowshipName, Flush the list, rebuild rows, UpdateFellowSelection …
|
||||||
|
```
|
||||||
|
|
||||||
|
So the "no fellowship" state is **authored**, not synthesised: `0x1000026B` holds whatever
|
||||||
|
retail wants you to see (the name-entry box `0x1000026F` and Create button `0x10000274`
|
||||||
|
almost certainly live inside it — the id ordering `0x1000026B < 0x1000026F < 0x10000274 <
|
||||||
|
0x10000275` supports it, but **containment is a DAT fact**, §8 item **U6**).
|
||||||
|
|
||||||
|
**Allegiance — no frame swap; per-block hiding + blanking.** There is no
|
||||||
|
"not in an allegiance" frame. Instead:
|
||||||
|
* `UpdateMonarchData`: if there is no monarch, **or the monarch is you**, →
|
||||||
|
`m_pMonarchField->SetVisible(false)` and `m_pMonarchName` / `m_pMonarchFollowers` are set
|
||||||
|
to the literal `L" "` (`ghidra@0x00491B40:78-99`).
|
||||||
|
* `UpdatePatronData`: if there is no patron, **or your patron is the monarch**, →
|
||||||
|
`m_pPatronField->SetVisible(false)` and `m_pPatronName = L" "`.
|
||||||
|
*(Careful: the "patron == monarch" case hides the **patron** block and instead relabels
|
||||||
|
the monarch block via `ID_Allegiance_PatronSlashMonarchLabel` and reveals `0x10000490`.)*
|
||||||
|
* `UpdateVassalsData`: `UIElement_ListBox::Flush(m_pVassalListBox)` then
|
||||||
|
`m_iidSelectedVassal = 0`; with no vassals the list simply stays empty.
|
||||||
|
* The player line (`0x10000251`/`0x10000252`/`0x10000253`) is written unconditionally.
|
||||||
|
|
||||||
|
**A live-vs-dim detail worth porting:** `m_pPatronField` is `SetState(1)` when the patron
|
||||||
|
`IsLoggedIn` and `SetState(0xD)` otherwise; `m_pMonarchField` likewise. The vassal rows use
|
||||||
|
a dedicated offline marker element `0x100004AA` instead.
|
||||||
|
|
||||||
|
**Side effect neither panel's name suggests:** both allegiance updates gate chat targets —
|
||||||
|
`gmCCommunicationSystem::SetTalkFocusEnabled(4, patronOnlineAndPresent)`,
|
||||||
|
`(5, monarchOnlineAndPresent)`, `(6, anyVassalOnline)`
|
||||||
|
(`ghidra@0x004917C0`, `ghidra@0x00491B40:216`, `ghidra@0x00492340`). Whatever the retail
|
||||||
|
"talk focus" ids 4/5/6 mean exactly is not established here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dialogs (Q4)
|
||||||
|
|
||||||
|
### 5.1 The five dialogs, and they are all plain Confirmation dialogs
|
||||||
|
|
||||||
|
Every one is built the same way — no dialog LayoutDesc is named in code; the type is
|
||||||
|
selected by a `PropertyCollection` property:
|
||||||
|
|
||||||
|
```
|
||||||
|
StringInfo si; si.SetStringIDandTableEnum(<ID_*>, 0x10000001); si.AddVariable_String(ID_Player, name)
|
||||||
|
PropertyCollection pc
|
||||||
|
prop.SetPropertyName(0x8E); prop.value = 1 // dialog TYPE = 1 (Confirmation)
|
||||||
|
pc.add(prop)
|
||||||
|
prop.SetPropertyName(0xC5); prop.value = &si // MESSAGE
|
||||||
|
pc.add(prop)
|
||||||
|
context = DialogFactory::MakeDialogInCurrentUI(&pc)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Dialog | Owner field | Guard / target | String | VA |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Swear Allegiance** | `m_swearContext` | Target = `ACCWeenieObject::selectedID`; name via `GetObjectName(NAME_APPROPRIATE)`; **no dialog if the name is empty**. On success stores `m_iidPossibleNewPatron = selectedID` | `ID_Allegiance_SwearConfirmation` | `0x004927B0` |
|
||||||
|
| **Accept incoming swear** | `m_acceptSwearContext` | Raised by `RecvNotice_SwearAllegianceRequest(name, serverCtx)`; stores `m_uiAcceptSwearServerContextID = serverCtx` | `ID_Allegiance_AcceptSwearConfirmation` | `0x00492990` |
|
||||||
|
| **Break Allegiance** | `m_breakContext` | Target = `AllegianceProfile::GetPatron(you)`; **no dialog if you have no patron or the name is empty** | `ID_Allegiance_BreakConfirmation` | `0x00492BF0` |
|
||||||
|
| **Kick vassal** | `m_kickContext` | Target = `m_iidSelectedVassal` via `GetData`; stores `m_iidPossibleKickedVassal` | `ID_Allegiance_KickConfirmation` | `0x00492E10` |
|
||||||
|
| **Accept fellowship invite** | `m_fellowRequestContext` | Raised by `RecvNotice_FellowshipRequest(name, serverCtx)`; stores `m_uiAcceptFellowRequestServerContextID` | `ID_Fellowship_FellowshipRequest` | `0x00490620` |
|
||||||
|
|
||||||
|
**All five are re-entrancy-guarded by `if (context == 0)`** — a second request while one is
|
||||||
|
open is dropped on the floor (returns `false`), it does not queue a second dialog.
|
||||||
|
|
||||||
|
### 5.2 On close
|
||||||
|
|
||||||
|
`RecvNotice_CloseDialog(context, PropertyCollection&)` on both classes reads property
|
||||||
|
**`0x8E`** (must be `1`) then property **`0x92`** (the boolean result), then dispatches on
|
||||||
|
which of its context fields matches (`ghidra@0x00492060`, `ghidra@0x0048F2C0`,
|
||||||
|
`pseudo_c:158340-158366`):
|
||||||
|
|
||||||
|
| Dialog | On **accept** | On **reject** |
|
||||||
|
|---|---|---|
|
||||||
|
| Swear | `CM_Allegiance::Event_SwearAllegiance(m_iidPossibleNewPatron)` | nothing |
|
||||||
|
| Accept-swear | `CM_Character::Event_ConfirmationResponse(**1**, m_uiAcceptSwearServerContextID, result)` — **sent either way**, carrying the boolean | same call, `result = 0` |
|
||||||
|
| Break | `CM_Allegiance::Event_BreakAllegiance(GetPatron(you))` | nothing |
|
||||||
|
| Kick | `CM_Allegiance::Event_BreakAllegiance(m_iidPossibleKickedVassal)` | nothing |
|
||||||
|
| Fellow invite | `CM_Character::Event_ConfirmationResponse(**4**, m_uiAcceptFellowRequestServerContextID, result)` — **sent either way** | same call, `result = 0` |
|
||||||
|
|
||||||
|
**Confirmation type ids: `1` = allegiance swear, `4` = fellowship.** Corroborated
|
||||||
|
independently by the abort handlers, which each filter on their own type:
|
||||||
|
`gmAllegianceUI::RecvNotice_AbortConfirmationRequest` acts only `if (arg2 == 1)`
|
||||||
|
(`pseudo_c:157192`), `gmFellowshipUI`'s only `if (arg2 == 4)` (`pseudo_c:154765`). Abort
|
||||||
|
calls `DialogFactory::CloseDialog(context)` and zeroes both the context and the stored
|
||||||
|
server context.
|
||||||
|
|
||||||
|
Destructors also `CloseDialog` their contexts (`pseudo_c:154900` for Fellowship).
|
||||||
|
|
||||||
|
### 5.3 acdream parity — the machinery already exists, exactly
|
||||||
|
|
||||||
|
`src/AcDream.App/UI/Layout/RetailDialogData.cs:9-38` already defines the identical property
|
||||||
|
map and enum:
|
||||||
|
|
||||||
|
```
|
||||||
|
Priority = 0x8D
|
||||||
|
Type = 0x8E ← the property both panels set to 1
|
||||||
|
AcceptLabel = 0x90
|
||||||
|
RejectLabel = 0x91
|
||||||
|
ConfirmationResult = 0x92 ← the property both panels read on close
|
||||||
|
QueueKey = 0xC3
|
||||||
|
Message = 0xC5 ← the StringInfo both panels set
|
||||||
|
enum RetailDialogType { Confirmation = 1, … }
|
||||||
|
```
|
||||||
|
|
||||||
|
and `RetailDialogFactory.MakeDialog(data, callback)`
|
||||||
|
(`src/AcDream.App/UI/Layout/RetailDialogFactory.cs:75`) is the port of
|
||||||
|
`MakeCallbackDialogInCurrentUI`. **No new dialog work is needed for Campaign FA** beyond
|
||||||
|
wiring five `RetailDialogData` builders and their callbacks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Open paths, wire events, and collisions (Q5 + Q3's collision ask)
|
||||||
|
|
||||||
|
### 6.1 Open path — keybind only; **there is no toolbar button**
|
||||||
|
|
||||||
|
| Panel | Input action | Default key | Anchors |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Allegiance | **`0x1000000E` `ToggleAllegiancePanel`** | **F3** | `retail-default.keymap.txt:141`; `keymap-default.txt:132` (`F3 scan=0x3D Action=0x1000000E Activation=0x03`) |
|
||||||
|
| Fellowship | **`0x1000000F` `ToggleFellowshipPanel`** | **F4** | `retail-default.keymap.txt:142`; `keymap-default.txt:133` (`F4 scan=0x3E Action=0x1000000F Activation=0x03`) |
|
||||||
|
|
||||||
|
The retail toolbar's full action row is `0x1000000D`, `0x10000010`, `0x10000013`,
|
||||||
|
`0x100001E7`, `0x10000016`, `0x1000001A`, `0x10000019`
|
||||||
|
(`docs/research/2026-08-10-options-panel-structure.md` §2.2, from the committed
|
||||||
|
`toolbar_21000016.json` fixture) — **neither `0x1000000E` nor `0x1000000F` is present.**
|
||||||
|
Retail opens these two panels from the keyboard only.
|
||||||
|
|
||||||
|
> **Warning — numeric-namespace collision.** `0x1000000E` and `0x1000000F` are *also*
|
||||||
|
> element class ids (`gmKeyboardUI` and `gmPowerbarUI`, `pseudo_c:225419`, `:222537`) and
|
||||||
|
> *also* DB-type enums. Grepping those literals in the pseudo-C returns mostly false
|
||||||
|
> positives; the keymap files are the authority for the action-id reading.
|
||||||
|
|
||||||
|
By the Options-panel pattern the panel root should carry `P0x57 = <its toggle action>` and
|
||||||
|
any close button `P0x12 = <the same action>`
|
||||||
|
(`docs/research/2026-08-10-options-panel-structure.md` §2.3). I did **not** find a
|
||||||
|
`GetAttribute_Enum(this, 0x57, …)` read site in this binary either — the same UNVERIFIED
|
||||||
|
status that doc records. **§8 item U7.**
|
||||||
|
|
||||||
|
Also observed: `gmAllegianceUI::ListenToGlobalMessage(1, 0x27)` clears the vassal list
|
||||||
|
selection (`UIElement_ListBox::SetSelectedItem(m_pVassalListBox, nullptr, true)`,
|
||||||
|
`ghidra@0x004908B0`). Global message `1` is "an input action fired"; action `0x27` is in the
|
||||||
|
small `0x0000xxxx` engine-action family (cf. `Action=0x00000029` = W in
|
||||||
|
`keymap-default.txt:24`) and its identity is **not established** here. §8 item **U8**.
|
||||||
|
|
||||||
|
### 6.2 Element messages and wire events
|
||||||
|
|
||||||
|
`gmFellowshipUI::ListenToElementMessage @ 0x004901C0` switches on `idMessage - 1` via
|
||||||
|
`lookup_table_490418[0x44]` + `jump_table_490408` (`pseudo_c:156546-156638`), then on
|
||||||
|
`idElement - 0x10000274` via `jump_table_49045c[0xD]`:
|
||||||
|
|
||||||
|
| `idMessage` | Meaning | Handling |
|
||||||
|
|---|---|---|
|
||||||
|
| `1` | button clicked | the seven-way element switch below |
|
||||||
|
| `4`, `0x43` | ListBox selection changed | read row's `0x1000000D` → `ACCWeenieObject::SetSelectedObject` (+ target-mode execute) → `m_iidSelectedFellow` → `UpdateButtons` |
|
||||||
|
| `0x12`, `0x44` | text changed | empty name-box → Create `SetState(0xD)`, else `SetState(1)` |
|
||||||
|
|
||||||
|
| Element (`+offset`) | Action | Wire |
|
||||||
|
|---|---|---|
|
||||||
|
| `0x10000274` (+0) | `CreateFellowship` | `CM_Fellowship::Event_Create(name, PlayerModule::FellowshipShareXP())` @ `0x006A67A0` |
|
||||||
|
| `0x1000027B` (+7) | `AssignLeadershipToFellow(m_iidSelectedFellow)` | `CM_Fellowship::Event_AssignNewLeader` @ `0x006A5F70` |
|
||||||
|
| `0x1000027C` (+8) | Quit — **if you are the leader it first hands leadership to `Fellowship::GetNonLeaderFellowID`** | `CM_Fellowship::Event_Quit(**0**)` @ `0x006A61E0` |
|
||||||
|
| `0x1000027D` (+9) | flip `_open_fellow` locally, then send | `CM_Fellowship::Event_ChangeFellowOpeness(newState)` @ `0x006A6040` |
|
||||||
|
| `0x1000027E` (+0xA) | `RecruitFellow(ACCWeenieObject::selectedID)` | `CM_Fellowship::Event_Recruit` @ `0x006A62B0` |
|
||||||
|
| `0x1000027F` (+0xB) | `DismissFellow(m_iidSelectedFellow)` | `CM_Fellowship::Event_Dismiss` @ `0x006A6110` |
|
||||||
|
| `0x10000280` (+0xC) | Disband | `CM_Fellowship::Event_Quit(**1**)` @ `0x006A61E0` |
|
||||||
|
|
||||||
|
**Quit and Disband are the same wire event with a different flag** — `Event_Quit(0)` vs
|
||||||
|
`Event_Quit(1)`. Offsets +1..+6 (`0x10000275`–`0x1000027A`) fall to the default arm, which
|
||||||
|
is consistent with them being the frame, name text, headers, list and scrollbar.
|
||||||
|
|
||||||
|
`CreateFellowship @ 0x0048F730` also runs the typed name through
|
||||||
|
`ACCharGenData::FormatName` and **writes the formatted name back into the entry box** before
|
||||||
|
sending — a visible retail behaviour.
|
||||||
|
|
||||||
|
`gmAllegianceUI::ListenToElementMessage @ 0x00493030` is far simpler
|
||||||
|
(`ghidra@0x00493030`): `idMessage == 1` → `0x10000263`/`0x10000264`/`0x10000265` open the
|
||||||
|
Swear/Break/Kick dialogs; `idMessage == 4` → read the row's `0x10000001` into
|
||||||
|
`m_iidSelectedVassal` and enable/disable Kick. **No text messages, no `0x43`/`0x44` arms.**
|
||||||
|
|
||||||
|
### 6.3 Subscription / refresh lifecycle
|
||||||
|
|
||||||
|
| | Fellowship | Allegiance |
|
||||||
|
|---|---|---|
|
||||||
|
| On show | `OnVisibilityChanged(true)` → `CM_Fellowship::Event_UpdateRequest(1)` (`pseudo_c:154778`) | `OnVisibilityChanged(true)` → `Event_UpdateRequest(1)` + `ClientUISystem::IncrementBusyCount` (hourglass) if not already awaiting |
|
||||||
|
| On hide | `Event_UpdateRequest(0)` | `Event_UpdateRequest(0)` + `DecrementBusyCount` |
|
||||||
|
| On `PostInit` | registers 9 notice handlers + global message 1, then calls `Update` | registers 9 notice handlers + global message 1 + **two quality handlers**, then sends `Event_UpdateRequest(1)` and sets `m_bAwaitingUpdate` |
|
||||||
|
| Quality triggers | — | `RegisterQualityHandlerForThePlayer(IID_StatType /*7*/, 0x19)` and `(7, 0x1A)` = `PropertyInstanceId.Patron = 25` / `Monarch = 26` (`src/AcDream.Core/Properties/PropertyInstanceId.cs:57-58`). Either changing → `Event_UpdateRequest(1)` |
|
||||||
|
| Update arrival | `RecvNotice_FellowshipUpdate` / `_FellowAdded` / `_FellowQuit` / `_FellowDismissed` / `_FellowUpdated` / `_FellowshipDisbanded` | `RecvNotice_AllegianceUpdate` / `_AllegianceUpdateAborted` / `_EnchantmentsChanged` / `_PlayerDescReceived` → `Update` |
|
||||||
|
|
||||||
|
`gmAllegianceUI::Update` runs, in order: `UpdatePlayerData`, `UpdateMonarchData`,
|
||||||
|
`UpdatePatronData`, `UpdateVassalsData`, `UpdateSwearButton`, `UpdateBreakButton`, then
|
||||||
|
Kick's own enable check (`pseudo_c:158677-158692`).
|
||||||
|
|
||||||
|
### 6.4 Element-id collision sweep
|
||||||
|
|
||||||
|
**Code-side (complete for the binary).** Every element id in §3 was counted across the whole
|
||||||
|
1.4 M-line pseudo-C. Each appears **only inside its own class's functions** (1–2 hits, both
|
||||||
|
in the same class). No cross-class code collision exists.
|
||||||
|
|
||||||
|
**Layout-side (partial — 33 committed fixtures only).** I walked every `Id` field in all 33
|
||||||
|
JSON layout fixtures under `tests/AcDream.App.Tests/UI/Layout/fixtures/` against the full
|
||||||
|
candidate range `0x10000251`–`0x1000028A` (both panels' ids plus every unreferenced id
|
||||||
|
between them) — **zero collisions**. That covers options (`0x2100002B`, `0x21000028`,
|
||||||
|
`0x21000029`, `0x2100002A`, `0x2100005C`), keyboard config (`0x21000009`), toolbar, chat,
|
||||||
|
character, inventory, paperdoll, spellbook, combat, radar, vitals, vitae, effects, appraisal,
|
||||||
|
dialogs, vendor, indicators, powerbar, link-status, mini-game, smartbox-FPS.
|
||||||
|
|
||||||
|
**This is not a full clearance.** The campaign's `0x10000211`-in-two-layouts precedent means
|
||||||
|
the real test is the panel's own resolved subtree against the *live* trees acdream mounts,
|
||||||
|
which requires the DAT dump — §8 item **U9**. Two specific risks to check there:
|
||||||
|
|
||||||
|
1. `0x10000490`, `0x10000492`, `0x100004AA` sit in a different, much busier numeric band than
|
||||||
|
the rest of the Allegiance panel and are the most likely to collide.
|
||||||
|
2. `0x10000492` is looked up under **two different parents** in retail
|
||||||
|
(`m_pMonarchField/0x10000490` and `m_pPatronField`) — i.e. **retail itself authors two
|
||||||
|
physical elements under one id**, and both lookups are scoped. A flat
|
||||||
|
`layout.FindElement(0x10000492)` will pick the wrong one. This is exactly the
|
||||||
|
Apply/Reset/Defaults hazard OP4 already solved with a scoped
|
||||||
|
`UiElement.FindDescendant` (`src/AcDream.App/UI/Layout/OptionsPanelController.cs:67-79`) —
|
||||||
|
**use the scoped search from day one.**
|
||||||
|
|
||||||
|
### 6.5 Shared-state collision with the Options panel (not an id collision — a *state* one)
|
||||||
|
|
||||||
|
Five of the six checkboxes these panels host are **duplicates of rows already live on the
|
||||||
|
Options → Character tab**:
|
||||||
|
|
||||||
|
| Panel widget | `PlayerOption` | Also authored on the Character tab |
|
||||||
|
|---|---|---|
|
||||||
|
| Allegiance `0x10000262` | `1` `IgnoreAllegianceRequests` | `CharacterOptionsPageController.cs:195` |
|
||||||
|
| Fellowship `0x10000270` | `2` `IgnoreFellowshipRequests` | `:196` |
|
||||||
|
| Fellowship `0x10000272` | `0x0F` `FellowshipShareXP` | `:198` |
|
||||||
|
| Fellowship `0x10000273` | `0x11` `FellowshipShareLoot` | `:199` |
|
||||||
|
| Fellowship `0x10000271` | `0x12` `FellowshipAutoAcceptRequests` | `:200` |
|
||||||
|
|
||||||
|
All five numeric values match acdream's enum exactly
|
||||||
|
(`src/AcDream.Core.Net/Messages/SocialActions.cs:365-382`). Retail's `UIOption_Checkbox`
|
||||||
|
writes through `PlayerModule::SetOption` and applies **immediately on click**
|
||||||
|
(`docs/research/2026-08-10-options-panel-structure.md` §3.7), so both surfaces are live
|
||||||
|
views of one value — and retail's `CPlayerModule::OnChanged(PlayerOption)` enforces the
|
||||||
|
`IgnoreFellowshipRequests` ↔ `FellowshipAutoAcceptRequests` mutual exclusion centrally
|
||||||
|
(already ported at `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:794-834`).
|
||||||
|
**Port implication:** these six checkboxes must bind to
|
||||||
|
`RuntimeCharacterOptionsState`, not to panel-local state, and both surfaces must refresh
|
||||||
|
when the other writes.
|
||||||
|
|
||||||
|
### 6.6 Widget-kind parity in acdream
|
||||||
|
|
||||||
|
| Retail Type | acdream factory arm | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `1` Button | `DatWidgetFactory` (button arm) | ✔ |
|
||||||
|
| `5` `UIElement_ListBox` | `UiTemplateListBox` (`DatWidgetFactory.cs:105`) | ✔ built, **but see gap below** |
|
||||||
|
| `7` `UIElement_Meter` | `BuildMeter` → `UiMeter` (`DatWidgetFactory.cs:108`) | ✔ (`UiMeter.Fill` is the `SetAttribute_Float(0x69)` seam) |
|
||||||
|
| `0xC` `UIElement_Text` | `BuildText` (`DatWidgetFactory.cs:122`) | ✔ |
|
||||||
|
| `0x10000035` `UIOption_Checkbox` | `BuildCheckbox` (`DatWidgetFactory.cs:125`) | ✔ |
|
||||||
|
|
||||||
|
**Gap found:** `UiTemplateListBox`
|
||||||
|
(`src/AcDream.App/UI/UiTemplateListBox.cs:81-229`) exposes `AddItemFromTemplateList(index)`
|
||||||
|
and `AddPrebuiltRow(row)` — but **no `Flush`, no selection model
|
||||||
|
(`SetSelectedItem`/`GetSelectedIndex`), and no per-row instance-id attribute**. Both panels
|
||||||
|
need all three (`Flush` on every `Update`; selection to drive Dismiss/Leader/Kick; the row
|
||||||
|
attribute `0x1000000D`/`0x10000001` as the identity key). This is a bounded, real
|
||||||
|
prerequisite for Campaign FA implementation — flagging it here so the planner sizes it.
|
||||||
|
|
||||||
|
Retail detail that matters for the Open/Close button: `UpdateButtons` calls
|
||||||
|
`UIElement_Text::SetText((UIElement_Text*)m_pFellowOpenButton, …)` — i.e. retail's
|
||||||
|
`UIElement_Button` **derives from** `UIElement_Text` and a button's caption is set through
|
||||||
|
the text API (`ghidra@0x0048E6C0:115,129`). acdream's button caption seam must allow the
|
||||||
|
same runtime relabel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Notes and loose ends worth carrying
|
||||||
|
|
||||||
|
### 7.1 `gmFellowshipUI::ListenToGlobalMessage` is a folded no-op — and message 3 is the frame tick
|
||||||
|
|
||||||
|
`symbols.json` gives `gmFellowshipUI::ListenToGlobalMessage = 0x004F5860`, which is an
|
||||||
|
**empty function shared by identical-COMDAT folding** with
|
||||||
|
`gmPanelUI::ListenToGlobalMessage`, `gmPanelUI::RecvNotice_PlayerDescReceived`,
|
||||||
|
`NoticeHandler::RecvNotice_ItemAttributesChanged` and `NoticeHandler::RecvNotice_DisplayWeenieError`
|
||||||
|
(`pseudo_c:248560` / `ghidra@0x004F5860`). So Fellowship handles **no** global message
|
||||||
|
directly, despite `PostInit` registering for message 1 and `Update` toggling registration
|
||||||
|
for message 3.
|
||||||
|
|
||||||
|
Global message **3** is byte-identified: `UIElementManager::UseTime @ 0x0045CFD0` calls
|
||||||
|
`BroadcastGlobalMessage(this, 3, 0)` once per frame, between `CheckTooltip` and
|
||||||
|
`DrawDirtyRegions` (`pseudo_c:102945`). So the register/unregister pattern is
|
||||||
|
"**tick me every frame while I have no fellowship**" — plausibly for the name-entry box's
|
||||||
|
caret, but the *effect* is UNVERIFIED because Fellowship's own handler is empty. Recorded,
|
||||||
|
not asserted. §8 item **U8**.
|
||||||
|
|
||||||
|
### 7.2 `RegisterNoticeHandler` operands are code addresses, not ids
|
||||||
|
|
||||||
|
`PostInit` registers 9 handlers on each panel with operands like `0x4DD20F`, `0x4DD210`,
|
||||||
|
`0x4DD246`, `0x4DD1EF`, `0x186A4` (Fellowship, `pseudo_c:156384-156392`) and `0x4DD1F0`,
|
||||||
|
`0x4DD201`, `0x4DD205`–`0x4DD208`, `0x4DD20D`, `0x4DD1EF`, `0x186A4` (Allegiance,
|
||||||
|
`pseudo_c:157398-157406`). The `0x4DD…` values are inside the code section, i.e. they are
|
||||||
|
**thunk/notice-slot addresses**, not notice ids; `0x186A4` (= 100004) looks like a genuine
|
||||||
|
numeric id. Same shape as `gmConfigUI`'s `RegisterNoticeHandler(0x4DD276, …)` recorded in
|
||||||
|
the Options doc. Not decoded here; not needed for panel structure. §8 item **U8**.
|
||||||
|
|
||||||
|
### 7.3 Two different row-identity attribute keys
|
||||||
|
|
||||||
|
Fellowship rows key on `0x1000000D`; Allegiance rows key on `0x10000001`. Both go through
|
||||||
|
`SetAttribute_InstanceID` / `GetAttribute_InstanceID`. Do **not** unify them in the port —
|
||||||
|
they are different authored attribute names and a shared row helper must take the key as a
|
||||||
|
parameter. (Both values also exist as element class ids —
|
||||||
|
`0x1000000D gmExternalContainerUI`, `0x10000001 gmUIElement_BurdenIndicator` — a different
|
||||||
|
namespace. Don't cross the wires.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Unknowns for the coordinator
|
||||||
|
|
||||||
|
Everything below is genuinely not established from the decomp. **U2** is the only one that
|
||||||
|
blocks a mount.
|
||||||
|
|
||||||
|
| # | Unknown | Why the decomp can't answer it | Verification recipe |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **U1** | The two panels' own **LayoutDesc DIDs** | Both classes are instantiated from an authored element by `Type`; no DID is loaded in code (§2.1) | DAT sweep: enumerate LayoutDescs and find the two whose element tree contains `Type == 0x1000002D` / `Type == 0x1000002C` |
|
||||||
|
| **U2** | **Which `0x2100006E` slot element** hosts each panel, and its `P0x10000029` panel id | `SetupChildren` hard-codes the 16 slot ids but reads the key from the DAT (§2.2) | For each of the 16 ids in §2.2's table, `LayoutImporter.ImportInfos(dats, 0x2100006E, slotId)` and read (a) `P0x10000029` on the slot, (b) the subtree root's `Type`. Report the full 16-row table — it also closes the 10 currently-unidentified `RetailPanelCatalog` entries in one pass |
|
||||||
|
| **U3** | **Geometry, media, fonts, base refs** for every element in §3.1/§3.2 | Code reveals ids, not layout | Dump both slot subtrees to fixtures (`fellowship_2100006E_<slot>.json`, `allegiance_2100006E_<slot>.json`) exactly like `options_panel_2100006E_1000018D.json` |
|
||||||
|
| **U4** | The **code-untouched element ids**: Fellowship `0x10000277`, `0x10000278`, `0x1000027A`, `0x10000281`, `0x10000282`; Allegiance `0x10000254`, `0x10000259`, `0x1000025B`, `0x1000025D`–`0x1000025F`, `0x10000261`, `0x10000266`, `0x10000267`, `0x1000026A` | Static labels, column headers, scrollbars and borders never appear in code | Falls out of U3's dump. Expect the two `…27A` / `…261` candidates to be the two ListBox scrollbars (`ScrollbarElementId`) |
|
||||||
|
| **U5** | Which StringTable the 24 `ID_Fellowship_*` / `ID_Allegiance_*` / 6 `ID_PlayerOption_*` names actually resolve in | Code says table enum `0x10000001` → `0x23000001` (and `0x10000003` → `0x23000003`), but the Campaign CH `0x2300000D` precedent shows the enum can be wrong | Exhaustive sweep: for each of the 24 names, `ComputeHash(name)` and probe every `0x23xxxxxx` table (and `0x78000000`, the DAT caption catalog CH used for chat captions). Report the resident table per family, not per name |
|
||||||
|
| **U6** | Whether `0x1000026F` (name box) and `0x10000274` (Create button) are **children of** `0x1000026B` (`m_pNotInAFellowshipFrame`), and whether the list + six buttons are children of `0x10000275` | `GetChildRecursive` is called from the panel root, so containment is invisible to code | Falls out of U3's dump. It decides whether acdream's empty-state is one `SetVisible` or many |
|
||||||
|
| **U7** | Whether the two panel roots carry `P0x57 = 0x1000000E` / `0x1000000F`, and whether either panel authors a close (X) button with `P0x12 = <the same action>` | No `GetAttribute_Enum(this, 0x57, …)` read site exists in the binary — the same UNVERIFIED status the Options doc records (its §2.3) | Falls out of U3's dump (read `P0x57` on the roots and `P0x12` on any `Type 1` child not in §3's tables) |
|
||||||
|
| **U8** | Three low-stakes semantics: input action `0x27` (clears vassal selection, §6.1); the `RegisterNoticeHandler(0x4DD…)` operands (§7.2); the *effect* of Fellowship's message-3 registration given its empty handler (§7.1) | Would need either a cdb trace or notice-table reversing | Only worth doing if a behaviour gap shows up in a connected gate |
|
||||||
|
| **U9** | **Full element-id collision clearance** across every layout acdream mounts | I could only sweep the 33 committed fixtures (§6.4) | After U3, run both panels' resolved id sets against every currently-mounted layout's id set. Prioritise `0x10000490`, `0x10000492`, `0x100004AA`, and treat `0x10000492`'s **intra-panel** duplication as a hard requirement for scoped lookups |
|
||||||
|
| **U10** | The row-template arrays: what LayoutDesc DID + element id sits at **index 0** of `P0x64` on `0x10000279` (fellows) and `0x10000260` (vassals) | Template arrays are authored on the ListBox (Options doc §1.5) | Falls out of U3's dump — read `P0x64` on both ListBoxes and dump the referenced template elements as their own fixtures |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Executive summary
|
||||||
|
|
||||||
|
1. **Fellowship and Allegiance are two separate `UIElement` classes** — `gmFellowshipUI`
|
||||||
|
(class id `0x1000002D`, `Register @ 0x0048E6A0`) and `gmAllegianceUI` (`0x1000002C`,
|
||||||
|
`0x004911F0`), both deriving from `UIElement_Field` — **not tabs of a social panel; no
|
||||||
|
`gmSocialUI` exists in the binary.** Each mounts as its own sibling in the shared
|
||||||
|
`gmPanelUI` page stack inside floaty host `0x2100006E`, exactly like Character Info
|
||||||
|
(`0x10000183`) and Options (`0x1000018D`), and inherits retail's one-active-sibling
|
||||||
|
visibility contract for free.
|
||||||
|
2. **The complete element inventory is recovered from code** — 19 bound elements + a 9-part
|
||||||
|
fellow row template (name, level/XP%, and health/stamina/mana meter+text triples) for
|
||||||
|
Fellowship; 15 bound elements + a 3-part vassal row for Allegiance, whose vassal list is
|
||||||
|
**flat (direct vassals only), not a tree**. Rows on both panels are built by
|
||||||
|
`AddItemFromTemplateList(listbox, 0, nullptr)` and keyed by a row instance-id attribute —
|
||||||
|
`0x1000000D` on Fellowship, `0x10000001` on Allegiance. The fellowship name is an
|
||||||
|
**inline editable text field** (`0x1000026F`), and "open fellowship" is a **button whose
|
||||||
|
caption swaps** (`0x1000027D`), not a checkbox.
|
||||||
|
3. **All five dialogs are plain Confirmation dialogs** built from `PropertyCollection`
|
||||||
|
`0x8E = 1` (Type) + `0xC5` (Message) and answered by `0x92` (Result) — precisely the
|
||||||
|
contract `RetailDialogData`/`RetailDialogFactory` already implements. Confirmation type
|
||||||
|
ids are **1 = allegiance swear**, **4 = fellowship**. Every panel string resolves by
|
||||||
|
**runtime name hash** (`compute_str_hash("ID_Fellowship_…")`) in table enum `0x10000001`
|
||||||
|
→ `0x23000001` (the two option checkboxes use `0x10000003` → `0x23000003`), so acdream's
|
||||||
|
existing `DatStringResolver.ComputeHash` seam is the whole job — but which table the
|
||||||
|
names *actually* live in still needs the `0x2300000D`-style sweep.
|
||||||
|
4. **Open path is keyboard-only:** `ToggleAllegiancePanel = 0x1000000E` (**F3**) and
|
||||||
|
`ToggleFellowshipPanel = 0x1000000F` (**F4**); neither action appears on the retail
|
||||||
|
toolbar. Empty state differs by panel — Fellowship swaps two **authored** frames
|
||||||
|
(`0x1000026B` no-fellowship / `0x10000275` in-fellowship), Allegiance has no frame swap and
|
||||||
|
instead hides the monarch/patron blocks and blanks their text to `L" "`.
|
||||||
|
5. **Blocking unknown: exactly one.** Which of the 16 `gmPanelUI::SetupChildren` slot ids
|
||||||
|
hosts each panel (§8 **U2**) — everything else is a nice-to-have dump. Two findings the
|
||||||
|
planner should size now: `UiTemplateListBox` has **no `Flush`, no selection model and no
|
||||||
|
row instance-id**, all three of which both panels need; and retail authors **`0x10000492`
|
||||||
|
twice inside the Allegiance panel** (under the monarch block and under the patron block),
|
||||||
|
so scoped `FindDescendant` lookups are mandatory from day one, not a later fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. COORDINATOR ADDENDUM (2026-08-11) — U2 CLOSED by live-DAT dump; §2's mounting call CORRECTED
|
||||||
|
|
||||||
|
`tests/AcDream.App.Tests/UI/Layout/FaPanelSlotProbeTests.cs`
|
||||||
|
(`ACDREAM_PROBE_LIVE_MOUNT=1`) dumped all 16 authored slots + `0x10000186`
|
||||||
|
against the installed DATs. Primary results:
|
||||||
|
|
||||||
|
**Fellowship and Allegiance are NOT separate gmPanelUI siblings — they are
|
||||||
|
two of FOUR pages of ONE tabbed social panel** at slot **`0x1000018F`**,
|
||||||
|
`P0x10000029` panel id **12**, root Type **8** (the same authored tab-host
|
||||||
|
class OP2/OP3 already ported). Pages, with their `RegisterElementClass`
|
||||||
|
identities:
|
||||||
|
|
||||||
|
| Tab button (x-order) | Page element | Page class | Panel |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `0x1000028C` (x=0) | `0x10000513` | `0x10000045` = `gmFriendsUI` (`@0048e21a`) | Friends |
|
||||||
|
| `0x1000028E` (x=72) | `0x10000291` | `0x1000002C` = `gmAllegianceUI` | **Allegiance** |
|
||||||
|
| `0x10000512` (x=144) | `0x10000292` | `0x1000002D` = `gmFellowshipUI` | **Fellowship** |
|
||||||
|
| `0x1000053B` (x=206) | `0x1000054A` | `0x10000047` = `gmSquelchUI` (`@0048d04a`) | Squelch |
|
||||||
|
|
||||||
|
(The button→page pairing above is by authored order and needs the tab
|
||||||
|
TABLE property (0x2E) read to pin exactly — one fixture dump closes it,
|
||||||
|
same as U3.)
|
||||||
|
|
||||||
|
Every lane-C allegiance element id is CONFIRMED present under page
|
||||||
|
`0x10000291`: swear `0x10000263` / break `0x10000264` / kick `0x10000265`
|
||||||
|
(three 88×33 buttons at y=562), vassal ListBox `0x10000260` (Type 5,
|
||||||
|
279×350) + scrollbar `0x10000261`, monarch/patron/self blocks
|
||||||
|
`0x10000250`/`0x10000255`/`0x1000025A`, meter `0x10000262`. The
|
||||||
|
fellowship page carries the §3.1 inventory including the two authored
|
||||||
|
empty/full frames `0x10000275`/`0x1000026B`.
|
||||||
|
|
||||||
|
The full 16-slot table also closes the RetailPanelCatalog unknowns:
|
||||||
|
`0x10000181`=1 gmAbuseUI, `0x10000182`=2 gmBookUI, `0x10000187`=8
|
||||||
|
gmLinkStatusUI, `0x10000188`=9 gmMiniGameUI, `0x1000018F`=12 the social
|
||||||
|
panel, `0x10000189`=14 gmUrgentAssistanceUI, `0x1000018A`=15 gmVitaeUI,
|
||||||
|
`0x1000018C`=16 gmMapUI+gmHouseUI pages, `0x10000559`=25
|
||||||
|
gmJournalUI/gmPageListUI/gmContractsUI. `0x10000186` does not resolve
|
||||||
|
(IMPORT NULL) — consistent with §2.2's "conspicuously absent" flag.
|
||||||
|
|
||||||
|
**Plan impact:** Campaign FA mounts ONE panel (id 12) with the OP3
|
||||||
|
`OptionsPanelController` recipe (Type-8 host, `ActivateTabBehavior`,
|
||||||
|
per-page scoped controllers); Friends and Squelch pages ride along and
|
||||||
|
map naturally onto `RuntimeCommunicationState`'s existing friends/squelch
|
||||||
|
owners (J4.1) — scope decision deferred to the plan. U3/U4/U6/U7/U10's
|
||||||
|
fixture dump should target slot `0x1000018F`.
|
||||||
95
tests/AcDream.App.Tests/UI/Layout/FaPanelSlotProbeTests.cs
Normal file
95
tests/AcDream.App.Tests/UI/Layout/FaPanelSlotProbeTests.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
using System.IO;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FA lane-A unknown U2 (docs/research/2026-08-11-fa-panel-structure.md
|
||||||
|
/// §8): which of the 16 gmPanelUI page-stack slots host the Fellowship and
|
||||||
|
/// Allegiance panels, and each slot's authored P0x10000029 panel id. Dumps the
|
||||||
|
/// full 16-row table against the live DATs — closing U2 (and the ten
|
||||||
|
/// unidentified RetailPanelCatalog entries) in one pass. Signature elements
|
||||||
|
/// identify the two FA slots definitively: 0x1000026F (fellowship name field)
|
||||||
|
/// and 0x10000263 (swear-allegiance button). Env-gated like the other probes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FaPanelSlotProbeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ProbePanelSlotTable()
|
||||||
|
{
|
||||||
|
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
|
||||||
|
return;
|
||||||
|
|
||||||
|
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
"Documents",
|
||||||
|
"Asheron's Call");
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
// The 16 authored slot ids, in gmPanelUI::SetupChildren call order
|
||||||
|
// (lane-A doc §2.2), plus the conspicuously-absent 0x10000186.
|
||||||
|
foreach (uint slotId in new[]
|
||||||
|
{
|
||||||
|
0x1000018Bu, 0x1000018Fu, 0x1000018Eu, 0x10000559u,
|
||||||
|
0x1000018Cu, 0x10000182u, 0x1000018Du, 0x10000190u,
|
||||||
|
0x10000184u, 0x10000185u, 0x10000181u, 0x10000189u,
|
||||||
|
0x10000183u, 0x1000018Au, 0x10000187u, 0x10000188u,
|
||||||
|
0x10000186u,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
ElementInfo? slot = LayoutImporter.ImportInfos(dats, 0x2100006Eu, slotId);
|
||||||
|
if (slot is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[faslot] 0x{slotId:X8} -> IMPORT NULL");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string panelId = slot.TryGetEffectiveProperty(0x10000029u, out var p)
|
||||||
|
? $"{p.UnsignedValue} (kind={p.Kind})"
|
||||||
|
: "ABSENT";
|
||||||
|
|
||||||
|
bool hasFellowshipName = FindInfo(slot, 0x1000026Fu);
|
||||||
|
// Allegiance signature set (lane A/C ids): swear 0x10000263,
|
||||||
|
// break 0x10000264, kick 0x10000265, row texts 0x10000268/69,
|
||||||
|
// and the doubled 0x10000492 monarch/patron child.
|
||||||
|
bool hasAllegiance = FindInfo(slot, 0x10000263u) || FindInfo(slot, 0x10000264u)
|
||||||
|
|| FindInfo(slot, 0x10000265u) || FindInfo(slot, 0x10000268u)
|
||||||
|
|| FindInfo(slot, 0x10000269u) || FindInfo(slot, 0x10000492u);
|
||||||
|
string family = hasFellowshipName ? " <= FELLOWSHIP"
|
||||||
|
: hasAllegiance ? " <= ALLEGIANCE"
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[faslot] 0x{slotId:X8} panelId={panelId} type={slot.Type} "
|
||||||
|
+ $"({slot.X},{slot.Y} {slot.Width}x{slot.Height}) "
|
||||||
|
+ $"children={slot.Children.Count}{family}");
|
||||||
|
|
||||||
|
// The unidentified slots: dump two levels of child ids so the
|
||||||
|
// allegiance panel can be identified by SHAPE if its recovered
|
||||||
|
// element ids are absent from the base-merge.
|
||||||
|
bool unidentified = slotId is 0x1000018Fu;
|
||||||
|
if (unidentified)
|
||||||
|
{
|
||||||
|
foreach (ElementInfo c in slot.Children)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[faslot] child 0x{c.Id:X8} type={c.Type} ({c.X},{c.Y} {c.Width}x{c.Height}) kids={c.Children.Count}");
|
||||||
|
foreach (ElementInfo g in c.Children)
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[faslot] g 0x{g.Id:X8} type={g.Type} ({g.X},{g.Y} {g.Width}x{g.Height})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool FindInfo(ElementInfo info, uint id)
|
||||||
|
{
|
||||||
|
if (info.Id == id) return true;
|
||||||
|
foreach (ElementInfo c in info.Children)
|
||||||
|
if (FindInfo(c, id)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue