acdream/docs/research/2026-08-10-set-character-options-wire.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

1009 lines
51 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

# `SetCharacterOptions (0x01A1)` — the real blob, the wire policy, ACE's acceptance, and the CH3 post-mortem
Research lane C of the settings-track campaign (retail four-tab Options
panel). Answers handoff question **Q4** in full and the ACE-acceptance tail
of **Q8** (`docs/research/2026-08-10-settings-track-handoff.md`).
Companions: `claude-memory/project_chat_digest.md`,
`docs/research/2026-08-09-chat-retail-window-shell.md` §4 (the per-window
GameplayOptions structure — not re-derived here),
`docs/research/2026-08-09-chat-side-channels-vs-ace.md` §3.8 / §5.5 (the
CH3 defect note this doc supersedes with the real layout).
**Verification legend used throughout**
| marker | meaning |
|---|---|
| **BYTE-VERIFIED** | read out of the PDB-paired binary `C:\Users\erikn\Downloads\acclient.exe` (`check_exe_pdb.py``=== MATCH ===`, GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`, linker 2013-09-06). Disassembly quoted. |
| **PDB-TYPE** | verbatim from `docs/research/named-retail/acclient.h` (IDA-exported PDB type info, not BN inference). |
| **BN** | Binary Ninja pseudo-C only. Cross-checked against ACE/holtburger where possible. |
| **ACE** | `references/ACE` source (the server acdream actually talks to). |
| **UNKNOWN / UNVERIFIED** | explicitly not established. Never guessed. |
---
## 0. TL;DR for the impatient
1. The 0x01A1 body **is `PlayerModule::Pack`** — the whole player-module
object, not an options word. Four unconditional `u32`s (header, options1,
spellbook filters, options2) plus five flag-gated sub-records, in a
specific order. ACE's reader and retail's writer **agree exactly** once you
account for three legacy branches retail 2013 never emits.
2. Retail's send decision is a **byte-verified 52-entry lookup table**,
`CPlayerModule::IsAutoSaveOption @0x0059A600`: 21 of the 52 `PlayerOption`
ids send `0x0005` immediately and never dirty the module; the other 31 (plus
*every* gameplay/window option) only mark the module dirty and ride the
0x01A1 blob out on Apply, logout, or an **8-minute** (480.0 s, BYTE-VERIFIED)
auto-save timer.
3. **ACE validates nothing.** It clamps nothing. It raw-stores `options1`,
`options2` and the opaque `GameplayOptions` byte[] and discards every other
section of the blob (each has its own dedicated GameAction). Its only refusal
is `0x01A1` **before `LoginComplete (0x00A1)`** — silently dropped with a log
warning. `0x0005` has no gate at all.
4. The deleted CH3 builder was a **16-byte message with one `u32`** where ACE
expects a section-flag word followed by ~40+ bytes. It could not have worked;
the "0x40 collision" is the sharpest symptom of a whole-word slot error.
5. A resurrected builder plugs into `SocialActions`
`WorldSession.SendSetCharacterOptions`
`IRuntimeCharacterCommands` (a new sibling of `SetSingleOption`), with the
local-write-then-notify pattern already proven by
`RuntimeCharacterOptionsState.SetOptionBit`.
6. **Defect found in passing:** the headless command path
(`DirectGameRuntimeCommandAdapter.SetSingleOption`) omits the local option
write that the graphical path performs — see §7.4. This is directly
load-bearing for the bot design in Q8.
---
## 1. The message envelope
Retail builder — `CM_Character::Event_CharacterOptionsEvent @0x006A10C0` (BN,
cross-checked against the sibling builders in the same block):
```
006a10c6 eax = Proto_UI::GetNextUICounter()
006a10ee eax_1 = playerModule->vtable->Pack(&null, 0) // MEASURE pass, returns size
006a1108 buf = operator new[](eax_1 + 0xc) // 0xc = order header + opcode
006a1120 OrderHdr::Pack(&hdr, &p, eax_1 + 0xc) // 8 bytes
006a1129 *(u32*)p = 0x1a1 // the GameAction opcode
006a113e p += 4
006a114a playerModule->vtable->Pack(&p, remaining) // WRITE pass
006a1154 Proto_UI::SendToWeenie(buf, eax_1 + 0xc)
```
The `0xc` prologue is the same 12-byte envelope every other acdream
`SocialActions` builder writes:
```
u32 0xF7B1 GameAction envelope
u32 gameActionSequence
u32 0x000001A1 SetCharacterOptions
<PlayerModule::Pack …> everything below
```
Sanity anchor from the same code block: `Event_LoginCompleteNotification`
allocates exactly `0xc` for an empty payload and writes opcode `0xa1`
(BN, `0x006A1480`) — so envelope = 12 bytes, payload starts at offset 12.
`Event_PlayerOptionChangedEvent` allocates `0x14` = 12 + 4 + 4, matching
acdream's existing 20-byte `BuildSetSingleCharacterOption`
(`src/AcDream.Core.Net/Messages/SocialActions.cs:151-160`).
---
## 2. The blob body — `PlayerModule::Pack @0x005D45C0`
### 2.1 The object being packed (PDB-TYPE, `acclient.h:36507`)
```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;
};
```
Declaration order is **not** wire order. Wire order is §2.3.
### 2.2 The header flag word (PDB-TYPE, `acclient.h:7835`)
```c
enum PlayerModulePackHeader
{
PM_Packed_None = 0x0,
PM_Packed_ShortCutManager = 0x1,
PM_Packed_SquelchList = 0x2,
PM_Packed_MultiSpellLists = 0x4,
PM_Packed_DesiredComps = 0x8,
PM_Packed_ExtendedMultiSpellLists = 0x10,
PM_Packed_SpellbookFilters = 0x20,
PM_Packed_2ndCharacterOptions = 0x40,
PM_Packed_TimeStampFormat = 0x80,
PM_Packed_GenericQualitiesData = 0x100,
PM_Packed_GameplayOptions = 0x200,
PM_Packed_8_SpellLists = 0x400,
};
```
**This is the correct enum.** It matches ACE's `CharacterOptionDataFlag`
(`references/ACE/Source/ACE.Entity/Enum/CharacterOptionDataFlag.cs`) value for
value, and matches acdream's existing inbound copy at
`src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:184-198`. The only
naming differences are cosmetic (`SpellLists8` vs `PM_Packed_8_SpellLists`,
`CharacterOptions2` vs `PM_Packed_2ndCharacterOptions`).
**What retail actually sets**`PlayerModule::SetPackHeader @0x005D44A0`,
BYTE-VERIFIED:
```
8b 41 04 mov eax,[ecx+4] ; shortcuts_
85 c0 test eax,eax
8b 44 24 04 mov eax,[esp+4]
74 03 je +3
83 08 01 or dword [eax], 1 ; ShortCutManager (conditional)
8b 10 mov edx,[eax]
81 ca 00 04 00 00 or edx, 0x400 ; 8_SpellLists (ALWAYS)
56 push esi
89 10 mov [eax],edx
8b b1 88 00 00 00 mov esi,[ecx+0x88] ; desired_comps_
85 f6 test esi,esi
74 05 je +5
83 ca 08 or edx, 8 ; DesiredComps (conditional)
89 10 mov [eax],edx
8b 10 mov edx,[eax]
83 ca 60 or edx, 0x60 ; SpellbookFilters |
89 10 mov [eax],edx ; 2ndCharacterOptions (ALWAYS)
8b b1 98 00 00 00 mov esi,[ecx+0x98] ; m_pPlayerOptionsData
85 f6 test esi,esi
5e pop esi
74 08 je +8
81 ca 00 01 00 00 or edx, 0x100 ; GenericQualitiesData (conditional)
89 10 mov [eax],edx
8b 91 9c 01 00 00 mov edx,[ecx+0x19c] ; m_colGameplayOptions … m_numElements
85 d2 test edx,edx
74 06 je +6
81 08 00 02 00 00 or dword [eax], 0x200 ; GameplayOptions (conditional)
c2 04 00 ret 4
```
So retail's header is **always at least `0x460`**
(`8_SpellLists | SpellbookFilters | 2ndCharacterOptions`), OR'd with
`0x01` / `0x08` / `0x100` / `0x200` when the corresponding member is populated.
**`0x02 SquelchList`, `0x04 MultiSpellLists`, `0x10 ExtendedMultiSpellLists`
and `0x80 TimeStampFormat` are NEVER set by the 2013 client.** ACE's own
comment ("SquelchList doesn't get used by the client, so should never be set",
`GameActionSetCharacterOptions.cs:138`) agrees for 0x02, and this disassembly
proves the other three. `0x04`/`0x10` are the pre-8-tab spell-bar formats;
`0x80` is superseded by the timestamp string living inside
`GenericQualitiesData` (see §2.5).
### 2.3 Field-by-field wire layout
`PlayerModule::Pack @0x005D45C0` (BN, prologue BYTE-VERIFIED — see below the
table). Read top to bottom; every offset is 4-byte aligned.
| # | field | present when | encoding |
|---|---|---|---|
| 1 | `header` | always | `u32` `PlayerModulePackHeader` (§2.2) |
| 2 | `options_` | always | `u32` `CharacterOptions1` bitfield |
| 3 | `shortcuts_` | `header & 0x001` | `ShortCutManager::Pack` — §2.4a |
| 4 | `favorite_spells_[0..7]` | **always 8 lists**, flagged `0x400` | 8 × `PackableList<u32>::Pack` — §2.4b |
| 5 | `desired_comps_` | `header & 0x008` | `PackableHashTable<DataID,i32>::Pack` — §2.4c |
| 6 | `spell_filters_` | always (`0x020` always set) | `u32` spellbook filter bitfield |
| 7 | `options2_` | always (`0x040` always set) | `u32` `CharacterOptions2` bitfield |
| 8 | `m_pPlayerOptionsData` | `header & 0x100` | `GenericQualitiesData::Pack` — §2.4d |
| 9 | `m_colGameplayOptions` | `header & 0x200` | `PackObjPropertyCollection::Pack` — §2.4e / §4 |
| 10 | tail padding | always | zero bytes to the next 4-byte boundary |
BYTE-VERIFIED prologue (`0x005D45C0`, first 0x78 bytes), showing fields 1, 2 and
the entry into field 4:
```
8b 07 ff 50 08 mov eax,[edi]; call [eax+8] ; GetPackSize()
3b e8 … 0f 82 f8 00 00 00 jb bail-out (buffer too small, return size)
e8 b1 fe ff ff call PlayerModule::SetPackHeader
8b 44 24 18 / 89 02 mov [edx], header ; FIELD 1
8b 16 83 c2 04 89 16 p += 4
8b 8f 8c 00 00 00 89 08 mov [p], [edi+0x8c] = options_ ; FIELD 2
83 06 04 p += 4
8b 4f 04 85 c9 74 07 … if (shortcuts_) shortcuts_->Pack ; FIELD 3
8d 5f 08 lea ebx,[edi+8] = &favorite_spells_[0]
c7 44 24 1c 08 00 00 00 loop counter = 8 ; FIELD 4 × 8
```
`PlayerModule::GetPackSize @0x005D4500` opens with `be 10 00 00 00`
(`mov esi, 0x10`) — BYTE-VERIFIED **16 bytes of unconditional payload**, i.e.
exactly fields 1, 2, 6 and 7. That is an independent proof that no other field
is unconditional.
### 2.4 Sub-record encodings
**(a) `ShortCutManager::Pack @0x005D5710`** (BN; struct PDB-TYPE
`acclient.h:36484-36495`)
```
u32 count // number of NON-NULL slots, NOT the array length
repeat count times:
i32 index_ // 0..17 (ShortCutManager holds shortCuts_[18])
u32 objectID_
u32 spellID_
```
`pack_size = 4 + 12 × count`. ACE reads exactly this
(`GameActionSetCharacterOptions.cs:51-60`).
**(b) `PackableList<unsigned long>::Pack @0x0048B710`** (BN)
```
u32 curNum
repeat curNum times: u32 element
```
`pack_size = 4 + 4 × curNum`. All eight `favorite_spells_` lists use this;
an empty tab is a lone `u32 0`.
**(c) `PackableHashTable<K,V>::Pack @0x005692B0`** (BN)
```
u32 sizeInfo = (_table_size << 16) | _currNum
repeat (_currNum) times: u32 key, u32 value
```
ACE reads `num = sizeInfo & 0xFFFF`
(`GameActionSetCharacterOptions.cs:109-114`) — agrees. Iteration order is
bucket order, i.e. **unspecified** for a builder's purposes; the receiver is a
hash table too, so order does not matter. `_table_size` in the high half is
advisory (ACE ignores it); acdream's own inbound parser also ignores it
(`PlayerDescriptionParser.cs:414-415`, where it reads it as `u16 count` +
`u16 discard` — the same 4 bytes read the other way round, which is
equivalent on little-endian for counts < 65536).
**(d) `GenericQualitiesData::Pack @0x006B78F0`** (BN note BN mislabels the
size helper as `CEnchantmentRegistry::pack_size`, a COMDAT-folding artifact,
not a real call into the enchantment registry)
```
u32 header: 0x1 = int table, 0x2 = bool table, 0x4 = float table, 0x8 = string table
[if 0x1] PackableHashTable<u32,i32> (u32 key, i32 value)
[if 0x2] PackableHashTable<u32,i32> (u32 key, i32 value)
[if 0x4] PackableHashTable<u32,double> (u32 key, PStringChar?) ← see caution
[if 0x8] PackableHashTable<u32,PString> (u32 key, string)
```
**Caution / divergence:** ACE's reader for the *float* sub-table reads
`u32 key` then a **`ReadString16L`** (`GameActionSetCharacterOptions.cs:163-172`),
while the retail member is `PackableHashTable<unsigned long,double>`. One of the
two is wrong. Since retail never sets `0x100` unless
`m_pPlayerOptionsData != 0`, and the only thing the 2013 client ever puts in it
is the **timestamp format string** (key `1`, see §2.5), the float table is
almost certainly always absent in practice and the discrepancy is unreachable.
**Recommendation: never set `0x100` from acdream.** Marked UNRESOLVED do not
build on either reading.
**(e) `PackObjPropertyCollection::Pack`** see §4. Opaque byte run.
### 2.5 Absent-section defaults (from `PlayerModule::UnPack @0x005D49D0`, BN)
The unpacker is the authority on what "flag not set" means:
| flag absent | resulting state |
|---|---|
| `0x020 SpellbookFilters` | `spell_filters_ = 0x3FFF` |
| `0x040 2ndCharacterOptions` | `options2_ = 0x00948700` |
| `0x001 ShortCutManager` | existing `shortcuts_` **destroyed** (set to null) |
| `0x008 DesiredComps` | existing `desired_comps_` **destroyed** |
| `0x100 GenericQualitiesData` | existing `m_pPlayerOptionsData` **destroyed** |
| `0x200 GameplayOptions` | collection left untouched (no destroy branch) |
`favorite_spells_[0]` is unpacked **unconditionally**; then *exactly one* of
`0x04` 4 more lists, `0x10` 6 more, `0x400` 7 more (an if/else-if chain,
so a sender that sets two of them gets only the first). Retail always takes the
`0x400` branch 1 + 7 = 8 lists.
When `0x100` **is** present, UnPack additionally does
`GenericQualitiesData::InqString(m_pPlayerOptionsData, 1, &m_TimeStampFormat)`
**the timestamp format string is string-key `1` inside GenericQualitiesData**,
which is why `PM_Packed_TimeStampFormat (0x80)` is dead in the 2013 client.
The constructor default is `"%#H:%M:%S "`
(`PlayerModule::PlayerModule @0x005D51F0`, BN string literal useful for the
Chat tab's timestamp work, but the literal itself is BN-sourced and
**not byte-verified**).
Constructor defaults, BYTE-VERIFIED at `0x005D5231`:
```
c7 86 8c 00 00 00 4a a5 c4 50 mov dword [esi+0x8c], 0x50C4A54A ; options_
c7 86 90 00 00 00 00 87 94 00 mov dword [esi+0x90], 0x00948700 ; options2_
c7 86 94 00 00 00 ff 3f 00 00 mov dword [esi+0x94], 0x00003FFF ; spell_filters_
```
This independently confirms `CharacterOptions1.Default = 0x50C4A54A` (the value
in `PlayerDescriptionParser.cs:217` and `RuntimeCharacterOptionsState`) and
retires any lingering doubt from the retracted register row **UN-9**.
### 2.6 Retail writer vs ACE reader — the agreement matrix
ACE's read order
(`references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionSetCharacterOptions.cs:45-191`):
| ACE step | gate | retail equivalent | verdict |
|---|---|---|---|
| `flags = ReadUInt32()` | | field 1 | |
| `options1 = ReadInt32()` `SetCharacterOptions1` | | field 2 | |
| shortcuts | `0x01` | field 3 | |
| `numTab1Spells` + spells | **unconditional** | `favorite_spells_[0]` | |
| 4 tabs | `0x04` | never emitted by 2013 retail | dead branch |
| 6 tabs | `0x10` | never emitted | dead branch |
| 7 tabs | `0x400` | `favorite_spells_[1..7]` | |
| desired comps | `0x08` | field 5 | |
| spellbook filters (else `0x3FFF`) | `0x20` | field 6 (+ §2.5 default) | |
| `options2` `SetCharacterOptions2` | `0x40` | field 7 (+ §2.5 default) | |
| `ReadString16L()` | `0x80` | never emitted | dead branch |
| GenericQualitiesData | `0x100` | field 8 | float sub-table shape disputed 2.4d) |
| rest-of-payload byte[] | `0x200` | field 9 | |
**Conclusion: ACE's reader is layout-correct for a faithful retail blob.** No
byte-level disagreement exists on any path the 2013 client can produce. The one
unresolved item (float sub-table) is unreachable if acdream never sets `0x100`.
### 2.7 The minimal correct blob acdream can emit
```
u32 0xF7B1
u32 seq
u32 0x000001A1
u32 header = 0x00000460 (| 0x01 | 0x08 | 0x200 as populated)
u32 options1
[shortcuts, if 0x01]
u32 tab0Count ; tab0 spells ┐
u32 tab1Count ; tab1 spells │ always 8 lists
… │
u32 tab7Count ; tab7 spells ┘
[desired comps, if 0x08]
u32 spellbookFilters (echo the parsed value; 0x3FFF if unknown)
u32 options2
[gameplay-options blob, if 0x200 — MUST be last]
```
Everything is `u32`, so the payload is inherently 4-aligned and retail's tail
pad is a no-op **unless** the gameplay-options blob has a length that is not a
multiple of 4 see §4.3.
---
## 3. Wire policy: when 0x0005, when 0x01A1
### 3.1 The decision point — `CPlayerModule::OnChanged(PlayerOption) @0x0059A8E0`
Every option write funnels here (each `PlayerModule::SetXxx` accessor tail-calls
vtable slot +0x14 with its `PlayerOption` id e.g. `SetHearGeneralChat
@0x005D35C0` writes `options2_ |= 0x100` then jumps with `arg2 = 0x23`).
```
1. CM_UI::SendNotice_PlayerOptionChanged(option) // local UI broadcast, always
2. local side-effect switch:
0x02 IgnoreFellowshipRequests → if set, clear FellowshipAutoAcceptRequests
0x04 DisableMostWeatherEffects → SmartBox::EnableWeather(!v)
0x05 PersistentAtDay → LScape::SetDay(v)
0x07 ViewCombatTarget → ClientCombatSystem::TrackTarget(v)
0x12 FellowshipAutoAccept → if set, clear IgnoreFellowshipRequests
0x30 DisableDistanceFog → LScape::m_fFogEnabled = !v
3. if (IsAutoSaveOption(option))
CM_Character::Event_PlayerOptionChangedEvent(option, GetOption(option)) // 0x0005 NOW
return // never dirties
4. else
if (!m_bDirty) { m_bDirty = 1; m_timeFirstDirtied = Timer::cur_time; }
```
Note step 3 **returns** an auto-save option never contributes to the blob's
dirty flag. And note the `SetXxx` accessors early-return when the value is
unchanged (`SetHearGeneralChat` opens with a compare-and-return), so **an
unchanged option produces no notice, no side effect, no message at all**. That
idempotency is retail's, not something acdream has to invent.
### 3.2 `IsAutoSaveOption` — BYTE-VERIFIED
`CPlayerModule::IsAutoSaveOption @0x0059A600`:
```
8b 44 24 04 mov eax,[esp+4] ; PlayerOption
83 f8 33 cmp eax, 0x33
77 13 ja return-0 ; > 0x33 → NOT auto-save
0f b6 80 2c a6 59 00 movzx eax, byte [eax+0x0059A62C]
ff 24 85 24 a6 59 00 jmp [eax*4 + 0x0059A624] ; [0]=ret 1, [1]=ret 0
```
Table at `0x0059A62C` (52 bytes, BYTE-VERIFIED):
```
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
```
Byte `0x00` **auto-save (send `0x0005` immediately)**. The 21 such ids:
| id | retail `PlayerOption` | storage | ACE `CharacterOption` name |
|---|---|---|---|
| 0x00 | `AutoRepeatAttack` | Opts1 `0x00000002` | AutoRepeatAttacks |
| 0x01 | `IgnoreAllegianceRequests` | Opts1 `0x00000004` | IgnoreAllegianceRequests |
| 0x02 | `IgnoreFellowshipRequests` | Opts1 `0x00000008` | IgnoreFellowshipRequests |
| 0x0F | `FellowshipShareXP` | Opts1 `0x00040000` | ShareFellowshipExpAndLuminance |
| 0x10 | `AcceptLootPermits` | Opts1 `0x00080000` | AcceptCorpseLootingPermissions |
| 0x11 | `FellowshipShareLoot` | Opts1 `0x00100000` | ShareFellowshipLoot |
| 0x12 | `FellowshipAutoAcceptRequests` | Opts1 `0x20000000` | AutomaticallyAcceptFellowshipRequests |
| 0x19 | `UseChargeAttack` | Opts1 `0x10000000` | UseChargeAttack |
| 0x1B | `HearAllegianceChat` | Opts1 `0x40000000` | ListenToAllegianceChat |
| 0x23 | `HearGeneralChat` | Opts2 `0x00000100` | ListenToGeneralChat |
| 0x24 | `HearTradeChat` | Opts2 `0x00000200` | ListenToTradeChat |
| 0x25 | `HearLFGChat` | Opts2 `0x00000400` | ListenToLFGChat |
| 0x26 | `HearRoleplayChat` | Opts2 `0x00000800` | ListenToRoleplayChat |
| 0x27 | `AppearOffline` | Opts2 `0x00001000` | AppearOffline |
| 0x2A | `LeadMissileTargets` | Opts2 `0x00008000` | LeadMissileTargets |
| 0x2B | `UseFastMissiles` | Opts2 `0x00010000` | UseFastMissiles |
| 0x2E | `HearSocietyChat` | Opts2 `0x00080000` | ListenToSocietyChat |
| 0x2F | `ShowHelm` | Opts2 `0x00100000` | ShowYourHelmOrHeadGear |
| 0x31 | `UseMouseTurning` | Opts2 `0x00400000` | UseMouseTurning |
| 0x32 | `ShowCloak` | Opts2 `0x00800000` | ShowYourCloak |
| 0x33 | `LockUI` | Opts2 `0x01000000` | LockUI |
The pattern is legible: **options with a server-side consequence**
(fellowship/allegiance/trade/loot state, chat-room membership, friend-list
visibility, appearance broadcast, and the combat pacing flags the server
simulates) go out immediately; pure client-presentation options wait for the
blob. ACE's `GameActionSetSingleCharacterOption` special-cases a subset of
exactly this set (AppearOffline, the two fellowship ones, cloak/helm, and the
six ListenTo*) every ACE special case is in the auto-save list. Independent
corroboration.
**Everything not in that table 31 of the 52 ids, including every
"User Interface Display" row the user screenshotted, `ToggleRun` (Run as
Default Movement, 0x0A), `AdvancedCombatUI` (0x0C), `AutoTarget` (0x0D),
`ShowTooltips` (0x08), `DisplayTimeStamps` (0x21), `FilterLanguage` (0x2C),
`ConfirmVolatileRareUse` (0x2D), `DisableDistanceFog` (0x30) only ever
reaches the server inside the 0x01A1 blob.**
### 3.3 What flushes the dirty blob
| trigger | site | force? | notes |
|---|---|---|---|
| **Options panel commit** | `PlayerOptionPage::SaveCurrentValues @0x004F2710` `CPlayerModule::SaveToServer(pm, 0)` | no | `PlayerOptionPage`'s constructor installs a vtable BN labels `gmCharacterSettingsUI::'vftable'` (`0x004F269A`) a strong lead that this is the Character tab's class, but treat the NAME as a lead for lane A/B rather than proof (folded/aliased vtable labels are a known BN artifact class). `PlayerOptionPage::OnVisibilityChanged @0x004F26E0` routes to `SaveCurrentValues` / `RestoreSavedValues` depending on the visibility argument **which branch is "shown" vs "hidden" is UNVERIFIED**; lane A/B owns `OptionPage`'s base semantics. |
| **Logout** | `CPlayerSystem::LogOffCharacter @0x00563520` `SaveToServer(pm, 0)` | no | Fires before `ExecuteLogOff` / `RequestLogOff`. |
| **8-minute auto-save** | `CPlayerModule::UseTime @0x0059A710` | | BYTE-VERIFIED below. |
`CPlayerModule::SaveToServer @0x0059A660` is `if (m_bDirty || force) send; m_bDirty = 0;`
**both production call sites pass `force = 0`**, so a clean module sends
nothing. This is precisely the behaviour ACE's header comment describes:
*flipping only single-option toggles and then clicking Apply sends no 0x01A1 at
all.*
`CPlayerModule::UseTime @0x0059A710`, BYTE-VERIFIED:
```
8a 86 a8 01 00 00 mov al,[esi+0x1a8] ; m_bDirty
84 c0 / 74 2c test/je → return if clean
dd 05 58 53 7e 00 fld qword [0x007E5358] ; = 480.0 (BYTE-VERIFIED: 00 00 00 00 00 00 7e 40)
dc 86 b0 01 00 00 fadd qword [esi+0x1b0] ; + m_timeFirstDirtied
dc 1d a8 69 83 00 fcomp qword [0x008369A8] ; vs Timer::cur_time
df e0 / f6 c4 41 fnstsw ax / test ah,0x41
7a 13 jp skip ; PF=1 ⇔ result 0x00 (ST>mem) or 0x41 (unordered)
call CM_Character::Event_CharacterOptionsEvent
c6 86 a8 01 00 00 00 mov byte [esi+0x1a8], 0 ; m_bDirty = 0
```
`test ah,0x41` after `fcomp`: C0 (`0x01`) = "less", C3 (`0x40`) = "equal".
PF is set only for results `0x00` (greater) and `0x41` (unordered), and `jp`
skips the send in both. So the blob is sent when
`480.0 + m_timeFirstDirtied <= Timer::cur_time` i.e. **480 seconds (8 minutes)
after the module FIRST went dirty**, not after the last change. Branch direction
is verified at the byte level, not inferred.
### 3.4 Gameplay/window options never use 0x0005
`PlayerModule::SetOption(BaseProperty) @0x005D52C0` (the gameplay-option setter
that `SetChatWindowOption` and the geometry writers use) stores into
`m_colGameplayOptions` and calls `CPlayerModule::OnChanged(BaseProperty, flags)
@0x0059A890`, which is:
```
CM_UI::SendNotice_GameplayOptionChanged(prop, flags) // local only
if (!m_bDirty) { m_bDirty = 1; m_timeFirstDirtied = cur_time; }
```
No immediate send, ever. **Every chat-window filter, position, size, visibility,
title and the two opacity values therefore reach the server only through the
0x01A1 blob**, on the same Apply / logout / 8-minute schedule. (BN field naming
here is an artifact BN renders the dirty flag as `m_TimeStampFormat…`; the
byte dump in §3.3 shows the real offsets `0x1A8` / `0x1B0`.)
### 3.5 The policy table (the answer to Q4's "when")
| user action | message(s) | when |
|---|---|---|
| Toggle one of the 21 auto-save options (LED click) | `0x0005` only | immediately, on the click |
| Toggle any other option | none on the wire | dirty flag set |
| Click **Apply** (or the panel's commit path) | `0x01A1` **iff** something dirty | on the click |
| Move/resize/close/rename a chat window; change a filter or opacity | none on the wire | dirty flag set |
| Nothing pressed, 8 minutes since first dirty change | `0x01A1` | timer |
| Log off | `0x01A1` iff dirty | before the logoff request |
| Re-set an option to its current value | **nothing at all** | early-return in the accessor |
---
## 4. The `GameplayOptions` blob (`0x1000008C` / `0x1000008B`)
### 4.1 What it is on the wire
`m_colGameplayOptions` is a `PackObjPropertyCollection`, i.e.
`PackUsingSerialize<PackObj>` + `PropertyCollection`. Its `Pack`
(`PackUsingSerialize<PackObj>::Pack @0x005D4E50`) does **not** write fields it
`memcpy`s the bytes of an already-serialized `AutoStoreVersionArchive`:
```
GetPackSize() → if (!m_fArchiveValid) { m_ar.InitForPacking(); Serialize(&m_ar);
AutoStoreVersionArchive::OnSerializingDone(&m_ar);
m_fArchiveValid = 1; }
return Archive::GetCurrentPosition(&m_ar);
Pack() → memcpy(dest, SmartBuffer(m_ar), size); reset archive
```
and `PropertyCollection::Serialize @0x00681420` is
`SerializeIntrusiveHashTable<int, IntrusiveHashTable<u32, HashTableData<u32,BaseProperty>*>, …, SB_Default>`
over the property bag a versioned archive of a hash table of `BaseProperty`
objects, each of which is itself polymorphic (int / bool / float / string /
array / nested bag, per the `0x11` array type seen at
`PlayerModule::GetChatOptionStructure @0x005D5300`).
**The exact archive byte format (version row layout, `SB_Default` element
framing, `BaseProperty` type tags) is UNKNOWN and was not derived in this lane.**
It is a self-contained sub-project: `Archive`, `AutoStoreVersionArchive`,
`SerializeVersionRow`, `BaseProperty::Serialize` and the `PropertyCollection`
hash-table serializer all have named symbols and can be walked when the campaign
needs the wire round-trip.
### 4.2 Semantic content — already researched
`docs/research/2026-08-09-chat-retail-window-shell.md` §4.14.2 has the
complete decoded structure (array `0x1000008C` indexed by `windowId - 1`,
element name `0x1000008B`, and the seven per-window property ids
`0x1000007F` filter / `0x10000086-89` geometry / `0x1000008A` visible /
`0x1000008D` title), plus the two guards (main window `m_eWindowID == 0` never
persists; geometry restore is skipped when a local screen-layout file was
loaded). The two global opacity options `0x10000080` / `0x10000081` live in the
same collection. **Not re-derived here.**
### 4.3 Two hazards for whoever packs it
1. **It must be the last section.** ACE reads it as "all remaining bytes"
(`GameActionSetCharacterOptions.cs:185-190`). Anything appended after it is
silently swallowed into the stored blob.
2. **Retail's tail padding lands inside it.** `PlayerModule::Pack` zero-pads the
whole payload up to a 4-byte boundary *after* the collection. If the archive
length is not a multiple of 4, ACE stores up to **3 extra zero bytes** and
echoes them back verbatim on the next `PlayerDescription`. Any decoder ours
or retail's must tolerate trailing zeros, and a bit-exact round-trip test
must account for them.
### 4.4 What acdream does with it today
Inbound: `PlayerDescriptionParser.cs:433-442` slices the blob out
**heuristically** (`TryHeuristicInventoryStart` scans forward for the inventory
section) and never parses it. Outbound: nothing. Local window layout persists to
`SettingsStore` instead (`RetailWindowLayoutPersistence`), per the CH6
decision to defer the wire (`…window-shell.md` §6.2 row CH6f).
---
## 5. ACE acceptance (Q4 part 4 + the Q8 tail)
### 5.1 Validation and clamping: there is none
`Player_Character.cs:80-120`:
```csharp
public void SetCharacterOptions1(int value) { Character.CharacterOptions1 = value; CharacterChangesDetected = true; }
public void SetCharacterOptions2(int value) { Character.CharacterOptions2 = value; CharacterChangesDetected = true; }
public void SetCharacterGameplayOptions(byte[] v) { Character.GameplayOptions = v; CharacterChangesDetected = true; }
```
Raw stores under a write lock. **No mask, no whitelist, no range check, no
rejection.** Bits ACE's enum calls `NotUsed1..5` are stored happily and echoed
back. The same is true of the `0x0005` path `SetCharacterOption` just
ORs/ANDs the attribute's mask into the same field.
### 5.2 The only refusal: `FirstEnterWorldDone`
`GameActionSetCharacterOptions.cs:25-35` drops the **whole** 0x01A1 message,
with a `log.Warn`, if `session.Player.FirstEnterWorldDone` is false a
deliberate guard against a client that logs out of the pink-bubble state before
receiving its options and overwrites them with defaults. The flag is set by
`GameActionLoginComplete (0x00A1)` (`GameActionLoginComplete.cs:15-17`), which
acdream already sends (`src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs`).
**Constraint: never send 0x01A1 before LoginComplete.** There is no response,
no error, no retry the message just vanishes.
`SetSingleCharacterOption (0x0005)` has **no such gate** and no validation of any
kind.
### 5.3 What ACE does with each section
| section | ACE behaviour | persisted? | echoed in PlayerDescription? |
|---|---|---|---|
| `options1` | `SetCharacterOptions1(value)` | `Character.CharacterOptions1` | always |
| shortcuts | read, **discarded** (`// TODO`) | | from `Character.GetShortcuts()` |
| 8 spell tabs | read, **discarded** (`// TODO`) | | from `GetSpellsInSpellBar(0..7)` |
| desired comps | read into a local dict, **discarded** | | from `GetFillComponents()` |
| spellbook filters | read into a local, **discarded** | | from `Character.SpellbookFilters` |
| `options2` | `SetCharacterOptions2(value)` | | (`0x40` always set outbound) |
| timestamp string (`0x80`) | read, discarded | | never sent |
| GenericQualitiesData (`0x100`) | read, discarded | | never sent |
| **GameplayOptions (`0x200`)** | `SetCharacterGameplayOptions(bytes)` | `Character.GameplayOptions` byte[] | verbatim when non-empty |
The four "discarded" sections each have a **dedicated** ACE GameAction that is
the real persistence channel: `AddShortcut`/`RemoveShortcut`,
`AddSpellFavorite`/`RemoveSpellFavorite`,
`SetDesiredComponentLevel (0x0224)`, `SpellbookFilter (0x0286)`. So sending them
inside the blob neither helps nor hurts on ACE.
**But do echo the real values anyway.** A blob that zeroes them would be wrong
against a retail server and would look wrong in a packet capture; echoing what
`PlayerDescription` last delivered costs nothing and keeps the message faithful.
ACE's outbound echo (`GameEventPlayerDescription.cs:340-397`) sets
`CharacterOptions2 | SpellLists8 | SpellbookFilters` unconditionally, plus
`Shortcut` / `DesiredComps` / `GameplayOptions` when non-empty, and **never**
sets `GenericQualitiesData` or `TimestampFormat`. Its `SpellLists8` branch
writes 8 lists in one loop, which the retail unpacker consumes as
1 unconditional + 7 flagged.
### 5.4 Failure modes a bot must avoid
1. **Malformed blob → partial application, silently.** ACE's per-action
`try/catch` (`InboundMessageManager.cs:135-143`) logs the exception and
continues; the session survives. But `SetCharacterOptions1` runs *before* any
read that could throw, so a truncated blob can leave `options1` applied and
`options2` not. Never send a speculative blob.
2. **Out-of-range option id on 0x0005 throws.** ACE casts the id to
`CharacterOption` unchecked, then `CharacterOptionExtensions` does a
`Dictionary[val]` lookup built from `Enum.GetValues` an id that is not an
enum member raises `KeyNotFoundException`, caught and logged, option lost.
**Only send ids in the enumerated set.**
3. **Ids `0x35` and `0x36` are landmines.** ACE defines
`CharacterOptions1Default = 0x35` and `CharacterOptions2Default = 0x36` with
the *whole default mask* as their attribute. Sending `0x35 = true` ORs the
entire `0x50C4A54A` default into `options1`. These are not real options
never emit them.
4. **`0x34` is version-dependent** see §8.1.
### 5.5 Answering Q8 directly
> *verify which options a bot can meaningfully hold and whether ACE rejects any
> option change from a logged-in session.*
- **ACE rejects no option change from a logged-in session.** The single gate is
pre-`LoginComplete` for 0x01A1. Post-login, every option in both bitfields is
settable to any value at any time.
- **Every option a bot could want is meaningfully holdable**, because ACE stores
the raw bitfields regardless of whether it simulates the behaviour. The ones
ACE actually *acts on* server-side are: the six `ListenTo*Chat` (Turbine room
join/leave), `AppearOffline` (friend-status broadcast), `ShowYourHelm` /
`ShowYourCloak` (`GameMessageObjDescEvent` re-broadcast), the fellowship
request/share flags, and the trade/allegiance ignore flags. The rest are
stored-and-echoed only.
- **The declared-vs-actual diff at login is sound and idempotent** and it is
exactly what retail does anyway 3.1 step 0: an accessor set to its current
value emits nothing). Two constraints:
1. Diff against the `PlayerDescription`-seeded state, **after**
`LoginComplete`, then send.
2. Prefer `0x0005` per changed option for the 21 auto-save ids (retail-exact,
and the only path that triggers ACE's Turbine join/leave). Use one 0x01A1
blob for a batch of non-auto-save changes which requires the builder this
doc specifies, so until it exists, a bot can only hold the 21.
- **Presentation-only settings must stay out of the bot schema** (opacity,
window geometry, audio, quality) they live in the `GameplayOptions` archive
that acdream cannot yet pack, and they are meaningless without a window.
---
## 6. CH3 deletion post-mortem
### 6.1 What existed
Introduced by `fa266aaa` (2026-04-19, *"feat(net): SocialActions query /
fellowship / channel / options outbound"*), removed by `614a1e05`
(2026-08-09, Campaign CH slice CH3). The whole builder:
```csharp
public const uint SetCharacterOptionsOpcode = 0x01A1u; // u32 options bitmap
/// <summary>Push the client's character-options bitmap to the server.</summary>
public static byte[] BuildSetCharacterOptions(uint seq, uint optionsBitmap)
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SetCharacterOptionsOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), optionsBitmap);
return body;
}
```
Its only reachable entrance was `IGameRuntimeCommands.SetOptions1`, which had no
production caller (tests only). Ten byte-exact unit tests asserted the encoding
of a message shape that does not exist.
### 6.2 Exactly what was wrong
**One error, three consequences.** The builder put a `CharacterOptions1`
bitfield in the payload's **first** `u32` slot. That slot is the *section-flag
word*, not an options word 2.2). Therefore:
1. **Every options1 bit aliases onto a section flag.** Feeding
`CharacterOptions1.Default = 0x50C4A54A` into the flags slot lights five
sections at once:
| bit | as `CharacterOptions1` | read by ACE as |
|---|---|---|
| `0x002` | `AutoRepeatAttack` | `SquelchList` (never read ACE's branch is commented out) |
| `0x008` | `IgnoreFellowshipRequests` | `DesiredComps` |
| `0x040` | `AllowGive` | `CharacterOptions2` |
| `0x100` | `ShowTooltips` | `GenericQualitiesData` |
| `0x400` | `ToggleRun` | `SpellLists8` |
(and `UseDeception 0x200` `GameplayOptions`, `DisableMostWeatherEffects
0x10000` nothing, etc. for non-default values). The handoff's shorthand
*"`CharacterOptionDataFlag.CharacterOptions2 = 0x40` collides with
`CharacterOptions1.AllowGive = 0x40`"* is the single sharpest instance of
this aliasing (`AllowGive` **is** in the default mask), not a separate bug.
2. **The payload ends immediately after that word**, so ACE's very next read
the unconditional `characterOptions1 = ReadInt32()` runs off the end of the
16-byte body and throws `EndOfStreamException`, caught and logged by
`InboundMessageManager`. Nothing would have been applied except nothing,
because options1 is read *after* flags. (Had the payload been one word
longer, `SetCharacterOptions1` would have been called with garbage *before*
the throw the partial-application hazard of §5.4.1.)
3. **No spell lists, no filters, no options2, no gameplay options** the
message could not carry the state it was named for even in principle.
CH3's judgement (delete rather than fix) was right: a builder with no caller, no
correct layout, and a green test suite pinning the wrong bytes is worse than
nothing, because the tests make it look verified.
### 6.3 Adjacent facts worth carrying forward
- The same commit deleted `AddChannel (0x0145)` / `RemoveChannel (0x0146)`,
which sent `string16L` where ACE reads `(Channel)ReadUInt32()`. Same class of
error: a shape asserted from a name rather than read from the receiver.
- Register row **UN-9** (a suspected `CharacterOptions1.Default` mismatch) was
filed and then **retracted** the same day the wrong literal `0x50C48D4A`
existed only in a research doc. §2.5's byte dump of the retail constructor
now confirms `0x50C4A54A` from a third independent source. Do not re-open it.
- Ten obsolete tests went with the builder; a resurrected one needs new
conformance tests written against §2.3, ideally including a round-trip against
`PlayerDescriptionParser`.
---
## 7. acdream today — what a resurrected builder plugs into
### 7.1 The working `0x0005` codec
| layer | file:line | role |
|---|---|---|
| Wire builder | `src/AcDream.Core.Net/Messages/SocialActions.cs:151-160` | `BuildSetSingleCharacterOption(seq, optionId, value)` 20 bytes |
| Option ids | `src/AcDream.Core.Net/Messages/SocialActions.cs:199-207` | `enum CharacterOptionId` **only the six `ListenTo*Chat` ids modelled**; the full 0x000x33 set is needed for the Options panel |
| Session send | `src/AcDream.Core.Net/WorldSession.cs:2202-2206` | `SendSetSingleCharacterOption` (allocates the game-action sequence) |
| Runtime command | `src/AcDream.Runtime/GameRuntimeCommands.cs:249-252` | `IRuntimeCharacterCommands.SetSingleOption(generation, optionId, value)` |
| Graphical route | `src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs:676` `src/AcDream.App/Net/LiveSessionCommandRouter.cs:69,167,477` `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:346-350` | publishes `SetSingleCharacterOptionRuntimeCmd`; the factory's `SendSingleCharacterOption` is the **single local-write chokepoint** |
| Headless route | `src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:652-666` | direct send **missing the local write**, see §7.4 |
| Settings entrance | `src/AcDream.App/Settings/RuntimeSettingsController.cs:538-566` | `PublishHearOptionChange` diffs previous vs current and publishes **changed bits only** (already retail's idempotency rule, §3.1) |
### 7.2 `RuntimeCharacterOptionsState` — the local-write-then-notify owner
`src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:628-712`.
- `DefaultOptions1 = 0x50C4A54A`, `DefaultOptions2 = 0x00948700` both now
byte-confirmed against the retail constructor 2.5).
- `Replace(options1, options2)` called from
`src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:210-213` on **every**
`PlayerDescription`. This is the seed the bot diff must run against.
- `SetOptionBit(characterOptionId, bool)` the local write. **Currently
recognises only the six `ListenTo*Chat` ids and silently no-ops every other
id** (`_ => (false, 0u)` at `:687`). The Options panel needs the full
`PlayerOption → (word, mask)` table here; §3.2's table plus `acclient.h:3404`
(`CharacterOption`) and `acclient.h:3451` (`CharacterOptions2`) are the
complete verbatim source.
- `Snapshot` carries a `Revision` counter, incremented only on an actual change
a natural dirty-tracking hook for the blob (see §9).
There is **no dirty flag, no `m_timeFirstDirtied` equivalent, and no
auto-save timer** anywhere in acdream today.
### 7.3 The inbound parser (round-trip partner)
`src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:353-447`. It agrees
with §2.3 on every path ACE can produce, with three noted deltas all harmless
against ACE, all worth a comment if the builder makes the parser its round-trip
oracle:
1. It reads the 8 spell lists **only** under `SpellLists8`, with a
single-list fallback otherwise; it ignores `MultiSpellList (0x04)` and
`ExtendedMultiSpellLists (0x10)` entirely. Retail reads list[0]
unconditionally, then 4 / 6 / 7 more. Equivalent for ACE (which always sets
`0x400`); divergent against a hypothetical legacy sender.
2. It reads `spellbookFilters` whenever 4 bytes remain rather than gating on
`0x20`, and defaults to `0x3FFF` (matching retail's UnPack default) if
absent. Equivalent for ACE (always sets `0x20`).
3. It never reads the `0x80` or `0x100` sections. ACE never sets them; retail
would, if acdream ever talked to one.
### 7.4 Defect spotted in passing (headless local-write gap)
`DirectGameRuntimeCommandAdapter.SetSingleOption`
(`src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:652-666`) sends
the wire message but does **not** perform the local
`Character.Options.SetOptionBit(optionId, value)` write that the graphical path
does at `LiveSessionRuntimeFactory.cs:348`. That local write exists precisely
because CH4's re-review found `TurbineChatMembershipGate` refusing a room the
player had just joined until the next `PlayerDescription` arrived
(the reasoning is written out at `LiveSessionRuntimeFactory.cs:330-345`).
A headless bot that joins a Turbine channel through
`IRuntimeCharacterCommands.SetSingleOption` therefore hits exactly the bug CH4
fixed for the graphical client: the room is joined server-side, but the local
gate keeps refusing outbound chat on it until a fresh `PlayerDescription` lands.
The adapter holds `_runtime` (`:57`) and can reach
`GameRuntime.Character.Options`, so the fix is a one-line local write before the
send or, better, hoisting the write into Runtime so **neither** host can omit
it. Filed here for the planner; not fixed in this lane (research-only).
---
## 8. Known divergences, cautions and UNKNOWNs
### 8.1 Our decomp is one build older than the option set the user screenshotted
The named-retail PDB is the **Sept 2013** build. Its `PlayerOption` enum
(`acclient.h:4162`) ends:
```
LockUI_PlayerOption = 0x33,
TotalNumberOfPlayerOptions_PlayerOption = 0x34,
```
and its `CharacterOptions2` bitfield (`acclient.h:3451`) ends at
`LockUI_CharacterOptions2 = 0x1000000`. **`HearPKDeath` does not exist in the
2013 client** no enum entry, no `PlayerModule::SetHearPKDeath` accessor, no
bit. ACE has `ListenToPKDeathMessages = 0x34` `CharacterOptions2 0x02000000`,
and the user's Character-tab screenshot shows *"Listen to PK death messages"*.
Conclusion: the option was added **after** Sept 2013, and `0x34` shifted from
"total count" to a real option in the final EoR client.
- The `0x34` `0x02000000` mapping is **ACE-sourced, UNVERIFIABLE against our
binary**. Use it, cite ACE, and mark it in the register.
- Whether the final client treats `0x34` as auto-save is **UNKNOWN**. Its five
`Hear*Chat` siblings all are, so treating it as auto-save is the
best-supported guess but it is a guess, and on ACE the end state is
identical either way (the `0x0005` handler's `default:` branch just sets the
bit; there is no PK-death Turbine room).
- `IsAutoSaveOption`'s `cmp eax, 0x33 / ja → 0` means the 2013 client would
classify `0x34` as non-auto-save by construction. Do not read that as evidence
about the later build.
### 8.2 `GetDefaultOptionValue` disagrees with the constructor defaults
`PlayerModule::GetDefaultOptionValue @0x005D2A30` the per-option default the
**Defaults** button would consult is BYTE-VERIFIED:
```
83 f8 2a cmp eax, 0x2A
77 13 ja return-0 ; > 0x2A → default OFF
0f b6 80 5c 2a 5d 00 movzx eax, byte [eax+0x005D2A5C]
ff 24 85 54 2a 5d 00 jmp [eax*4 + 0x005D2A54] ; [0]=ret 1, [1]=ret 0
```
Table at `0x005D2A5C` (BYTE-VERIFIED) default-ON ids: `0x00 0x02 0x06 0x08
0x0A 0x0D 0x0E 0x0F 0x14 0x15 0x19 0x1B 0x23 0x24 0x25 0x2A`.
Those are exactly the twelve `CharacterOptions1.Default` bits plus four of the
seven `CharacterOptions2.Default` bits. **`ConfirmVolatileRareUse (0x2D)`,
`ShowHelm (0x2F)` and `ShowCloak (0x32)` are ON in the constructor default
`0x00948700` but report default-OFF here**, because the function's range check
tops out at `0x2A` it predates those three options and was never extended.
So pressing "Defaults" in retail does **not** reproduce a fresh
`PlayerModule`; it turns helm, cloak and the rare-gem confirmation off. This is
retail behaviour, byte-proven, and acdream should reproduce it rather than
"fix" it with a register row explaining why, so a future reader does not
"correct" it back. (Whether the panel's Defaults button actually calls this
function is lane A/B's question; this lane only establishes what the function
returns.)
### 8.3 Explicit UNKNOWNs
| # | unknown | why it matters |
|---|---|---|
| U1 | `PropertyCollection` / `AutoStoreVersionArchive` byte format 4.1) | blocks the `0x1000008B`/`0x1000008C` wire (CH6f) local persistence only until then |
| U2 | `GenericQualitiesData` float sub-table: `double` (retail struct) vs `string16L` (ACE reader) 2.4d) | unreachable if we never set `0x100`; do not set it |
| U3 | `PlayerOptionPage::OnVisibilityChanged` argument polarity which of show/hide commits vs reverts 3.3) | Apply/Reset/Defaults semantics; lane A/B territory (`OptionPage` base class) |
| U4 | Whether the final EoR client made `0x34 HearPKDeath` auto-save 8.1) | cosmetic on ACE; a divergence row either way |
| U5 | Whether the 2017 client changed `PlayerModule::Pack` at all (new sections, new flags) | our layout is the 2013 one; ACE's reader is 2017-era and matches it, which is strong evidence nothing changed but it is inference, not proof |
| U6 | The default timestamp format string `"%#H:%M:%S "` is BN-sourced, not byte-verified | only matters when the Chat tab implements timestamp formatting |
---
## 9. For the planner
**What is now settled and can be specified without further research**
1. **The blob layout** 2.3 + §2.4 + §2.7) is complete and cross-verified
against ACE's reader. A builder can be written from this doc alone.
2. **The send policy** 3.5) is byte-verified, including the 21-id auto-save
table 3.2) and the 480-second timer 3.3).
3. **ACE accepts anything post-`LoginComplete`** and stores only
`options1`, `options2`, `GameplayOptions` 5).
**Recommended slice shape**
- **S-a the option map.** Extend `enum CharacterOptionId` to all ids
`0x00..0x33` (verbatim from `acclient.h:4162`) plus ACE's `0x34`, and extend
`RuntimeCharacterOptionsState.SetOptionBit` from six ids to the full
`PlayerOption → (Options1|Options2, mask)` table (`acclient.h:3404` /
`:3451`). Add `IsAutoSave(id)` as a table on the Runtime side it is the
policy, so it belongs with the state, not in the panel. Pure Runtime + tests,
no visual gate. **Do this first; the panel and the bot both depend on it.**
Guard-rail: reject `0x35`/`0x36` at the seam 5.4.3).
- **S-b the dirty model.** Add retail's `m_bDirty` + `m_timeFirstDirtied`
(`Revision` already gives change detection) and the three flush triggers
(Apply / logout / 480 s). Runtime-owned, so the headless host inherits it.
Decide explicitly whether acdream ships the 8-minute autosave or defers it
with a register row retail-faithful says ship it.
- **S-c the builder.** `SocialActions.BuildSetCharacterOptions(...)` per §2.7
+ `WorldSession.SendSetCharacterOptions` + a new
`IRuntimeCharacterCommands.SetAllOptions`-style command beside
`SetSingleOption`. Echo the last-parsed shortcuts / spell tabs / desired comps
/ spellbook filters rather than zeroing them 5.3). **Omit `0x100`
entirely** (U2). Conformance tests must round-trip through
`PlayerDescriptionParser` and must include a golden byte vector, since the
last builder's tests pinned a wrong shape and looked green.
- **S-d the headless `characterOptions` block.** Strict JSON schema keyed by
option *name*, resolved through S-a's map; diff against the
`PlayerDescription`-seeded state after `LoginComplete`; auto-save ids go out
as `0x0005`, the remainder as one blob. Presentation-only settings are
excluded by construction. Fix §7.4 in this slice or before it.
- **CH6f (gameplay-options packing) stays out of scope** until U1 is researched.
The blob builder must simply omit `0x200` when acdream has nothing to pack
which §2.5 confirms is safe (the receiver leaves its collection untouched).
**Design calls this doc recommends**
- Mirror retail's auto-save split exactly rather than sending everything as
`0x0005`. It is not merely cosmetic: `0x0005` is the only path that makes ACE
join/leave Turbine rooms, and the blob is the only path that carries the
window/gameplay options the split is load-bearing in both directions.
- Reproduce the `GetDefaultOptionValue` quirk 8.2) with a register row rather
than "fixing" it.
- Keep `SetOptionBit`'s local-write-then-notify shape for every option, not just
the six chat ones it is retail's own ordering
(`SetHearGeneralChat @0x005D35C0` writes the bit, *then* jumps to `OnChanged`)
and it is what makes the client's own consumers correct before the round trip.
**Register rows this work will need**
- `0x34 HearPKDeath` mapping sourced from ACE, unverifiable against our 2013
binary 8.1).
- The `GetDefaultOptionValue` vs constructor-default disagreement, if the
Defaults button ships 8.2).
- The GameplayOptions blob remaining unpacked / local-only until CH6f (already
anticipated by `…window-shell.md` §6.3).
- Any decision to defer the 480-second auto-save.