# Campaign OP slice OP3 — MECHANISM-FAITHFULNESS review **Commit under review:** `9d26ecc6` ("feat(ui): Campaign OP slice OP3 — Options panel shell, open paths, Gameplay tab") **Contract:** `docs/plans/2026-08-10-options-panel-campaign.md` §4 OP3 + §2 D1/D5/D6/D9 **Oracles:** `docs/research/2026-08-10-options-panel-structure.md` §5/§6/§10.4; `docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md` §1–§4/§7.1; `docs/research/named-retail/acclient_2013_pseudo_c.txt` + `docs/research/named-retail/acclient.h` (re-read directly for every decomp claim the code's comments make). **Lens:** mechanism faithfulness only (blast radius is the other lens). **Method:** read-only. No build, no test run, no client launch. ## VERDICT: APPROVE-WITH-FIXES One MUST-FIX (a byte-verifiable wrong `LogTextType` on six user-visible chat lines this slice ships), six SHOULD-FIX, eight NOTEs. The core ports — `OptionPage`/`BoolOptionRow`, the mount, the two open paths, the seven-button dispatch, the mouse-turning macro — are faithful and, unusually for a slice this size, every element id and every retail anchor I re-checked held up. --- ## 0. What I verified as CORRECT (evidence first) These are not filler: each one was a candidate finding that the source refuted. ### 0.1 `OptionPage`/`BoolOptionRow` vs the decomp — exact Re-read at `acclient_2013_pseudo_c.txt:245757` (`OptionPage::SaveCurrentValues @0x004F2C60`), `:245783` (`RestoreDefaultValues @0x004F2CB0`), `:245809` (`RestoreSavedValues @0x004F2D00`), `:245834` (`Changed @0x004F2D60`), `:245374` (`PlayerOptionPage::OnVisibilityChanged @0x004F26E0`), `:245390` (`PlayerOptionPage::SaveCurrentValues @0x004F2710`), `:146926-146990` (`UIOption_Checkbox::Changed/SaveCurrentValue/RestoreSavedValue/ RestoreDefaultValue/SetDefaultValue/SetCurrentValue`). | Contract item | Retail | `OptionPageModel.cs` | Verdict | |---|---|---|---| | LED click applies immediately | `0x00486983-88`: `SetAttribute_Bool(0xE, v); vtable->Apply(1)` | `:82-86` `_current = value; _apply?.Invoke(value)` | ✅ | | Apply commits **unconditionally** | `0x004F2C71-93`: no `Changed` test in the loop | `:178-183` plain `foreach` | ✅ | | Apply flushes the blob | `0x004F271D` `SaveToServer(pm, 0)` then tailcall | `:182` `AfterApply?.Invoke()` | ✅ | | Reset reverts **only Changed** | `0x004F2D1E`: `if (ecx != 0 && vtable+0x2BC() != 0)` | `:192` `.Where(row => row.Changed)` | ✅ | | Reset applies live | `0x00486913` `Apply(0)` | `:90-94` re-invokes `_apply` | ✅ | | Defaults applies live, no commit | `0x004F2CC1-E3` unconditional; `0x00486943` `Apply(0)`; never touches `m_saved` | `:199-203` / `:96-100` | ✅ | | Defaults never disable-gated | `0x004F27D0` only fetches `0x100001FC`/`0x100001FD`; `0x100001FE` absent | no gate exists in the model | ✅ | | Hide reverts uncommitted | `0x004F2701` `RestoreSavedValues` | `:213` `OnHidden() => Reset()` | ✅ | | Show applies + commits | `0x004F26F4` `SaveCurrentValues` | `:208` `OnShown() => Apply()` | ✅ | `Changed => _saved != _current` matches `0x004868D7` exactly, and `OptionPage.Changed` matches `0x004F2D7B`'s any-row short-circuit. ### 0.2 The mount — both claims independently confirmed from the fixtures - `tests/AcDream.App.Tests/UI/Layout/fixtures/options_panel_2100006E_1000018D.json:1-6` — root `Id: 268435853` = **`0x1000018D`**, `Type: 8`, 300 × 362, i.e. the import through host `0x2100006E` really does resolve to the base-merged tab host at the authored slot extent. - Same fixture `:583-587` — the slot element itself authors `"268435497"` (= `0x10000029`, the `gmPanelUI` stack key) with `UnsignedValue: 10`. This corroborates research §10.1's "stack key `P0x10000029 = 10`" *from the DAT*, not just from the decomp. - `tests/.../fixtures/toolbar_21000016.json:7869` — element `268435867` = **`0x1000019B`** authors the same `"268435497": UnsignedValue: 10` **and** `"18"` (= `P0x12`) `= 268435482` (= `0x1000001A`). - `0x10000049` (`RestorePreviousPropertyId`) is **absent** from the options slot (`grep -c 268435529` → 0), so `RegisterMainPanel(..., restorePrevious: false)` is the correct read. **Is `P0x10000029` the property `RetailPanelUiController` consumes, and did `P0x12` silently win?** `ToolbarController.cs:49` `PanelIdAttribute = 0x10000029u`; `:170-175` collects `(panelId, button)` pairs from exactly that property for the seven ids in `PanelButtonIds` (`:54-55`, which already included `0x1000019B`); `RetailUiRuntime.BindToolbarPanelButtons` `:677-687` maps panel id → window name → `ToggleWindow`. **`P0x12` is consumed nowhere** — `grep -rn "0x12u\b" src/AcDream.App/UI/` returns nothing, and neither `ElementReader`, `LayoutImporter`, `DatWidgetFactory` nor `UiButton` reads an authored input-action id. So there is exactly ONE route, no double-toggle, and the implementer's "no new plumbing beyond the catalog entry" claim is accurate (`RetailPanelCatalog.cs:30,42` are the only two new lines). Retail's own toolbar highlight path (`gmToolbarUI::RecvNotice_SetPanelVisibility @0x004BD300`, cited at `ToolbarController.cs:395`) is panel-id-based too, so the surviving route is the retail-consistent one. **Mutual exclusion** is real, not asserted: `RetailUiRuntime.ToggleWindow` `:597-600` routes any catalog-known name into `_panelUi.TogglePanel(panelId)`, and `RetailPanelUiController.SetPanelVisibility` `:154-172` hides the previous `_activePanel` before showing the requested one. ### 0.3 F11 — the binding really did pre-exist, in the right table `src/AcDream.UI.Abstractions/Input/KeyBindings.cs:216` sits inside `RetailDefaults()` (opens `:147`), binding `Key.F11 / None → InputAction.ToggleOptionsPanel`, matching `docs/research/named-retail/retail-default.keymap.txt:148` (`ToggleOptionsPanel [ "" [ 0 DIK_F11 ] ]`). The commit changed only the *landing*: `GameplayInputCommandController.cs:238-246` now calls `_retained.ToggleOptionsPanel()` (→ `RetailUiRuntime.ToggleWindow( WindowNames.Options)` → the panel controller) instead of the dead `_devTools.ToggleSettingsPanel()`. `ToggleSettingsPanel` itself is untouched and now unreachable from any action — exactly what D1/OP9 contract for. ### 0.4 The seven buttons vs research §6 / §1–§4 Element ids in `OptionsPanelController.cs:62-68` (`0x10000203`, `0x10000204`, `0x10000205`, `0x10000206`, `0x10000207`, `0x100005CC`, `0x10000617`) and the close button `0x10000210` (`:58`) match research §6's table and §10.1 one-for-one, and `OptionsPanelControllerTests.cs:247-268` pins all seven against the committed fixture (so a future regeneration that drops an id fails the build rather than degrading to a logged no-op). - **Exit Game** → `_bindings.Indicators.EndCharacterSession` directly (`RetailUiRuntime.cs:1942`) — **no** confirmation, **no** mid-air check. Matches §3: `m_bLogoutConfirmed` is set by the button itself and `UseTime` takes the `QueueUIMode(0x10000009)` epilogue branch, which never reaches the `transient_state` test. - **Exit to Character Selection** → confirm FIRST, mid-air check on accept (`:1846-1860`). Matches `RecvNotice_EndCharacterSession(1)` (dialog) → `gmGamePlayUI::UseTime @0x004EA3A0` (the `m_bLogoutConfirmed`-gated drain that owns the `transient_state & 1` test). Ordering is right. - The confirm text is **DAT-resolved**, not hard-coded: `ResolveEndCharacterSessionConfirmMessage()` `:1834-1845` resolves `ID_Client_EndCharacterSessionConfirm` through `DatStringResolver.ComputeHash` against table `0x23000001`. - **Mid-air refusal typing is EXACTLY right.** Retail: `ECM_UI::SendNotice_DisplayStringInfo(0x1A, "Cannot log off while in mid-air.")`. acdream: `RetailLogTextType.ClientLocal` = `RetailLogTextType.cs:60` **`0x1A`**. Same constant. The string itself is byte-anchored at VA `0x007C29C0` in `ClientTextRefusals.cs`. - **Inert buttons are genuinely inert.** `Bind` `:163-174` never calls `BindButton` for `0x10000204`/`0x10000205`; `BindButton` `:188-197` early-returns on a null handler. Tests `:220-245` assert zero callbacks. No invented text anywhere in the slice. ### 0.5 The mouse-turning macro vs `SetMouseTurningDefaults @0x0049E8F0` Re-read at `acclient_2013_pseudo_c.txt:169512-169640`. Every structural claim holds: | # | Retail condition / target | `MouseTurningSettingsMacro.cs` | ✓ | |---|---|---|---| | 1 | `GetCurrentValue() != 0.949999988f` → `SetCurrentValue(0.95f)` `@0x0049E93C` | `:60-65` vs `MouseTurningTarget.Stiffness = 0.95f` | ✅ | | 2 | `!= 50f` → `50f` `@0x0049E9AC` | `:67-72` | ✅ | | 3 | `!= 0.699999988f` → `0.7f` `@0x0049EA1C` | `:74-79` | ✅ | | 4 | `m_pSlope->GetValue() == 1` → `SetCurrentValue(0)` `@0x0049EA69-77` | `:81-86` (target false) | ✅ | | 5 | `m_pInvert->GetValue() == 0` → `SetCurrentValue(1)` `@0x0049EABB-C9` | `:88-93` | ✅ | | 6 | `m_pMouseTurn->GetValue() == 0` → `SetCurrentValue(1)` `@0x0049EB0D-1B` | `:95-99` | ✅ | | tail | `this->vtable->SaveCurrentValues()` `@0x0049EB57` | `RetailUiRuntime.cs:1908` `ConfigPage.Apply()` → `AfterApply` → `SaveCharacterOptionsRuntimeCmd` | ✅ | The tail is faithful because `gmConfigUI : PlayerOptionPage, gmNoticeHandler` (`acclient.h:55846`), so its `SaveCurrentValues` vtable slot IS `PlayerOptionPage::SaveCurrentValues` → `SaveToServer(pm, 0)`. acdream's `SaveOptions` is dirty-AND-seed-gated (`RuntimeCharacterOptionsState.TryFlush` `:922-941`), which is exactly `SaveToServer(force: 0)`'s `if (m_bDirty != 0 || arg2 != 0)`. The five client-local vs one server-synced split is right, and `UseMouseTurning` really is an **auto-save** id in OP1's table (`CharacterOptionTable.cs:160`: `Add(UseMouseTurning, false, 0x00400000u, true, false)` → `autoSave: true`), so the `SetSingleCharacterOptionRuntimeCmd` publication reaches `TrySetOption` and emits a `0x0005` immediately, after the local bit write. Correct per lane C's split. `%f` → `.ToString("F6")` is the right rendering (retail's `%f` default precision is 6; the double promotions of `0.45f`/`0.55f`/`0.7f` all round to the same six decimals as .NET's float `F6`). ### 0.6 TS-74's "camera mode ABSENT" verdict — spot-check holds `src/AcDream.UI.Abstractions/Input/MouseLookState.cs:5-32` documents and implements only retail's MMB-hold `CameraInstantMouseLook` (three transitions: `Press`/`Release`/`OnWantCaptureMouseChanged`; `ApplyDelta` scales mouse-X into a turn adjustment). There is no persistent "turn to face camera" mode. The only other `UseMouseTurning` references in the tree are `GameplaySettings.cs:42/65`, `SettingsPanel.cs:366-368` (the unrendered ImGui-era surface OP9 retires) and `SettingsStore.cs:161/617` — i.e. storage, no consumer. **The ABSENT verdict is correct.** ### 0.7 Register rows `AD-74`, `AD-75`, `AD-76`, `TS-74` are accurate against the code they cite, carry the five-column shape plus retail anchors, and the section counts are bumped correctly (AD 53 → **56**, TS 40 → **41**) with the header narrative updated in the same hunk. No resurrection of retired rows in the diff (the `feedback_register_revert_resurrection` check). --- ## 1. MUST-FIX ### M1 — the six mouse-turning chat lines are typed `0x1A`; retail types them `0x07` **Where:** `src/AcDream.App/UI/RetailUiRuntime.cs:1906-1907` (`foreach (string line in result.ChatLines) _bindings.Options.DisplaySystemMessage(line);`) → `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs:815-816` (`DisplaySystemMessage: text => d.Communication.AddText(text, RetailLogTextType.ClientLocal)`). **Retail, byte-anchored.** All six emission sites in `gmConfigUI::SetMouseTurningDefaults` call the same chokepoint with the same type argument (`acclient_2013_pseudo_c.txt:169512-169640`, addresses `0x0049E97C`, `0x0049E9EC`, `0x0049EA5C`, `0x0049EAAE`, `0x0049EB00`, `0x0049EB52`): ``` 0049e972 *(uint32_t*)((char*)var_2c)[4] = 7; <- BN's rendering of the pushed type 0049e97c ClientSystem::AddTextToScroll(ClientUISystem::GetUISystem(), var_2c, 1, 0); ``` The five-parameter signature is `ClientSystem::AddTextToScroll(this, char const* text, uint32 type, uint8 allowPluginFilter, uint32 windowId)` (`acclient_2013_pseudo_c.txt:148528`), and the fully-decoded sibling site three functions away renders all five: `0x004887B3` → `AddTextToScroll(GetUISystem(), <"The key for Camera Zoom In has been…">, 7, 1, 0)` (`:148984`). The stray `= 7` line immediately preceding each truncated call is the missing `type` argument in the same idiom. **Type = 7.** `0x07` is `Magic` / `colorLightBlue` `#3FBFFF` (`docs/research/2026-08-09-chat-retail-color-table.md:151`). acdream ships `0x1A ClientLocal` — **bright red**, and per that doc's §3.2 `0x1A` also *bypasses the timestamp prefix and the chat log file*, which `0x07` does not. So this is a colour divergence AND a routing divergence on six lines the OP3 gate script explicitly asks the user to eyeball (step 15). **Why this is MUST and not SHOULD:** the answer is byte-verifiable and unambiguous, the fix is one typed argument, the divergence is user-visible at the connected gate the user is about to run, `OptionsPanelText.cs`'s own doc calls these "byte-verified retail string literals" (true of the text, not of the typing), and **no register row covers it** — an undocumented deviation is, per the register rules, "a bug twice over". **Fix:** give the Options bindings a typed message seam (e.g. `Action`, or a second delegate) and emit the six macro lines as `RetailLogTextType.Magic (0x07)`. Keep `ClientLocal (0x1A)` for the mid-air refusal (§0.4 — that one is exact) and for the UA/RA adaptation (AD-75 already records that retail used a native `MessageBoxA` there, so there is no retail type to match). --- ## 2. SHOULD-FIX ### S1 — the Gameplay tab is given an `OptionPage`; retail's Gameplay page is not one **Evidence.** `acclient.h:55857`: `struct __cppobj gmGameplayOptionsUI : UIElement_Field, gmNoticeHandler` — it derives from neither `OptionPage` (`acclient.h:54418`) nor `PlayerOptionPage` (`:55739`). Its class id is the Gameplay page slot's authored class (`UIElement::RegisterElementClass(0x10000029, gmGameplayOptionsUI::Create)` at `acclient_2013_pseudo_c.txt:169244`, and research §10.1 lists page class `0x10000029` for the Gameplay page). The three *other* pages do derive: `gmCharacterSettingsUI` (`:55745`), `gmChatOptionsUI` (`:55817`), `gmConfigUI` (`:55846`), all `: PlayerOptionPage`. Consequence in retail: showing/hiding the Gameplay tab runs plain `UIElement::OnVisibilityChanged` — **no** `SaveCurrentValues`, **no** `SaveToServer`, no baseline traffic of any kind. **What OP3 ships instead.** `OptionsPanelController.cs:125-129` creates an `OptionPage` for *all four* slots including `GameplayPageId`, each with the same `AfterApply`, and `RetailUiRuntime.cs:1944-1946` wires that `AfterApply` to `Publish(new SaveCharacterOptionsRuntimeCmd())`. So: 1. `controller.ActivateTabs()` at mount (`RetailUiRuntime.cs:1954`) fires `ActivePageChanged(0 → 0x10000212)` → `GameplayPage.OnShown()` → `Apply()` → a `SaveOptions` publication **during UI mount, before login state exists**. It is inert today only because OP1's M1 fix makes `TryFlush` refuse before `HasServerSeed` (`RuntimeCharacterOptionsState.cs:928`). 2. Every later entry into the Gameplay tab publishes another `SaveOptions`. After any batched (non-auto-save) Character-tab edit — i.e. from OP4 on — switching to the Gameplay tab flushes the `0x01A1` blob at a moment retail would not (retail waits for Apply, logout, or the 480 s timer). **The justification in the code is the inverse of the decomp.** `OptionPageModel.cs:136-144` argues the empty page still flushes because "retail's `SaveCurrentValues` flushes the blob regardless of whether THIS page's own rows changed anything". That sentence is true — but only for a page that *is* a `PlayerOptionPage`, which the Gameplay page is not. This is precisely the `2026-08-05-c4-closeout-handoff.md` failure class: a contract asserting a mechanism that does not exist. **And two green tests pin the wrong shape** — `OptionPageModelTests.cs:48` `EmptyPage_Apply_StillInvokesAfterApply` and `:63` `EmptyPage_OnShown_InvokesAfterApply` — the same "green tests pinning a wrong shape" that killed the CH3 builder (plan §4 OP1 cites it). **Fix:** do not construct an `OptionPage` for `GameplayPageId` (or construct it with `AfterApply = null` and document *why*), leave `OnActivePageChanged` tolerant of a slot with no page model (it already uses `TryGetValue`), and retarget the two tests at a *synthetic* empty `PlayerOptionPage`-shaped page so the model-level property is still pinned without asserting it about the Gameplay tab. Amend the `OptionPageModel` XML doc's empty-page paragraph to say `gmGameplayOptionsUI` is not an `OptionPage` at all. ### S2 — `OptionPage` has no `OnOptionChanged` seam, so "Apply/Reset disable when clean" is only half-ported Retail runs `this->vtable->OnOptionChanged(0)` as the **last statement of all three verbs** — `0x004F2C95` (Apply), `0x004F2CE5` (Defaults), `0x004F2D4A` (Reset) — and `UIOption::HandleDialogAndNotices @0x004EFB90` additionally calls `m_pOCH->OnOptionChanged(this)` on a user click (`Apply(1)` only). The override that matters, `PlayerOptionPage::OnOptionChanged @0x004F27D0` (`acclient_2013_pseudo_c.txt:245433-245455`), is the ONLY thing that enable-gates the buttons: ``` eax = GetChildRecursive(page, 0x100001FC); // Apply eax_1 = GetChildRecursive(page, 0x100001FD); // Reset if (OptionPage::Changed(page) == 0) { SetState(0x0D) on both } // disabled else { SetState(0x01) on both } // enabled ``` `0x100001FE` (Defaults) is never fetched — which is exactly research §10.4's point 3. The contract (§4 OP3) lists "Apply/Reset disable when clean" as an `OptionPageModel` requirement. The model ships only the `Changed` predicate (`:173`) with **no notification and no verb-tail hook**, so nothing can react to a value change without polling, and each of OP4/OP5/OP6 will have to invent its own driver — three chances to get retail's call order wrong. **Fix (small, and it belongs here rather than in OP4):** add `Action? OnOptionChanged { get; set; }` to `OptionPage`, invoke it as the last statement of `Apply`/`Reset`/`Defaults`, and give `BoolOptionRow` a page-notification callback fired from `SetCurrentValue` only (retail's `Apply(1)`-only path) — not from `RestoreSavedValue`/`RestoreDefaultValue`, which retail calls with `Apply(0)` precisely so the page can notify once itself. ### S3 — `IsGrounded`'s `IsPlayerMode` conjunction emits the mid-air refusal where retail emits nothing, and the comment says the opposite `InteractionRetainedUiComposition.cs:807-810`: ```csharp // "on the ground" is meaningless outside player mode, so the mid-air // refusal never fires while flying/spectating. IsGrounded: () => d.PlayerMode.IsPlayerMode && d.PlayerController.Controller is { IsAirborne: false }, ``` `RetailUiRuntime.cs:1852-1859` takes the **else** branch — i.e. *emits* "Cannot log off while in mid-air." — whenever `IsGrounded()` is false. With `IsPlayerMode == false` (or a null `Controller`), `IsGrounded()` is false, so the refusal **does** fire, which is the opposite of what the comment asserts. Retail's drain (`gmGamePlayUI::UseTime @0x004EA3A0`, research §2.2) reaches the `transient_state & 1` test only inside `else if (smartbox->player)`; with no player it emits nothing at all. The mapping itself is right where it matters — `PlayerMovementController.cs:441` `IsAirborne => !_body.OnWalkable` is exactly retail's `transient_state & 1` (`ON_WALKABLE`) inverted. It is only the no-player fallback that misbehaves, and only by producing a misleading line. **Fix:** either drop the `IsPlayerMode` conjunction from *this* binding (the `playerOnGround` shape at `:322-324` is a different question — there it gates a positive action), or make the binding tri-state (`bool?`) so "no player" maps to retail's silent no-op. Either way the comment must be corrected. ### S4 — file the docked-`gmPanelUI`-host register row the plan delegated to this review Plan §5: "The `0x21000017` docked `gmPanelUI` host variant — acdream ships the floating host only (**register row in OP3 if the review deems it a divergence**; retail exposes both)." It is a divergence — retail authors both hosts for the same slot (research §10.1) and a retail user can dock the Options panel; acdream can only float it. `grep -n "21000017\|docked\|gmFloatyPanelUI" docs/architecture/retail-divergence-register.md` returns **nothing**, so there is no existing row. Two nuances for whoever writes it: (a) OP3 did not *introduce* this — every `gmPanelUI` sibling already ships floating-only via `RetailWindowFrame.Mount` + `RegisterMainPanel`, so the row should be scoped client-wide (all registered main panels), not "the Options panel"; (b) it is an AD (adaptation), not a TS. ### S5 — TS-74's "five unwired preferences" is carried only in the Risk column The row's What/Where is about the missing camera *mode*; the fact that all five `CameraTurningSettings` values land in a brand-new `settings.json` `cameraTurning` section that **no subsystem reads** appears only as a parenthetical in the Risk column ("only the (unwired) preferences persisting"). That has a concrete user-visible consequence the row does not state: the chat lines quote a *from* value (`0.550000`,`0.450000`,`40.000000` on a fresh profile — `CameraTurningSettings.Default`) that describes no live client state, while acdream's actually-live sensitivity lives elsewhere (`MouseLookState.SensitivityPerPixel`, F8/F9-adjustable). **Fix:** extend TS-74's What/Where to name the five keys as store-only with no consumer (OP6 is their contracted home), so the row can be retired precisely rather than argued about later. ### S6 — three gate-visible behaviours missing from the OP3 test script `docs/research/2026-08-11-campaign-op-test-script.md` is otherwise strong (15 steps, retail-expected outcome stated for each, INERT explicitly contracted, "what to report" and "not in scope" sections). Gaps: 1. **The toolbar Options button changed state, not just behaviour.** Before this commit, panel id 10 was not in `RetailPanelCatalog`, so `BindPanelButtons`'s `isAvailable(10)` returned false and the button was **ghosted/disabled** (`ToolbarController.cs:379-381`). Step 3 should say "the button is no longer greyed out, and highlights while the panel is open" (`SetPanelOpen` → `Highlight`/`Normal`, `:393-405`). 2. **Nothing proves the `UseMouseTurning` `0x0005` reached ACE.** TS-74 says the bit is sent; the only user-observable proof is a relogin (after which a second click should emit no "Turn to Face Camera" line, because `IsUseMouseTurningEnabled` reads the server-seeded `Options2`). Add a step. 3. **No cross-relaunch check on the five new preferences.** Step 15's "second click in the same session sees zero lines" only covers in-session state; a relaunch is what exercises the new `cameraTurning` `settings.json` section. (If M1 is fixed, step 15 should also state the expected *colour* — light blue, not the red the SpewBox uses for client-local refusals.) --- ## 3. NOTES (no action required this slice; carry into OP4–OP6) - **N1 — flush/commit ordering.** Retail's `PlayerOptionPage::SaveCurrentValues` calls `SaveToServer` at `0x004F271D` and *then* tailcalls `OptionPage::SaveCurrentValues`; acdream commits baselines first, then `AfterApply`. Content-identical (every value is already live before Apply is pressed), so this is deliberate-looking and harmless — recording it so a future reader does not "discover" it as a bug. - **N2 — `SaveCurrentValue` re-reads the widget in retail.** `0x004868E5`: `m_current = vtable->GetValue(); m_saved = m_current;`. `BoolOptionRow.SaveCurrentValue` commits its own `_current`. Equivalent only while *every* mutation goes through `SetCurrentValue`. OP4 must not let an LED widget mutate its own visual state on a path that bypasses the row. - **N3 — `Apply(0)` vs `Apply(1)` is not modelled.** Retail's Reset/Defaults pass `0`, which skips `UIOption::HandleDialogAndNotices`'s confirm dialog and the per-option `OnOptionChanged` notify (`0x004EFB90`). The single `_apply` callback carries no arg. This first bites at OP6 — `gmConfigUI::InitOptions @0x0049E5BF` is the only `SetConfirmChange` on the four tabs (`Display_Resolution`). - **N4 — `%f` fidelity.** `F6` on `float` matches retail's `%f` on the double-promoted float for all six values in play. No action. - **N5 — hard-coded English confirm fallback.** `ResolveEndCharacterSessionConfirmMessage`'s `?? fallback` predates this slice (it was `RequestEndCharacterSession`'s own), and the test script correctly instructs the user to report if the fallback is what they see. Plan §6's "never hard-coded English" is about DAT-resident strings; the byte-verified `.rdata` literals in `OptionsPanelText`/`ClientTextRefusals` are the Campaign CH-established pattern and are fine. - **N6 — `P0x12` is dead data client-wide.** Not an OP3 defect (see §0.2), but worth an issue eventually: several authored buttons carry an input-action id acdream never reads, and OP8's Configure Keyboard button (`P0x12 = 0x1000001F`, research §6) is one of them — OP8 will need either that mechanism or an explicit binding. - **N7 — UA/RA text is *near*-verbatim, and AD-75 covers the delta.** The shipped body drops retail's `(Error code %d)` clause (documented) and its trailing `\n` (not documented, immaterial in a chat line). One unverified detail nobody has closed: the research renders the literal wrapped over three lines, so whether "…is listed below." and "Please go there…" are separated by a space (as shipped) or a newline in the binary is **not** established. Cheap to settle with one push-imm32 read at VA `0x007A81E0` / `0x007A8128` if OP9 wants it exact. - **N8 — mount ordering.** `MountOptionsPanel()` runs before `MountDialogFactory()` in `RetailUiRuntime.Mount`. Safe, because `ShowConfirmation` resolves `DialogFactory` at click time, not at bind time — noting it so a future reorder does not silently break Exit-to-Char-Select's dialog. --- ## 4. Summary table | ID | Class | One-line | |---|---|---| | M1 | MUST-FIX | Six mouse-turning chat lines typed `ClientLocal 0x1A`; retail's `AddTextToScroll(…, 7, 1, 0)` types them `0x07` (light blue, timestamped, logged). No register row. | | S1 | SHOULD-FIX | Gameplay tab gets an `OptionPage` + `AfterApply`; `gmGameplayOptionsUI` is not an `OptionPage` (`acclient.h:55857`) — extra `SaveOptions` at mount and on every Gameplay-tab entry, with two tests pinning the wrong shape. | | S2 | SHOULD-FIX | No `OnOptionChanged` seam; retail runs it as the tail of all three verbs and it is the sole Apply/Reset enable-gate (`0x004F27D0`). | | S3 | SHOULD-FIX | `IsGrounded`'s `IsPlayerMode` conjunction fires the mid-air refusal where retail is silent; the code comment asserts the opposite. | | S4 | SHOULD-FIX | File the docked-host (`0x21000017`) register row the plan §5 delegated to this review — scoped client-wide, none exists today. | | S5 | SHOULD-FIX | TS-74 should name the five store-only camera-turning preferences in What/Where, not only in Risk. | | S6 | SHOULD-FIX | Test script: toolbar button no-longer-ghosted/highlight, `UseMouseTurning` bit surviving relogin, prefs surviving relaunch. | | N1–N8 | NOTE | Ordering, `GetValue` re-read, `Apply(0)` arg, `%f`, fallback string, `P0x12`, UA/RA whitespace, mount order. |