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>
1009 lines
51 KiB
Markdown
1009 lines
51 KiB
Markdown
# `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.1/§4.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 0x00–0x33 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.
|