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>
51 KiB
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
- The 0x01A1 body is
PlayerModule::Pack— the whole player-module object, not an options word. Four unconditionalu32s (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. - Retail's send decision is a byte-verified 52-entry lookup table,
CPlayerModule::IsAutoSaveOption @0x0059A600: 21 of the 52PlayerOptionids send0x0005immediately 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. - ACE validates nothing. It clamps nothing. It raw-stores
options1,options2and the opaqueGameplayOptionsbyte[] and discards every other section of the blob (each has its own dedicated GameAction). Its only refusal is0x01A1beforeLoginComplete (0x00A1)— silently dropped with a log warning.0x0005has no gate at all. - The deleted CH3 builder was a 16-byte message with one
u32where 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. - A resurrected builder plugs into
SocialActions→WorldSession.SendSetCharacterOptions→IRuntimeCharacterCommands(a new sibling ofSetSingleOption), with the local-write-then-notify pattern already proven byRuntimeCharacterOptionsState.SetOptionBit. - 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)
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)
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
memcpys 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
- 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. - Retail's tail padding lands inside it.
PlayerModule::Packzero-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 nextPlayerDescription. 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:
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
- Malformed blob → partial application, silently. ACE's per-action
try/catch(InboundMessageManager.cs:135-143) logs the exception and continues; the session survives. ButSetCharacterOptions1runs before any read that could throw, so a truncated blob can leaveoptions1applied andoptions2not. Never send a speculative blob. - Out-of-range option id on 0x0005 throws. ACE casts the id to
CharacterOptionunchecked, thenCharacterOptionExtensionsdoes aDictionary[val]lookup built fromEnum.GetValues— an id that is not an enum member raisesKeyNotFoundException, caught and logged, option lost. Only send ids in the enumerated set. - Ids
0x35and0x36are landmines. ACE definesCharacterOptions1Default = 0x35andCharacterOptions2Default = 0x36with the whole default mask as their attribute. Sending0x35 = trueORs the entire0x50C4A54Adefault intooptions1. These are not real options — never emit them. 0x34is 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-
LoginCompletefor 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(GameMessageObjDescEventre-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:
- Diff against the
PlayerDescription-seeded state, afterLoginComplete, then send. - Prefer
0x0005per 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.
- Diff against the
- Presentation-only settings must stay out of the bot schema (opacity,
window geometry, audio, quality) — they live in the
GameplayOptionsarchive 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:
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:
-
Every options1 bit aliases onto a section flag. Feeding
CharacterOptions1.Default = 0x50C4A54Ainto the flags slot lights five sections at once:bit as CharacterOptions1read by ACE as 0x002AutoRepeatAttackSquelchList(never read — ACE's branch is commented out)0x008IgnoreFellowshipRequestsDesiredComps0x040AllowGiveCharacterOptions20x100ShowTooltipsGenericQualitiesData0x400ToggleRunSpellLists8(and
UseDeception 0x200→GameplayOptions,DisableMostWeatherEffects 0x10000→ nothing, etc. for non-default values). The handoff's shorthand — "CharacterOptionDataFlag.CharacterOptions2 = 0x40collides withCharacterOptions1.AllowGive = 0x40" — is the single sharpest instance of this aliasing (AllowGiveis in the default mask), not a separate bug. -
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 throwsEndOfStreamException, caught and logged byInboundMessageManager. Nothing would have been applied except… nothing, because options1 is read after flags. (Had the payload been one word longer,SetCharacterOptions1would have been called with garbage before the throw — the partial-application hazard of §5.4.1.) -
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 sentstring16Lwhere 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.Defaultmismatch) was filed and then retracted the same day — the wrong literal0x50C48D4Aexisted only in a research doc. §2.5's byte dump of the retail constructor now confirms0x50C4A54Afrom 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 fromsrc/AcDream.Runtime/Session/LiveSessionEventRouter.cs:210-213on everyPlayerDescription. This is the seed the bot diff must run against.SetOptionBit(characterOptionId, bool)— the local write. Currently recognises only the sixListenTo*Chatids and silently no-ops every other id (_ => (false, 0u)at:687). The Options panel needs the fullPlayerOption → (word, mask)table here; §3.2's table plusacclient.h:3404(CharacterOption) andacclient.h:3451(CharacterOptions2) are the complete verbatim source.Snapshotcarries aRevisioncounter, 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:
- It reads the 8 spell lists only under
SpellLists8, with a single-list fallback otherwise; it ignoresMultiSpellList (0x04)andExtendedMultiSpellLists (0x10)entirely. Retail reads list[0] unconditionally, then 4 / 6 / 7 more. Equivalent for ACE (which always sets0x400); divergent against a hypothetical legacy sender. - It reads
spellbookFilterswhenever ≥4 bytes remain rather than gating on0x20, and defaults to0x3FFF(matching retail's UnPack default) if absent. Equivalent for ACE (always sets0x20). - It never reads the
0x80or0x100sections. 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↔0x02000000mapping is ACE-sourced, UNVERIFIABLE against our binary. Use it, cite ACE, and mark it in the register. - Whether the final client treats
0x34as auto-save is UNKNOWN. Its fiveHear*Chatsiblings 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 (the0x0005handler'sdefault:branch just sets the bit; there is no PK-death Turbine room). IsAutoSaveOption'scmp eax, 0x33 / ja → 0means the 2013 client would classify0x34as 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
- 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.
- The send policy (§3.5) is byte-verified, including the 21-id auto-save table (§3.2) and the 480-second timer (§3.3).
- ACE accepts anything post-
LoginCompleteand stores onlyoptions1,options2,GameplayOptions(§5).
Recommended slice shape
- S-a — the option map. Extend
enum CharacterOptionIdto all ids0x00..0x33(verbatim fromacclient.h:4162) plus ACE's0x34, and extendRuntimeCharacterOptionsState.SetOptionBitfrom six ids to the fullPlayerOption → (Options1|Options2, mask)table (acclient.h:3404/:3451). AddIsAutoSave(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: reject0x35/0x36at the seam (§5.4.3). - S-b — the dirty model. Add retail's
m_bDirty+m_timeFirstDirtied(Revisionalready 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.7WorldSession.SendSetCharacterOptions+ a newIRuntimeCharacterCommands.SetAllOptions-style command besideSetSingleOption. Echo the last-parsed shortcuts / spell tabs / desired comps / spellbook filters rather than zeroing them (§5.3). Omit0x100entirely (U2). Conformance tests must round-trip throughPlayerDescriptionParserand must include a golden byte vector, since the last builder's tests pinned a wrong shape and looked green.
- S-d — the headless
characterOptionsblock. Strict JSON schema keyed by option name, resolved through S-a's map; diff against thePlayerDescription-seeded state afterLoginComplete; auto-save ids go out as0x0005, 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
0x200when 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:0x0005is 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
GetDefaultOptionValuequirk (§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 @0x005D35C0writes the bit, then jumps toOnChanged) and it is what makes the client's own consumers correct before the round trip.
Register rows this work will need
0x34 HearPKDeathmapping sourced from ACE, unverifiable against our 2013 binary (§8.1).- The
GetDefaultOptionValuevs 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.