From cf6ef8b4b4da9bbfb4374ed28c4ff62dd6344561 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 10 Aug 2026 22:37:46 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Campaign=20OP=20research=20=E2=80=94=20?= =?UTF-8?q?character=20option=20map=20(lane=20B)=20+=200x01A1=20wire=20(la?= =?UTF-8?q?ne=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-08-10-character-options-map.md | 615 ++++++++++ .../2026-08-10-set-character-options-wire.md | 1009 +++++++++++++++++ 2 files changed, 1624 insertions(+) create mode 100644 docs/research/2026-08-10-character-options-map.md create mode 100644 docs/research/2026-08-10-set-character-options-wire.md diff --git a/docs/research/2026-08-10-character-options-map.md b/docs/research/2026-08-10-character-options-map.md new file mode 100644 index 00000000..e3be0c61 --- /dev/null +++ b/docs/research/2026-08-10-character-options-map.md @@ -0,0 +1,615 @@ +# Character-tab option map — settings-track research lane B + +**Campaign:** settings track (retail four-tab Options panel). +Handoff: `docs/research/2026-08-10-settings-track-handoff.md`. +**Lane:** B — handoff research questions **Q2** (complete Character-tab +option map), **Q7** (which options have live acdream consumers), and the +**bot-relevance half of Q8**. +**Date:** 2026-08-10. **Mode:** research only — no code changed, nothing +built, nothing launched. + +**Sources.** `docs/research/named-retail/acclient.h` (verbatim retail +`PlayerOption` enum + `PlayerModule`/`UIOption_Checkbox` structs), +`docs/research/named-retail/acclient_2013_pseudo_c.txt` (BN pseudo-C), +the PDB-paired binary at `C:\Users\erikn\Downloads\acclient.exe` +(`check_exe_pdb.py` → `=== MATCH ===`, GUID +`9e847e2f-777c-4bd9-886c-22256bb87f32`, linker 2013-09-06T00:17:56Z), +vendored ACE at `references/ACE/`, and acdream `src/` in this worktree. + +--- + +## 0. Verdict summary + +| Question | Answer | +|---|---| +| Do we have the complete Character-tab row list? | **Yes, and it is decomp-authored, not inferred.** `gmCharacterSettingsUI::InitOptions @0x004a02f0` emits exactly **50 toggle rows in 6 header groups**, in an order that matches the user's three screenshots **row-for-row**. | +| Is ACE's option-id → bit map right? | **Yes, byte-verified.** The client's own `PlayerModule::GetOption @0x005d3aa0` switch assigns the same bit to all 52 options present in the 2013 build. `CharacterOptions1.Default = 0x50C4A54A` also reconstructs exactly from retail's own per-option default table (§1.4). | +| 0x0005 vs 0x01A1 — what decides? | **`CPlayerModule::IsAutoSaveOption @0x0059a600`**, a 0x34-byte lookup table (byte-verified, §1.3). 21 of 53 options send `SetSingleCharacterOption (0x0005)` **immediately**; the other 32 only mark the module dirty and ride the batched `SetCharacterOptions (0x01A1)` blob 480 s later or at forced save. | +| Does the Apply button send the blob? | **Not necessarily.** Each checkbox applies through `PlayerModule::SetOption` the moment Apply runs; auto-save ids leave as 0x0005 and never enter the blob. ACE's own header comment in `GameActionSetCharacterOptions.cs:11-16` independently describes this behaviour (and its stated rule — "options with a value set in the enum" — is **wrong**; all 53 have values. The real rule is the `IsAutoSaveOption` table). | +| Does acdream track these today? | **Two bits are live; everything else is either a local `settings.json` bool the server never sees, or absent.** 6 `Hear*Chat` bits drive `TurbineChatMembershipGate`; `DragItemOnPlayerOpensSecureTrade` drives item interaction. That is the whole list. | +| Is there a shared Runtime seam for bots? | **Yes, it already exists**: `IRuntimeCharacterCommands.SetSingleOption` (`src/AcDream.Runtime/GameRuntimeCommands.cs:249`), implemented by both hosts. It has **one defect** (§4.4): the headless implementation skips the local option-bit write the graphical one does. | +| Does ACE reject option changes from a live session? | **0x0005: no gate at all.** **0x01A1: refused before `FirstEnterWorldDone`.** But an *unknown* option id makes ACE throw (§5.3) — a real bot-safety constraint. | + +Two divergences worth naming immediately, because they are behavioural +and currently unfiled: + +1. **`/acceptcorpselooting` toggles a local bool the server never sees.** + `ClientCommandController.cs:444-450` flips + `GameplaySettings.AcceptLootPermits` only. Retail sends + `0x0005 (0x10)` immediately (auto-save), and ACE honours the bit at + `Player_Death.cs:755`. Today the corpse permission is a lie. +2. **`AutoRepeatAttack` is client-local in acdream but server-authoritative + in ACE.** `LiveCombatAttackOperations.cs:95,169` reads the local + setting; ACE independently re-attacks based on its own stored bit + (`Player_Melee.cs:375`, `Player_Missile.cs:284`). The two can disagree. + +--- + +## 1. The retail mechanism (settled, byte-verified) + +### 1.1 Storage + +`PlayerModule` (`acclient.h:36507`) holds the two option words plus the +non-boolean option payloads: + +```c +struct __cppobj PlayerModule : PackObj { + ShortCutManager *shortcuts_; + PackableList favorite_spells_[8]; + PackableHashTable,long> *desired_comps_; + unsigned int options_; // CharacterOptions1 + unsigned int options2_; // CharacterOptions2 + unsigned int spell_filters_; + GenericQualitiesData *m_pPlayerOptionsData; + PackObjPropertyCollection m_colGameplayOptions; + AC1Legacy::PStringBase m_TimeStampFormat; +}; +``` + +The linear id space is `enum PlayerOption` (`acclient.h:4162-4217`), +`0x00`..`0x33`, terminated by +`TotalNumberOfPlayerOptions_PlayerOption = 0x34`. **The 2013 build has no +`0x34`.** ACE's `ListenToPKDeathMessages = 0x34` is a post-PDB addition +(ACE says so itself at `PlayerFactory.cs:659` — *"possibly was added to +Defaults post PDB we have"*). + +### 1.2 Two kinds of option row — the tab discriminator + +`UIOption_Checkbox` (`acclient.h:6108`) carries **both** a +`PlayerOption m_playerOption` and a `PStringBase m_prefName` / +`unsigned int m_propName`. `UIOption_Checkbox::GetValue @0x00486f60` +branches on which is set: + +* `m_playerOption != Invalid (0xFFFFFFFF)` → `PlayerModule::GetOption` → + the server-synced character option. +* otherwise → `UIPreferences::InqPreferenceValue(m_prefName)` → a + **client-local preference**, applied via + `UIPreferences::ModifyPreference` + `CM_UI::SendNotice_UserPreferenceChanged` + (the apply path at `0x00486e2b`-`0x00486e49`). + +**Cross-lane fact, cheap to state here:** `gmConfigUI::InitOptions +@0x0049e400` and `gmChatOptionsUI::InitOptions @0x0049fc60` contain +**zero** `AddToggleOption(_PlayerOption)` calls (12 `AddHeader` + +12 `AddSeperator` between them, no PlayerOption rows). Every +server-synced character option in retail lives on the **Character tab**; +Config and Chat are `m_prefName`/per-window-blob territory. + +### 1.3 Apply → wire: `IsAutoSaveOption` + +``` +PlayerModule::SetOption(opt, v) @0x005d3eb0 + → writes the bit into options_ / options2_ + → virtual vtable+0x14 == CPlayerModule::OnChanged(PlayerOption) @0x0059a8e0 + → CM_UI::SendNotice_PlayerOptionChanged(opt) // local UI fan-out + → local side-effect switch (6 cases only — §1.5) + → if (CPlayerModule::IsAutoSaveOption(opt)) @0x0059a600 + CM_Character::Event_PlayerOptionChangedEvent(opt, GetOption(opt)) + // == GameAction opcode 5, 0x14-byte body: u32 option, u32 value + return; + else + m_bDirty = 1; m_timeFirstDirtied = Timer::cur_time; + +CPlayerModule::UseTime @0x0059a710 : dirty && (cur_time - firstDirtied) > 480.0 + → CM_Character::Event_CharacterOptionsEvent +CPlayerModule::SaveToServer @0x0059a660 : (dirty || force) → same +CM_Character::Event_CharacterOptionsEvent @0x006a10c0 : opcode 0x1A1, body is + literally PlayerModule::Pack(...) +``` + +So the **0x01A1 body is the packed `PlayerModule`** — flags-driven, +exactly what ACE's `GameActionSetCharacterOptions` reader parses. (Lane +for Q4; recorded here because the builder address is the answer.) + +**`IsAutoSaveOption` lookup table — byte-verified** from the PDB-paired +binary at VA `0x0059a62c` (file offset `0x19a62c`, `.text`), 0x34 bytes: + +``` +00 00 00 01 01 01 01 01 01 01 01 01 01 01 01 00 00 00 00 01 01 01 01 01 +01 00 01 00 01 01 01 01 01 01 01 00 00 00 00 00 01 01 00 00 01 01 00 00 +01 00 00 00 +``` + +`0x00` → `jump_table[0]` → `return 1` (auto-save). The 21 auto-save ids +are therefore: **0x00, 0x01, 0x02, 0x0F, 0x10, 0x11, 0x12, 0x19, 0x1B, +0x23, 0x24, 0x25, 0x26, 0x27, 0x2A, 0x2B, 0x2E, 0x2F, 0x31, 0x32, 0x33**. +Every other id (0x03–0x0E, 0x13–0x18, 0x1A, 0x1C–0x22, 0x28, 0x29, 0x2C, +0x2D, 0x30) is batched. + +Sanity check against an independent source: ACE's comment says +*"Auto Repeat Attacks → SetSingleCharacterOption; Disable Most Weather +Effects → won't trigger"*. Table: `0x00` = auto-save ✔, `0x04` = +batched ✔. + +### 1.4 The Defaults button — retail's own per-option default table + +`PlayerModule::GetDefaultOptionValue @0x005d2a30`. The +`UIOption_Checkbox` caches it into `m_default` at +`SetPlayerOption @0x00486f2d`, so this **is** what the Character tab's +Defaults button restores. + +Byte-verified at VA `0x005d2a5c` (file `0x1d2a5c`), **0x2B bytes** — +indices `0x00`..`0x2A` only, followed by `90` padding: + +``` +00 01 00 01 01 01 00 01 00 01 00 01 01 00 00 00 01 01 01 01 00 00 01 01 +01 00 01 00 01 01 01 01 01 01 01 00 00 00 01 01 01 01 00 +``` + +`0x00` → default **true**. **Options `0x2B`..`0x33` fall off the end of +the table and default to `false`** (`UseFastMissiles`, `FilterLanguage`, +`ConfirmVolatileRareUse`, `HearSocietyChat`, `ShowHelm`, +`DisableDistanceFog`, `UseMouseTurning`, `ShowCloak`, `LockUI`) — the +table was never extended when those options were added. + +Reconstructing the words from the true entries: + +* **Options1** = `0x02|0x08|0x40|0x100|0x400|0x2000|0x8000|0x40000|0x400000|0x800000|0x10000000|0x40000000` = **`0x50C4A54A`** — **identical to ACE's + `CharacterOptions1.Default`.** This is an independent binary + confirmation of that constant *and* of the id→bit map for those 12 ids. +* **Options2** = `0x100|0x200|0x400|0x8000` = **`0x00008700`**, whereas + ACE's `CharacterOptions2.Default = 0x00948700`. The three extra ACE + bits (`ConfirmVolatileRareUse 0x40000`, `ShowHelm 0x100000`, + `ShowCloak 0x800000`) are exactly three of the ids past the table's + end. **This is not a divergence to file** — retail's table is the + *client Defaults button*, ACE's constant is the *server + character-creation* value; they are different mechanisms and retail's + own server-side creation value is not observable to us. It IS a + behavioural difference the panel must reproduce: our Defaults button + must restore `0x50C4A54A` / `0x00008700`, not ACE's creation mask. + +### 1.5 Retail's immediate local side effects (only six) + +`CPlayerModule::OnChanged(PlayerOption)` switch — lookup table at +`0x0059aa04` (0x2F bytes, index = `arg2 - 2`), byte-verified: + +| Option | Effect | +|---|---| +| `0x02 IgnoreFellowshipRequests` | if set → clears `FellowshipAutoAcceptRequests` | +| `0x04 DisableMostWeatherEffects` | `SmartBox::EnableWeather(!value)` | +| `0x05 PersistentAtDay` | `LScape::SetDay(value)` | +| `0x07 ViewCombatTarget` | `ClientCombatSystem::TrackTarget(value)` | +| `0x12 FellowshipAutoAcceptRequests` | if set → clears `IgnoreFellowshipRequests` | +| `0x30 DisableDistanceFog` | `LScape::m_fFogEnabled = !value` | + +`CPlayerModule::OnInitialize @0x0059a690` applies four of these at login +(`PersistentAtDay`, `DisableDistanceFog`, `DisableMostWeatherEffects`, +`ViewCombatTarget`). **Everything else is consumed lazily at its point of +use** (§2's "retail consumer" column). + +> **BN artifact note.** `OnInitialize`/`OnChanged` render the logical NOT +> as `eax = -(eax); x = ((eax - eax) + 1)` — that is `neg` / `sbb eax,eax` +> / `add eax,1`, i.e. `x = !value`. Reading it literally as arithmetic +> would invert the weather and fog semantics. This is the exact artifact +> class the chat digest's DO-NOT-RETRY table warns about. + +### 1.6 Every option is also a bindable keyboard action + +`CPlayerSystem::OnAction(InputEvent*) @0x00561890` contains one +`case` per option that does `Set