acdream/docs/research/2026-08-10-character-options-map.md
Erik cf6ef8b4b4 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>
2026-08-10 22:37:46 +02:00

615 lines
41 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.