docs: Campaign OP research — character option map (lane B) + 0x01A1 wire (lane C)

Two of the four Opus research lanes for the retail Options panel campaign
(docs/research/2026-08-10-settings-track-handoff.md):

- Lane B: the complete Character-tab option map. 50 rows / 6 groups
  decomp-authored from gmCharacterSettingsUI::InitOptions @0x004a02f0
  (the screenshots' PK-death row is 2015-client-only; the 2013 enum caps
  at 0x33). Wire routing is retail's byte-verified lookup table
  CPlayerModule::IsAutoSaveOption @0x0059a600 — ~21 ids send 0x0005
  immediately, the rest dirty the module for the batched 0x01A1. Retail's
  Defaults-button table reconstructs Options1 = 0x50C4A54A exactly
  (independent confirmation of ACE's constant) and Options2 = 0x00008700
  vs ACE creation's 0x00948700 (a real client-vs-server distinction, not
  a bug). Per-option ACE handling + acdream consumer inventory included.

- Lane C: the real 0x01A1 body is PlayerModule::Pack @0x005D45C0
  (builder CM_Character::Event_CharacterOptionsEvent @0x006A10C0), flag
  enum PlayerModulePackHeader verbatim at acclient.h:7835;
  SetPackHeader @0x005D44A0 always sets 0x460 and never 0x02/0x04/0x10/
  0x80, so ACE's extra reader branches are dead legacy. Flush triggers:
  Apply, logout, 480 s autosave. ACE stores options words raw, discards
  the rest, refuses only pre-LoginComplete; unknown option ids THROW.
  CH3 post-mortem: the deleted 16-byte builder put a CharacterOptions1
  word in the section-flag slot.

Both lanes independently converged on the same latent defect: the
headless DirectGameRuntimeCommandAdapter.SetSingleOption sends the wire
but skips the local Options.SetOptionBit write the graphical path does
(LiveSessionRuntimeFactory.cs:348) — the CH4 stale-membership-gate bug
class reproduced on the bot side. Flagged for the campaign plan, not
fixed here (research-only lanes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 22:37:46 +02:00
parent 629d83411d
commit cf6ef8b4b4
2 changed files with 1624 additions and 0 deletions

View file

@ -0,0 +1,615 @@
# Character-tab option map — settings-track research lane B
**Campaign:** settings track (retail four-tab Options panel).
Handoff: `docs/research/2026-08-10-settings-track-handoff.md`.
**Lane:** B — handoff research questions **Q2** (complete Character-tab
option map), **Q7** (which options have live acdream consumers), and the
**bot-relevance half of Q8**.
**Date:** 2026-08-10. **Mode:** research only — no code changed, nothing
built, nothing launched.
**Sources.** `docs/research/named-retail/acclient.h` (verbatim retail
`PlayerOption` enum + `PlayerModule`/`UIOption_Checkbox` structs),
`docs/research/named-retail/acclient_2013_pseudo_c.txt` (BN pseudo-C),
the PDB-paired binary at `C:\Users\erikn\Downloads\acclient.exe`
(`check_exe_pdb.py``=== MATCH ===`, GUID
`9e847e2f-777c-4bd9-886c-22256bb87f32`, linker 2013-09-06T00:17:56Z),
vendored ACE at `references/ACE/`, and acdream `src/` in this worktree.
---
## 0. Verdict summary
| Question | Answer |
|---|---|
| Do we have the complete Character-tab row list? | **Yes, and it is decomp-authored, not inferred.** `gmCharacterSettingsUI::InitOptions @0x004a02f0` emits exactly **50 toggle rows in 6 header groups**, in an order that matches the user's three screenshots **row-for-row**. |
| Is ACE's option-id → bit map right? | **Yes, byte-verified.** The client's own `PlayerModule::GetOption @0x005d3aa0` switch assigns the same bit to all 52 options present in the 2013 build. `CharacterOptions1.Default = 0x50C4A54A` also reconstructs exactly from retail's own per-option default table (§1.4). |
| 0x0005 vs 0x01A1 — what decides? | **`CPlayerModule::IsAutoSaveOption @0x0059a600`**, a 0x34-byte lookup table (byte-verified, §1.3). 21 of 53 options send `SetSingleCharacterOption (0x0005)` **immediately**; the other 32 only mark the module dirty and ride the batched `SetCharacterOptions (0x01A1)` blob 480 s later or at forced save. |
| Does the Apply button send the blob? | **Not necessarily.** Each checkbox applies through `PlayerModule::SetOption` the moment Apply runs; auto-save ids leave as 0x0005 and never enter the blob. ACE's own header comment in `GameActionSetCharacterOptions.cs:11-16` independently describes this behaviour (and its stated rule — "options with a value set in the enum" — is **wrong**; all 53 have values. The real rule is the `IsAutoSaveOption` table). |
| Does acdream track these today? | **Two bits are live; everything else is either a local `settings.json` bool the server never sees, or absent.** 6 `Hear*Chat` bits drive `TurbineChatMembershipGate`; `DragItemOnPlayerOpensSecureTrade` drives item interaction. That is the whole list. |
| Is there a shared Runtime seam for bots? | **Yes, it already exists**: `IRuntimeCharacterCommands.SetSingleOption` (`src/AcDream.Runtime/GameRuntimeCommands.cs:249`), implemented by both hosts. It has **one defect** (§4.4): the headless implementation skips the local option-bit write the graphical one does. |
| Does ACE reject option changes from a live session? | **0x0005: no gate at all.** **0x01A1: refused before `FirstEnterWorldDone`.** But an *unknown* option id makes ACE throw (§5.3) — a real bot-safety constraint. |
Two divergences worth naming immediately, because they are behavioural
and currently unfiled:
1. **`/acceptcorpselooting` toggles a local bool the server never sees.**
`ClientCommandController.cs:444-450` flips
`GameplaySettings.AcceptLootPermits` only. Retail sends
`0x0005 (0x10)` immediately (auto-save), and ACE honours the bit at
`Player_Death.cs:755`. Today the corpse permission is a lie.
2. **`AutoRepeatAttack` is client-local in acdream but server-authoritative
in ACE.** `LiveCombatAttackOperations.cs:95,169` reads the local
setting; ACE independently re-attacks based on its own stored bit
(`Player_Melee.cs:375`, `Player_Missile.cs:284`). The two can disagree.
---
## 1. The retail mechanism (settled, byte-verified)
### 1.1 Storage
`PlayerModule` (`acclient.h:36507`) holds the two option words plus the
non-boolean option payloads:
```c
struct __cppobj PlayerModule : PackObj {
ShortCutManager *shortcuts_;
PackableList<unsigned long> favorite_spells_[8];
PackableHashTable<IDClass<_tagDataID,32,0>,long> *desired_comps_;
unsigned int options_; // CharacterOptions1
unsigned int options2_; // CharacterOptions2
unsigned int spell_filters_;
GenericQualitiesData *m_pPlayerOptionsData;
PackObjPropertyCollection m_colGameplayOptions;
AC1Legacy::PStringBase<char> m_TimeStampFormat;
};
```
The linear id space is `enum PlayerOption` (`acclient.h:4162-4217`),
`0x00`..`0x33`, terminated by
`TotalNumberOfPlayerOptions_PlayerOption = 0x34`. **The 2013 build has no
`0x34`.** ACE's `ListenToPKDeathMessages = 0x34` is a post-PDB addition
(ACE says so itself at `PlayerFactory.cs:659` — *"possibly was added to
Defaults post PDB we have"*).
### 1.2 Two kinds of option row — the tab discriminator
`UIOption_Checkbox` (`acclient.h:6108`) carries **both** a
`PlayerOption m_playerOption` and a `PStringBase<char> m_prefName` /
`unsigned int m_propName`. `UIOption_Checkbox::GetValue @0x00486f60`
branches on which is set:
* `m_playerOption != Invalid (0xFFFFFFFF)``PlayerModule::GetOption`
the server-synced character option.
* otherwise → `UIPreferences::InqPreferenceValue(m_prefName)` → a
**client-local preference**, applied via
`UIPreferences::ModifyPreference` + `CM_UI::SendNotice_UserPreferenceChanged`
(the apply path at `0x00486e2b`-`0x00486e49`).
**Cross-lane fact, cheap to state here:** `gmConfigUI::InitOptions
@0x0049e400` and `gmChatOptionsUI::InitOptions @0x0049fc60` contain
**zero** `AddToggleOption(<X>_PlayerOption)` calls (12 `AddHeader` +
12 `AddSeperator` between them, no PlayerOption rows). Every
server-synced character option in retail lives on the **Character tab**;
Config and Chat are `m_prefName`/per-window-blob territory.
### 1.3 Apply → wire: `IsAutoSaveOption`
```
PlayerModule::SetOption(opt, v) @0x005d3eb0
→ writes the bit into options_ / options2_
→ virtual vtable+0x14 == CPlayerModule::OnChanged(PlayerOption) @0x0059a8e0
→ CM_UI::SendNotice_PlayerOptionChanged(opt) // local UI fan-out
→ local side-effect switch (6 cases only — §1.5)
→ if (CPlayerModule::IsAutoSaveOption(opt)) @0x0059a600
CM_Character::Event_PlayerOptionChangedEvent(opt, GetOption(opt))
// == GameAction opcode 5, 0x14-byte body: u32 option, u32 value
return;
else
m_bDirty = 1; m_timeFirstDirtied = Timer::cur_time;
CPlayerModule::UseTime @0x0059a710 : dirty && (cur_time - firstDirtied) > 480.0
→ CM_Character::Event_CharacterOptionsEvent
CPlayerModule::SaveToServer @0x0059a660 : (dirty || force) → same
CM_Character::Event_CharacterOptionsEvent @0x006a10c0 : opcode 0x1A1, body is
literally PlayerModule::Pack(...)
```
So the **0x01A1 body is the packed `PlayerModule`** — flags-driven,
exactly what ACE's `GameActionSetCharacterOptions` reader parses. (Lane
for Q4; recorded here because the builder address is the answer.)
**`IsAutoSaveOption` lookup table — byte-verified** from the PDB-paired
binary at VA `0x0059a62c` (file offset `0x19a62c`, `.text`), 0x34 bytes:
```
00 00 00 01 01 01 01 01 01 01 01 01 01 01 01 00 00 00 00 01 01 01 01 01
01 00 01 00 01 01 01 01 01 01 01 00 00 00 00 00 01 01 00 00 01 01 00 00
01 00 00 00
```
`0x00``jump_table[0]``return 1` (auto-save). The 21 auto-save ids
are therefore: **0x00, 0x01, 0x02, 0x0F, 0x10, 0x11, 0x12, 0x19, 0x1B,
0x23, 0x24, 0x25, 0x26, 0x27, 0x2A, 0x2B, 0x2E, 0x2F, 0x31, 0x32, 0x33**.
Every other id (0x030x0E, 0x130x18, 0x1A, 0x1C0x22, 0x28, 0x29, 0x2C,
0x2D, 0x30) is batched.
Sanity check against an independent source: ACE's comment says
*"Auto Repeat Attacks → SetSingleCharacterOption; Disable Most Weather
Effects → won't trigger"*. Table: `0x00` = auto-save ✔, `0x04` =
batched ✔.
### 1.4 The Defaults button — retail's own per-option default table
`PlayerModule::GetDefaultOptionValue @0x005d2a30`. The
`UIOption_Checkbox` caches it into `m_default` at
`SetPlayerOption @0x00486f2d`, so this **is** what the Character tab's
Defaults button restores.
Byte-verified at VA `0x005d2a5c` (file `0x1d2a5c`), **0x2B bytes**
indices `0x00`..`0x2A` only, followed by `90` padding:
```
00 01 00 01 01 01 00 01 00 01 00 01 01 00 00 00 01 01 01 01 00 00 01 01
01 00 01 00 01 01 01 01 01 01 01 00 00 00 01 01 01 01 00
```
`0x00` → default **true**. **Options `0x2B`..`0x33` fall off the end of
the table and default to `false`** (`UseFastMissiles`, `FilterLanguage`,
`ConfirmVolatileRareUse`, `HearSocietyChat`, `ShowHelm`,
`DisableDistanceFog`, `UseMouseTurning`, `ShowCloak`, `LockUI`) — the
table was never extended when those options were added.
Reconstructing the words from the true entries:
* **Options1** = `0x02|0x08|0x40|0x100|0x400|0x2000|0x8000|0x40000|0x400000|0x800000|0x10000000|0x40000000` = **`0x50C4A54A`** — **identical to ACE's
`CharacterOptions1.Default`.** This is an independent binary
confirmation of that constant *and* of the id→bit map for those 12 ids.
* **Options2** = `0x100|0x200|0x400|0x8000` = **`0x00008700`**, whereas
ACE's `CharacterOptions2.Default = 0x00948700`. The three extra ACE
bits (`ConfirmVolatileRareUse 0x40000`, `ShowHelm 0x100000`,
`ShowCloak 0x800000`) are exactly three of the ids past the table's
end. **This is not a divergence to file** — retail's table is the
*client Defaults button*, ACE's constant is the *server
character-creation* value; they are different mechanisms and retail's
own server-side creation value is not observable to us. It IS a
behavioural difference the panel must reproduce: our Defaults button
must restore `0x50C4A54A` / `0x00008700`, not ACE's creation mask.
### 1.5 Retail's immediate local side effects (only six)
`CPlayerModule::OnChanged(PlayerOption)` switch — lookup table at
`0x0059aa04` (0x2F bytes, index = `arg2 - 2`), byte-verified:
| Option | Effect |
|---|---|
| `0x02 IgnoreFellowshipRequests` | if set → clears `FellowshipAutoAcceptRequests` |
| `0x04 DisableMostWeatherEffects` | `SmartBox::EnableWeather(!value)` |
| `0x05 PersistentAtDay` | `LScape::SetDay(value)` |
| `0x07 ViewCombatTarget` | `ClientCombatSystem::TrackTarget(value)` |
| `0x12 FellowshipAutoAcceptRequests` | if set → clears `IgnoreFellowshipRequests` |
| `0x30 DisableDistanceFog` | `LScape::m_fFogEnabled = !value` |
`CPlayerModule::OnInitialize @0x0059a690` applies four of these at login
(`PersistentAtDay`, `DisableDistanceFog`, `DisableMostWeatherEffects`,
`ViewCombatTarget`). **Everything else is consumed lazily at its point of
use** (§2's "retail consumer" column).
> **BN artifact note.** `OnInitialize`/`OnChanged` render the logical NOT
> as `eax = -(eax); x = ((eax - eax) + 1)` — that is `neg` / `sbb eax,eax`
> / `add eax,1`, i.e. `x = !value`. Reading it literally as arithmetic
> would invert the weather and fog semantics. This is the exact artifact
> class the chat digest's DO-NOT-RETRY table warns about.
### 1.6 Every option is also a bindable keyboard action
`CPlayerSystem::OnAction(InputEvent*) @0x00561890` contains one
`case` per option that does `Set<Option>(!Get<Option>())` — e.g.
`case 0x17``AutoRepeatAttack`, `case 0x21``ToggleRun`,
`case 0x22``StayInChatMode`. That single function is the source of
the `0x00562xxx` call sites that appear against nearly every accessor,
and it is why an accessor with *only* a `0x00562xxx` reference has **no
real consumer** — it is just bindable. Relevant to the campaign's
Configure-Keyboard question (Q5): the keymap and the option map are
coupled.
---
## 2. The map
Row order is retail's authored order from
`gmCharacterSettingsUI::InitOptions @0x004a02f0`; group boundaries are its
`AddHeader`/`AddSeperator` calls. Group *names* come from the user's
screenshots (the header string ids render as `0` in BN — see §6 unknowns).
**Column legend**
* **Storage**`id` = `PlayerOption`/ACE `CharacterOption` value;
`O1`/`O2` = `CharacterOptions1`/`CharacterOptions2`; bit verified
against `PlayerModule::GetOption @0x005d3aa0`.
* **Def** — retail *client* Defaults-button value (§1.4). `F*` = falls
past the end of retail's default table (id ≥ 0x2B), so `false` by
omission.
* **Wire****S** = `SetSingleCharacterOption 0x0005`, sent immediately
on change (auto-save). **B** = batched into `SetCharacterOptions 0x01A1`
(480 s dirty timer / forced save).
* **ACE***Honors* = the server changes real behaviour from the stored
bit (cite); *Stores* = persisted + echoed in PlayerDescription, never
read.
* **Retail consumer** — where the 2013 client actually reads it
(`—` = nowhere but §1.6's keybind toggle, i.e. server-only).
* **acdream**`LIVE(server)` = driven by the server bit;
`LIVE(local)` = a live consumer exists but reads `settings.json`, not
the server bit; `state-only` = persisted, no consumer; `missing` =
nothing at all.
* **Bot** — would a headless bot ever meaningfully set it.
### 2.1 User Interface Behavior (3 rows)
| Retail label | Storage | Def | Wire | ACE | Retail consumer | acdream | Bot |
|---|---|---|---|---|---|---|---|
| Keep Combat Targets in View | 0x07 · O1 `0x00000080` | F | B | Stores | `ClientCombatSystem::TrackTarget` via `OnChanged @0x0059a954` / `OnInitialize @0x0059a6ee` | **LIVE(local)**`src/AcDream.App/Combat/CombatCameraTargetSource.cs:44``RuntimeSettingsController.cs:229``GameplaySettings.ViewCombatTarget` | no |
| Salvage Multiple Materials at Once | 0x22 · O2 `0x00000080` | F | B | Stores | `gmSalvageUI::IsItemSuitable @0x004cb040` | missing | low |
| Use Main Pack as Default for Picking Up Items | 0x29 · O2 `0x00004000` | F | B | Stores | `CPlayerSystem::PlaceInBackpack @0x0055d8c0` | missing | **yes** |
### 2.2 User Interface Display (15 rows)
| Retail label | Storage | Def | Wire | ACE | Retail consumer | acdream | Bot |
|---|---|---|---|---|---|---|---|
| Vivid Targeting Indicator | 0x0E · O1 `0x00008000` | T | B | Stores | `VividTargetIndicator::UpdateDisplayState @0x004f5fb0` | **LIVE(local)**`src/AcDream.App/Composition/InteractionRetainedUiComposition.cs:675` | no |
| Display 3D Tooltips | 0x08 · O1 `0x00000100` | T | B | Stores | `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004e5ad0` | state-only — `GameplaySettings.cs:32`, no consumer | no |
| Show Coordinates By the Radar | 0x14 · O1 `0x00400000` | T | B | Stores | `gmRadarUI::UpdateCoordinates @0x004d8c80` | **LIVE(local)**`src/AcDream.App/Composition/LivePresentationComposition.cs:836` | no |
| Side By Side Vitals | 0x13 · O1 `0x00200000` | F | B | Stores | `gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004e9da0`; vitals layout `@0x004cf160`, `@0x004d0830` | state-only — `GameplaySettings.cs:34` | no |
| Display Spell Durations | 0x15 · O1 `0x00800000` | T | B | Stores | `EffectInfoRegion::Update @0x004f1c00` (+ `@0x004f2522`) | state-only — `GameplaySettings.cs:36` | no |
| Disable Most Weather Effects | 0x04 · O1 `0x00010000` | F | B | Stores | `SmartBox::EnableWeather` via `OnChanged @0x0059a921` | missing — seam is `RuntimeWorldEnvironmentState.Weather` (`src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs:149`) | no |
| Disable Distance Fog | 0x30 · O2 `0x00200000` | F* | B | Stores | `LScape::m_fFogEnabled` via `OnChanged @0x0059a940` | missing — seam is `SceneLightingUbo.cs:127-128` / `WorldRenderFrameBuilder.cs:489-492` | no |
| Always Daylight Outdoors | 0x05 · O2 `0x00000001` | F | B | Stores | `LScape::SetDay` via `OnChanged @0x0059a916` | missing — seam is `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` (`RuntimeWorldEnvironmentState.cs:50`) | no |
| Disable House Restriction Effects | 0x16 · O1 `0x02000000` | F | B | Stores | `ACCWeenieObject::CanMoveInto @0x0058da40` | missing | no |
| Use Crafting Chance of Success Dialog | 0x1A · O1 `0x80000000` | F | B | **Honors**`ACE.Server/Managers/RecipeManager.cs:94` | `ItemHolder::TargetAcquired @0x00588ef0` | missing | **yes** |
| Confirm Use of Rare Gems | 0x2D · O2 `0x00040000` | F* | B | **Honors**`ACE.Server/WorldObjects/Gem.cs:76` | `ItemHolder::UseObject @0x00588a80` | missing | **yes** |
| Display Timestamps | 0x21 · O2 `0x00000040` | F | B | Stores | `ClientSystem::AddTextToScroll @0x00563c50` (line 368476) | missing — chokepoint is `RuntimeCommunicationState.AddText` (`src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs:178`) | no |
| Filter Language | 0x2C · O2 `0x00020000` | F* | B | Stores | `ClientSystem::AddTextToScroll @0x00563c50` (line 368389) | missing | no |
| Show Your Helm or Head Gear | 0x2F · O2 `0x00100000` | F* | **S** | **Honors**`Creature_Networking.cs:49`; the 0x0005 handler also re-broadcasts `GameMessageObjDescEvent` | — (server-driven appearance) | state-only — `GameplaySettings.cs:39` | low |
| Show Your Cloak | 0x32 · O2 `0x00800000` | F* | **S** | **Honors**`Creature_Networking.cs:50`; same ObjDesc rebroadcast | — (zero client reads in the whole binary) | state-only — `GameplaySettings.cs:40` | low |
### 2.3 Grouping (6 rows)
| Retail label | Storage | Def | Wire | ACE | Retail consumer | acdream | Bot |
|---|---|---|---|---|---|---|---|
| Ignore Allegiance Requests | 0x01 · O1 `0x00000004` | F | **S** | **Honors**`Player_Allegiance.cs:317` | — | missing | **yes** |
| Ignore Fellowship Requests | 0x02 · O1 `0x00000008` | T | **S** | **Honors**`Player_Fellowship.cs:98` | mutual exclusion with 0x12, `OnChanged @0x0059a96a` | missing | **yes** |
| Show Allegiance Logons | 0x18 · O1 `0x08000000` | F | B | **Honors**`Player_Allegiance.cs:454,466` | — | missing | **yes** |
| Share Fellowship Experience and Luminance | 0x0F · O1 `0x00040000` | T | **S** | **Client-sourced** — ACE reads the `shareXP` field of the create-fellowship action, not the option (`Entity/Fellowship.cs:31,53-54`) | `gmFellowshipUI::CreateFellowship @0x0048f730` reads the option into the packet | missing | **yes** (if the bot creates fellowships) |
| Share Fellowship Loot | 0x11 · O1 `0x00100000` | F | **S** | **Honors**`Entity/Fellowship.cs:57` (leader's option at creation) | — | missing | **yes** |
| Automatically Accept Fellowship Requests | 0x12 · O1 `0x20000000` | F | **S** | **Honors**`Entity/Fellowship.cs:120` | mutual exclusion with 0x02, `OnChanged @0x0059a980` | missing | **yes** |
### 2.4 Other Players (11 rows)
| Retail label | Storage | Def | Wire | ACE | Retail consumer | acdream | Bot |
|---|---|---|---|---|---|---|---|
| Accept Corpse Looting Permissions | 0x10 · O1 `0x00080000` | F | **S** | **Honors**`Player_Death.cs:755` | — | **LIVE(local), never sent**`ClientCommandController.cs:444-450``LiveSessionRuntimeFactory.cs:431-432``GameplaySettings.AcceptLootPermits`. **Divergence, unfiled** | **yes** |
| Attempt to Deceive Other Players | 0x09 · O1 `0x00000200` | F | B | **Honors**`WorldObjects/Player.cs:324` | — | missing (`GameplaySettings` does not model it) | no |
| Let Other Players Give You Items | 0x06 · O1 `0x00000040` | T | B | **Honors**`Player_Inventory.cs:3288` (raw bit test, not `GetCharacterOption`) | — | state-only — `GameplaySettings.cs:37` | **yes** |
| Ignore All Trade Requests | 0x03 · O1 `0x00020000` | F | B | **Honors**`Player_Trade.cs:49` | — | missing | **yes** |
| Drag Item to Player Opens Trade | 0x17 · O1 `0x04000000` | F | B | Stores (pure client behaviour) | `ItemHolder::AttemptPlaceIn3D @0x00588600` | **LIVE(server)**`RuntimeCharacterState.cs:616-619,644-645``InteractionRetainedUiComposition.cs:318`; register row **TS-48** | no |
| Allow Others to See Your Date of Birth | 0x1C · O2 `0x00000002` | F | B | **Honors**`Network/Structure/AppraiseInfo.cs:355` | — | missing | no |
| …Your Age | 0x1D · O2 `0x00000020` | F | B | **Honors**`AppraiseInfo.cs:357` | — | missing | no |
| …Your Chess Rank | 0x1E · O2 `0x00000004` | F | B | **Honors**`AppraiseInfo.cs:359` | — | missing | no |
| …Your Fishing Skill | 0x1F · O2 `0x00000008` | F | B | **Honors**`AppraiseInfo.cs:361` | — | missing | no |
| …Your Number of Deaths | 0x20 · O2 `0x00000010` | F | B | **Honors**`AppraiseInfo.cs:363` | — | missing | no |
| …Your Number of Titles | 0x28 · O2 `0x00002000` | F | B | **Honors**`AppraiseInfo.cs:365` | — | missing | no |
### 2.5 Character Behavior (7 rows)
| Retail label | Storage | Def | Wire | ACE | Retail consumer | acdream | Bot |
|---|---|---|---|---|---|---|---|
| Run as Default Movement | 0x0A · O1 `0x00000400` | T | B | Stores | `ACCmdInterp::UITogglesRun @0x0058b180` | state-only — `GameplaySettings.cs:30`. (`RuntimeMovementCommand.ToggleRunLock` is a **keybind latch**, not this option) | **yes** |
| Advanced Combat Interface | 0x0C · O1 `0x00001000` | F | B | Stores | `gmCombatUI::RecvNotice_SetCombatMode @0x004cc620`; `@0x0056bf66` | state-only — `GameplaySettings.cs:31` | no |
| Auto Target | 0x0D · O1 `0x00002000` | T | B | Stores | `ClientCombatSystem` `@0x0056be07`, `@0x0056bfeb`, `@0x0056c754`, `@0x0056d4fc` | **LIVE(local)**`LiveCombatAttackOperations.cs:184`, `InteractionRetainedUiComposition.cs:285` | **yes** |
| Automatically Repeat Attacks | 0x00 · O1 `0x00000002` | T | **S** | **Honors**`Player_Melee.cs:375`, `Player_Missile.cs:284` | `ClientCombatSystem::RepeatAttackInProgress @0x0056a860` | **LIVE(local), never sent**`LiveCombatAttackOperations.cs:95,169`; mirrored into the retail combat panel at `CombatUiController.cs:192`. **Divergence, unfiled** | **yes** |
| Use Charge Attack | 0x19 · O1 `0x10000000` | T | **S** | **Honors**`Player_Melee.cs:193` | — | missing | **yes** |
| Lead Missile Targets | 0x2A · O2 `0x00008000` | T | **S** | **Honors**`Creature_Missile.cs:318` | — | missing | **yes** |
| Use Fast Missiles | 0x2B · O2 `0x00010000` | F* | **S** | **Honors**`Creature_Missile.cs:223` | — | missing | **yes** |
### 2.6 Chat (7 rows in the 2013 build; the screenshot shows 8)
| Retail label | Storage | Def | Wire | ACE | Retail consumer | acdream | Bot |
|---|---|---|---|---|---|---|---|
| Stay in Chat Mode After Sending a Message | 0x0B · O1 `0x00000800` | F | B | Stores | `ChatInterface::HandleEnterKey @0x004f52d0` | missing | no |
| Listen to Allegiance Chat | 0x1B · O1 `0x40000000` | T | **S** | **Honors** — join/leave in `GameActionSetSingleCharacterOption.cs`; `Player_Networking.cs:96`, `Player_Allegiance.cs:136,276` | `SendTurbineChat @0x0057db10` refuses locally when clear | **LIVE(server)**`TurbineChatMembershipGate.cs:109`; wire via `SetSingleOption` | **yes** |
| Listen to General Chat | 0x23 · O2 `0x00000100` | T | **S** | **Honors**`TurbineChatHandler.cs:293`, `Player_Networking.cs:98` | same | **LIVE(server)**`TurbineChatMembershipGate.cs:113` | **yes** |
| Listen to Trade Chat | 0x24 · O2 `0x00000200` | T | **S** | **Honors**`TurbineChatHandler.cs:294`, `Player_Networking.cs:100` | same | **LIVE(server)**`:117` | **yes** |
| Listen to LFG Chat | 0x25 · O2 `0x00000400` | T | **S** | **Honors**`TurbineChatHandler.cs:295`, `Player_Networking.cs:102` | same | **LIVE(server)**`:121` | **yes** |
| Listen to Roleplay Chat | 0x26 · O2 `0x00000800` | F | **S** | **Honors**`TurbineChatHandler.cs:296`, `Player_Networking.cs:104` | same | **LIVE(server)**`:125` | **yes** |
| Listen to Society Chat | 0x2E · O2 `0x00080000` | F* | **S** | **Honors**`TurbineChatHandler.cs:237`, `Player_Networking.cs:106` | same | **LIVE(server)**`:129` | **yes** |
| Listen to PK death messages | 0x34 · O2 `0x02000000` (ACE `HearPKDeath`) | **n/a** | **UNKNOWN** | **Stores only** — set once at creation (`PlayerFactory.cs:660`), never read anywhere in ACE | **NOT PRESENT** in the 2013 build (`TotalNumberOfPlayerOptions = 0x34`) | missing | no |
### 2.7 Options that exist but are NOT on the Character tab
Three of the 53 ids have no `AddToggleOption` row anywhere in the
2013 build's option pages. They are real, wire-capable options.
| Option | Storage | Wire | ACE | Notes |
|---|---|---|---|---|
| `AppearOffline` 0x27 | O2 `0x00001000` | **S** | **Honors**`Player_Character.cs:215`; the 0x0005 handler routes it to `Player.SetAppearOffline` | No UI row and no caller of `SetAppearOffline` outside `SetOption` in the 2013 build. Probably a later addition or a chat-command path. **UNKNOWN** how retail 2013 sets it. **Bot-relevant.** |
| `UseMouseTurning` 0x31 | O2 `0x00400000` | **S** | Stores | Only setter is the keybind toggle (`CPlayerSystem::OnAction @0x00562617`). The Gameplay-Options tab's *"Use Mouse Turning Settings"* button is a different thing (lane A). |
| `LockUI` 0x33 | O2 `0x01000000` | **S** | Stores | Set from UI at `@0x004d7630` and `@0x00570434`; read widely (`gmFloatyToolbarUI::UpdateLockedStatus @0x004cf490` and 4 more). acdream has this **LIVE(local)**`InteractionRetainedUiComposition.cs:476`, `LivePresentationComposition.cs:837`, `LiveSessionRuntimeFactory.cs:365`. |
---
## 3. Cross-checks and caveats
* **Row order and grouping are decomp-authored.** The six
`AddHeader`/`AddSeperator` boundaries in `InitOptions` reproduce the
screenshots exactly — 3 / 15 / 6 / 11 / 7 / 7 rows. The screenshot's
extra 8th Chat row (PK deaths) is the only difference, and it is
explained by the build gap.
* **The user's screenshots are from a later client than our PDB.** The
installed `C:\Turbine\Asheron's Call\acclient.exe` is a 2015 build
(CLAUDE.md's debugger section). Any 2015-only row (PK deaths today;
possibly others on the unscreenshotted tabs) will not be in the 2013
decomp. Treat the screenshots as the acceptance target and the decomp
as the mechanism.
* **ACE's `GetCharacterOption` grep is not a complete "honors" sweep.**
`AllowGive` is read as a raw bit at `Player_Inventory.cs:3288`. A
future audit must grep both `GetCharacterOption(` **and**
`CharacterOptions1 &` / `CharacterOptions2 &`.
* **ACE's `/option <name> [on|off]` command**
(`Command/Handlers/PlayerCommands.cs:196-330`) can set any option by a
GDLE-compatible name. It calls `SetCharacterOption` **directly**, so it
**skips** the 0x0005 handler's side effects — `/option HearGeneralChat
on` sets the bit but does **not** join the Turbine room, and
`/option ShowHelm on` does not rebroadcast the ObjDesc. Not a
substitute for the wire message.
* **`ShareFellowshipExpAndLuminance` is the one client-sourced option.**
ACE takes XP-sharing from the create-fellowship packet field, not from
the stored bit — matching retail's `gmFellowshipUI::CreateFellowship`,
which reads the option and puts it in the packet. Any acdream
fellowship-create must do the same.
---
## 4. acdream inventory (Q7)
### 4.1 What exists
| Piece | Where |
|---|---|
| Canonical option words + revision | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:628-712` (`RuntimeCharacterOptionsState`, `DefaultOptions1 = 0x50C4A54A`, `DefaultOptions2 = 0x00948700`) |
| Seeded from every PlayerDescription | `PlayerDescriptionParser.cs:430``RuntimeCharacterState``LiveSessionRuntimeFactory.cs:323` (`OnCharacterOptionsChanged`) |
| Local bit write (6 ids only) | `RuntimeCharacterState.cs:671-704` (`SetOptionBit`) — **silently no-ops for every id outside the six `ListenTo*Chat`** |
| Wire builder | `src/AcDream.Core.Net/Messages/SocialActions.cs:151-160` (`BuildSetSingleCharacterOption`, 20-byte body — byte-identical to retail's `Event_PlayerOptionChangedEvent @0x006a1510`) |
| Id enum | `SocialActions.cs:199-207` (`CharacterOptionId`**6 of 53 ids modelled**) |
| Bitfield enums | `PlayerDescriptionParser.cs:206-237` (`CharacterOptions1`: 4 named bits + Default; `CharacterOptions2`: 5 `Hear*` bits) |
| Shared Runtime command | `src/AcDream.Runtime/GameRuntimeCommands.cs:236-252` (`IRuntimeCharacterCommands.SetSingleOption`) |
| Graphical implementation | `src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs:668-681``SetSingleCharacterOptionRuntimeCmd``LiveSessionCommandRouter.cs:477`**`LiveSessionRuntimeFactory.cs:344-350`** (the local-write-then-send chokepoint) |
| Headless implementation | `src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:652-665` |
| Server→UI reseed precedent | `src/AcDream.App/Settings/RuntimeSettingsController.cs:583-605` (`SyncChatFromServerOptions`) + `:560-565` (`PublishHearOptionChange` — the diff-then-send pattern the bot config wants) |
| The non-retail local mirror | `src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs` — 16 options as `settings.json` booleans, explicitly documented (`:11-16`) as **local-only, server sync deferred** |
### 4.2 Live consumers today — the complete list
**Driven by the server bit (2):**
1. The six `Hear*Chat` bits → `TurbineChatMembershipGate.cs:109-129`.
2. `DragItemOnPlayerOpensSecureTrade``RuntimeCharacterState.cs:616-619`
`InteractionRetainedUiComposition.cs:318`.
**Driven by the local `settings.json` copy (7):**
`AutoTarget`, `AutoRepeatAttack`, `ViewCombatTarget` (combat —
`RuntimeSettingsController.cs:225-229`, `LiveCombatAttackOperations.cs`,
`CombatCameraTargetSource.cs:44`), `LockUI`
(`InteractionRetainedUiComposition.cs:476`,
`LivePresentationComposition.cs:837`), `VividTargetingIndicator`
(`InteractionRetainedUiComposition.cs:675`), `CoordinatesOnRadar`
(`LivePresentationComposition.cs:836`), `AcceptLootPermits`
(`LiveSessionRuntimeFactory.cs:431`).
**State-only, no consumer (9):** `ShowTooltips`, `SideBySideVitals`,
`SpellDuration`, `AllowGive`, `ShowHelm`, `ShowCloak`, `ToggleRun`,
`AdvancedCombatUI`, `UseMouseTurning` (all in `GameplaySettings.cs`).
**Absent entirely (~34):** everything else in §2, including all six
privacy rows, all four fellowship/allegiance grouping rows, weather /
fog / daylight, timestamps, language filter, salvage, main-pack, charge
attack, missile options, crafting/rare-gem confirmations, and stay-in-chat.
### 4.3 Existing register coverage
* **TS-48**`DragItemOnPlayerOpensSecureTrade` honoured but the
secure-trade subsystem is unported.
* **AP-187** — per-window chat filters persist locally because the
`0x1000008C` GameplayOptions blob is unparsed.
* **No row exists** for "16 character options live in `settings.json` and
never reach the server", nor for the two behavioural divergences in
§0. **COORDINATOR TODO:** the implementing slice must add them (or
retire them by shipping the wire).
### 4.4 One defect found while inventorying
`DirectGameRuntimeCommandAdapter.SetSingleOption`
(`src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:652-665`)
sends `SendSetSingleCharacterOption` **without** the local
`Options.SetOptionBit(...)` write that the graphical path performs at
`LiveSessionRuntimeFactory.cs:348`. That local write is the CH4
REJECT-review fix documented in `RuntimeCharacterState.cs:660-669` — its
absence on the headless path means a bot that turns on
`ListenToGeneralChat` still has `TurbineChatMembershipGate` refusing
`/g` until the next PlayerDescription arrives. Same bug, same shape,
different host. **COORDINATOR TODO:** fix in the bot-settings slice; a
Runtime-level test can cover it without a client.
---
## 5. Bot relevance (Q8, research half)
### 5.1 Does ACE reject option changes from a logged-in session?
* **`SetSingleCharacterOption (0x0005)`** — no session-state gate at all.
`GameActionAttribute` carries only an opcode
(`ACE.Server/Network/GameAction/GameActionAttribute.cs`), and
`GameActionSetSingleCharacterOption.Handle` has no precondition. Any
in-world session may change any option at any time.
* **`SetCharacterOptions (0x01A1)`** — **refused** before
`FirstEnterWorldDone` with a logged warning
(`GameActionSetCharacterOptions.cs:25-35`). ACE added that guard
specifically because a client logging out from the pink-bubble state
would overwrite stored options with defaults.
* **No per-option rejection exists.** The handler's `default:` branch
stores whatever it is given.
### 5.2 Which options can a bot meaningfully hold?
**Tier 1 — server-honoured AND plausibly bot-relevant** (a bot would
actually want to declare these; `AttemptToDeceiveOtherPlayers` and the
six privacy rows are also server-honoured but pointless for a bot, so
they sit in tier 3): `IgnoreAllegianceRequests 0x01`, `IgnoreFellowshipRequests
0x02`, `IgnoreAllTradeRequests 0x03`, `LetOtherPlayersGiveYouItems 0x06`,
`ShareFellowshipExpAndLuminance 0x0F`*, `AcceptCorpseLootingPermissions
0x10`, `ShareFellowshipLoot 0x11`, `AutomaticallyAcceptFellowshipRequests
0x12`, `ShowAllegianceLogons 0x18`, `UseChargeAttack 0x19`,
`UseCraftingChanceOfSuccessDialog 0x1A`, `AutoRepeatAttacks 0x00`,
`LeadMissileTargets 0x2A`, `UseFastMissiles 0x2B`,
`ConfirmUseOfRareGems 0x2D`, `AppearOffline 0x27`, plus all six
`ListenTo*Chat` (`0x1B, 0x23, 0x24, 0x25, 0x26, 0x2E`).
*(\* client-sourced — the bot must put it in the fellowship-create
action, not just in the option word.)*
**Tier 2 — client-side but a bot still wants them:**
`UseMainPackAsDefaultForPickingUpItems 0x29` (pickup routing),
`RunAsDefaultMovement 0x0A` (movement default), `AutoTarget 0x0D`,
`SalvageMultipleMaterialsAtOnce 0x22`. These need an acdream-side
consumer before they mean anything headless.
**Tier 3 — presentation-only; must stay out of the bot schema by
construction:** every row in §2.2 except the two confirmation dialogs and
helm/cloak, plus `SideBySideVitals`, `AdvancedCombatInterface`,
`StayInChatMode`, `LockUI`, `UseMouseTurning`, and the six privacy rows
(harmless but pointless for a bot).
### 5.3 Bot-safety constraints (hard)
1. **Never send an unmodelled id.** ACE's
`CharacterOptionExtensions.GetCharacterOptions1Attribute` is a
`Dictionary[val]` lookup over `Enum.GetValues` — an out-of-range
`CharacterOption` cast throws `KeyNotFoundException` **inside the
game-action handler**. Validate against the id table before sending.
2. **Never send `0x35`/`0x36`.** `CharacterOptions1Default` and
`CharacterOptions2Default` are real enum entries whose "bit" is the
whole default mask; `SetCharacterOption(0x35, true)` ORs
`0x50C4A54A` into the player's word in one shot.
3. **Use `0x0005` per change, not the blob.** The blob's
`FirstEnterWorldDone` guard, its `PlayerModule::Pack` body, and the
fact that it carries shortcuts/favourite-spells/desired-comps make it
the wrong tool for a declarative bot diff.
4. **Idempotence comes free.** Seed from PlayerDescription (already
parsed), diff declared-vs-actual, send only differences — the exact
shape `RuntimeSettingsController.PublishHearOptionChange` already
uses. Re-running it on reconnect is a no-op.
### 5.4 Headless config today
`src/AcDream.Headless/Configuration/HeadlessConfiguration.cs` has **no**
`characterOptions` block; the schema is strict
(`[JsonRequired]` + a validating loader), so presentation settings are
excluded by construction today and would stay excluded if the new block
only admits the id names in §5.2 tiers 12.
---
## 6. Open unknowns (explicitly not guessed)
| # | Unknown | Why it is open |
|---|---|---|
| U1 | The six group-header **strings** and their string ids | `PlayerOptionPage::AddHeader(this, 0)` renders its second argument as `0` at all six sites. Either BN elided a pushed immediate or the header text is resolved elsewhere. The screenshots give us the labels; the *ids* are unresolved. Lane A territory. |
| U2 | The per-row **label and tooltip strings** | `InitOptions` hashes string keys (`ID_PlayerOption_<Name>` / `_Help`) via `compute_str_hash`. Some previews are truncated at ~33 chars and some literal operands render as pooled `vftable` members (a known BN artifact — e.g. `ID_PlayerOption_HearGeneralChat = compute_str_hash(&…RecvNotice_UserPreferenceChanged_Menu)`). The **key names** are recoverable from the global symbol names; the **displayed English** must come from the string table / screenshots, not from these previews. |
| U3 | Whether the 2015 client added Character-tab rows beyond PK deaths | Only the 2013 PDB is paired. The user's screenshots are the acceptance target. |
| U4 | How retail 2013 sets `AppearOffline 0x27` | Wire-capable, ACE-honoured, auto-save — but no UI row and no setter call site. |
| U5 | Exact `0x01A1` field order for the non-boolean sections | Out of lane-B scope (handoff Q4). The answer is `PlayerModule::Pack` at the `CM_Character::Event_CharacterOptionsEvent @0x006a10c0` call site, cross-read against `GameActionSetCharacterOptions.cs`. |
| U6 | Retail's *server-side* character-creation option defaults | Not observable from the client. ACE's `0x50C4A54A` / `0x00948700` are ACE's choice; retail's client Defaults button is `0x50C4A54A` / `0x00008700` (§1.4). Do not "fix" one to match the other. |
---
## 7. For the planner
### 7.1 Implement-vs-store split
**Group A — wire + state only (no client consumer needed).** The panel
toggles them, the option word tracks them, `0x0005`/`0x01A1` carries
them, ACE does the rest. This is the cheapest and largest group, and it
is where most of the retail-parity value is.
> `IgnoreAllegianceRequests`, `IgnoreFellowshipRequests`,
> `IgnoreAllTradeRequests`, `LetOtherPlayersGiveYouItems`,
> `AcceptCorpseLootingPermissions`, `ShareFellowshipLoot`,
> `AutomaticallyAcceptFellowshipRequests`, `ShowAllegianceLogons`,
> `AttemptToDeceiveOtherPlayers`, `UseChargeAttack`,
> `UseCraftingChanceOfSuccessDialog`, `ConfirmUseOfRareGems`,
> `LeadMissileTargets`, `UseFastMissiles`,
> `ShowYourHelmOrHeadGear`, `ShowYourCloak`, all six
> `AllowOthersToSeeYour*`, `AppearOffline`.
**Group B — needs a client consumer, and the seam already exists.** Small,
well-understood bindings; each is a one-line read at a known site.
| Option | Bind at |
|---|---|
| Display Timestamps | `RuntimeCommunicationState.AddText` (`:178`) — retail binds at the same chokepoint |
| Filter Language | same chokepoint |
| Always Daylight Outdoors | `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` |
| Disable Most Weather Effects | `RuntimeWorldEnvironmentState.Weather` |
| Disable Distance Fog | `SceneLightingUbo` fog start/end |
| Use Main Pack as Default | the pickup/placement path (`SendPickup` / preferred-container choice) |
| Run as Default Movement | `RuntimeLocalPlayerMovementState` (distinct from the `ToggleRunLock` keybind latch) |
| Side By Side Vitals / Display Spell Durations / Display 3D Tooltips / Vivid Targeting Indicator / Show Coordinates / Advanced Combat Interface / Stay in Chat Mode | their existing retail-UI controllers |
**Group C — already live, but must be re-pointed from `settings.json` to
the server bit.** This is the highest-risk work in the campaign because
it changes behaviour that currently works: `AutoTarget`,
`AutoRepeatAttacks`, `KeepCombatTargetsInView`, `LockUI`,
`VividTargetingIndicator`, `ShowCoordinatesByTheRadar`,
`AcceptCorpseLootingPermissions`. The precedent is CH3/CH4: server bit is
authoritative, the panel reseeds from PlayerDescription, the toggle writes
locally **then** sends.
**Group D — defer with a register row.** `SalvageMultipleMaterialsAtOnce`
(no salvage UI), `DisableHouseRestrictionEffects` (no housing),
`ShareFellowshipExpAndLuminance` (needs the fellowship-create field, not
just the bit), `ListenToPKDeathMessages` (not in our decomp; ACE never
reads it), `UseMouseTurning` (belongs to the Gameplay-Options tab's mouse
dialog).
### 7.2 Structural recommendations
1. **Promote the map into one typed table in Runtime.** Today
`CharacterOptionId` models 6 of 53 ids and `SetOptionBit` no-ops the
rest — a panel wired to the current seam would silently fail on 47
rows. The table needs: id → (word, mask, auto-save?, client default).
All four columns are in §2 and all four are byte-verified.
2. **Keep `SetSingleOption` as the single seam** and fix the headless
local-write gap (§4.4) in the same slice — one shared helper on the
Runtime side rather than the write living in
`LiveSessionRuntimeFactory`.
3. **Honour `IsAutoSaveOption`.** Sending `0x0005` for every toggle would
be simpler but is a wire divergence; sending nothing for the batched
32 loses them. Retail's own dirty-timer + forced-save-at-logout is the
faithful shape and needs a `PlayerModule::Pack`-equivalent (handoff Q4)
before the batched set can round-trip. **If Q4's blob is not ready
when the panel ships, sending `0x0005` for batched options too is the
pragmatic interim — but it must be a register row, not a silent
choice.**
4. **The Defaults button restores `0x50C4A54A` / `0x00008700`**, not
ACE's creation mask, and not `GameplaySettings.Default` (whose own
doc comment at `:19-23` admits it is not bit-exact).
5. **`GameplaySettings` should die** as options move to server truth —
or be explicitly re-scoped to acdream-only client settings. Leaving
two writable copies of the same 16 booleans is how the CH3
`ChatSettings` lie happened.
### 7.3 Bot-meaningful subset (short answer)
22 options in tier 1 (§5.2) plus 4 in tier 2. The strict headless schema
should accept **exactly** those id names, reject everything else at load,
and diff-then-send through `IRuntimeCharacterCommands.SetSingleOption`
after PlayerDescription seeds actual state.

File diff suppressed because it is too large Load diff