From efe80d5a0d333eaa098662e3ab269493143ea1bf Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 11 Aug 2026 02:32:57 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20OP3=20dual-lens=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20two=20APPROVE-WITH-FIXES?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converged MUST-FIX: the six mouse-turning macro lines are typed ClientLocal 0x1A where retail's AddTextToScroll sites pass type 7 (Magic, light blue — mechanism lens decoded the stray [4]=7 writes at all six BN call sites); 0x1A additionally routes to the SpewBox where the 4-slot cap discards two of the six lines before they draw (blast lens). Fix: a typed message seam, Magic for the macro, ClientLocal retained for the mid-air refusal and AD-75; byte-verify the type argument during the fix. Mechanism: the Gameplay page is NOT an OptionPage in retail (gmGameplayOptionsUI : UIElement_Field, acclient.h:55857) — the auto-flush-on-visibility acdream gave it flushes the blob at moments retail would not, and two green tests pin the wrong shape; the OnOptionChanged enable-gating seam (@0x004F27D0 — Apply/Reset gated, Defaults never) is missing from the model; the mid-air refusal fires where retail is silent (non-player-mode/null controller). Blast: the plan-assigned docked-host (0x21000017) register ruling was not filed — deemed a divergence, row owed client-wide; Options is the only shared-geometry panel with ResizeX/four-edge/min-size, which the shared _mainPanelGeometry silently reverts when siblings show; three targeted seams lack pins (real-sibling mutual exclusion, DefaultQueueKey, dormant ActivePageChanged silence); UA/RA legibility flagged for the gate. Clean: exactly one toolbar button changed (previously ghosted), F11 same-action since K.1c, no queue key invented, settings additive. Co-Authored-By: Claude Fable 5 --- docs/research/2026-08-11-op3-review-blast.md | 443 ++++++++++++++++ .../2026-08-11-op3-review-mechanism.md | 476 ++++++++++++++++++ 2 files changed, 919 insertions(+) create mode 100644 docs/research/2026-08-11-op3-review-blast.md create mode 100644 docs/research/2026-08-11-op3-review-mechanism.md diff --git a/docs/research/2026-08-11-op3-review-blast.md b/docs/research/2026-08-11-op3-review-blast.md new file mode 100644 index 00000000..d2030de1 --- /dev/null +++ b/docs/research/2026-08-11-op3-review-blast.md @@ -0,0 +1,443 @@ +# OP3 review — regression / blast-radius lens + +**Commit under review:** `9d26ecc6` — *feat(ui): Campaign OP slice OP3 — +Options panel shell, open paths, Gameplay tab* +**Reviewer lens:** regression + blast radius (what ALREADY-SHIPPED behavior +can this change move?). Pure read-only; no build, no test run, no launch. +**Date:** 2026-08-11 +**Verdict:** **APPROVE-WITH-FIXES** — 2 MUST-FIX, 4 SHOULD-FIX, 8 NOTE. + +The slice's core structural choice is sound and low-blast: it reuses the +generic catalog/`RetailPanelUiController`/toolbar mechanisms rather than +adding a parallel path, and every seam it touches was already generic. The +two MUST-FIX items are both evidence/bookkeeping gaps (an unverified chat +routing decision with a demonstrable line-loss consequence, and a register +row the plan explicitly assigned to this review), not architectural breaks. + +--- + +## Enumeration A — the `gmPanelUI` panel catalog, before and after + +`src/AcDream.App/UI/RetailPanelCatalog.cs`. `Mounted` is what +`TryGetWindowName`/`TryGetPanelId` resolve; every entry that also calls +`RetailPanelUiController.RegisterMainPanel` shares ONE parent rectangle +(`RetailPanelUiController.cs:41-65`, `:284-314`) and retail's +"one active child" exclusion (`:139-197`). + +| panelId | const | window | mount site | Left/Top | ResizeX / ResizeY | resizable edges | Min W×H | authored `RestorePrevious` (`0x10000049`) | +|---|---|---|---|---|---|---|---|---| +| 3 | `CharacterInformation` | `characterInformation` | `RegisterIndicatorDetailPanel` (`RetailUiRuntime.cs:1713`) | 18 / 18 | false / true | Bottom | — | **yes** (fixture `character_info_2100006E_10000183.json`) | +| 4 | `PositiveEffects` | `positiveEffects` | `MountEffects` (`:1519`) | right-edge / 18 | false / true | Bottom | — | **yes** (`effects_positive_2100001B.json`) | +| 5 | `NegativeEffects` | `negativeEffects` | `MountEffects` (`:1519`) | right-edge / 18 | false / true | Bottom | — | **yes** | +| 7 | `Inventory` | `inventory` | `MountInventory` (`:2307`) | root.Left / root.Top | false / true | Bottom | — | not read (3-arg overload → false) | +| 8 | `LinkStatus` | `linkStatus` | `RegisterIndicatorDetailPanel` | 18 / 18 | false / true | Bottom | — | **yes** (`link_status_2100001D.json`) | +| 9 | `MiniGame` | `miniGame` | `RegisterIndicatorDetailPanel` | 18 / 18 | false / true | Bottom | — | **yes** (`mini_game_2100001E.json`) | +| 11 | `Character` | `character` | `MountCharacter` (`:2226`) | 540 / 18 | false / true | Bottom | — | not read (3-arg overload → false) | +| 13 | `Magic` | `spellbook` | `MountSpellbook` (`:1326`) | 18 / 18 | false / true | Bottom | — | not read (3-arg overload → false) | +| 15 | `Vitae` | `vitae` | `RegisterIndicatorDetailPanel` | 18 / 18 | false / true | Bottom | — | **yes** (`vitae_21000020.json`) | +| **10** | **`Options` (NEW)** | **`options`** | **`MountOptionsPanel` (`:1984`)** | **150 / 80** | **TRUE / true** | **all four** | **300 × 200** | **absent from the fixture → false** | + +Authored extents (fixture roots): every one of these panels is **300 wide**; +heights are 600 (spellbook, character) or 362 (inventory, vitae, character +info, effects, **options**). So the shared-rectangle height coupling between +600-tall and 362-tall panels **already existed** pre-OP3; Options joins the +362 cohort and introduces no new height class. + +**Does registering Options change any existing panel's open/close/stacking +behavior?** No, with one caveat and one new asymmetry: + +- **Exclusion:** unchanged in kind. `SetPanelVisibility` hides the previous + active panel regardless of which sibling is being shown; Options is just + one more sibling. No existing pair's behavior changes. +- **Deferred-restore (`RestorePrevious`):** Options does **not** author + `0x10000049` (verified: `grep -c 268435529 + tests/.../fixtures/options_panel_2100006E_1000018D.json` → 0). Per + `RetailPanelUiController.cs:164-167` a non-`RestorePrevious` panel **clears + `_deferredPanel`**. So `Character sheet → Character Info (defers sheet) → + F11` drops the pending sheet restore. This is byte-identical to what + opening Inventory/Character/Spellbook already did in that same position + (all three are `RestorePrevious: false`), so it is not a regression — but + it is a newly-reachable route to that drop. +- **Geometry asymmetry (new):** Options is the **only** main panel with + `ResizeX = true`, all-four-edge resize, and explicit `MinWidth`/`MinHeight` + (`RetailUiRuntime.cs:1970-1980`). See SHOULD-FIX 2. + +--- + +## Enumeration B — toolbar panel-button routing, before and after + +`ToolbarController.PanelButtonIds` (`ToolbarController.cs:54-55`) has always +carried all seven ids; each binds by its OWN authored `0x10000029` value +(`:170-175`), and `BindPanelButtons` (`:374-392`) sets +`button.Enabled = isAvailable(panelId)` with `OnClick = null` when not +available. `isAvailable` is +`RetailPanelCatalog.TryGetWindowName(panelId, …) && WindowManager.TryGet(name)` +(`RetailUiRuntime.cs:679-686`). + +| element | authored panelId | in catalog pre-OP3? | pre-OP3 state | post-OP3 state | +|---|---|---|---|---| +| `0x10000197` | 12 | no | ghosted, `OnClick = null` | **unchanged** | +| `0x10000198` | 13 (Magic) | yes | enabled → spellbook | **unchanged** | +| `0x10000199` | 11 (Character) | yes | enabled → character | **unchanged** | +| `0x1000055A` | 25 | no | ghosted | **unchanged** | +| `0x1000019A` | 16 | no | ghosted | **unchanged** | +| **`0x1000019B`** | **10** | **no** | **ghosted, `Enabled=false`, `OnClick=null` — clicking did NOTHING** | **enabled → `ToggleWindow("options")`** | +| `0x100001B1` | 7 (Inventory) | yes | enabled → inventory | **unchanged** | + +**Answer to "what did `0x1000019B` do pre-OP3":** it was authored with panel +id 10, discovered by the generic binder, found panel 10 absent from the +catalog, and was therefore **ghosted with a null click handler**. Nothing +happened on click. No test pinned that old behavior (the fixture table at +`ToolbarControllerTests.cs:26-35` pins the *element→panelId* mapping only, +and `PanelButtons_bindByDatPanelId_andGhostUnavailablePanels` at `:221` +drives its own `isAvailable` predicate, not the real catalog), so nothing +pinning old behavior had to change. **Exactly one button's routing changed.** +`SyncToolbarWindowButtons` (`RetailUiRuntime.cs:670-675`) now also syncs +panel 10's Highlight/Normal state; `SetPanelOpen` (`ToolbarController.cs:394`) +early-returns per-id, so no other button's state is touched. + +Degradation path is clean: if `MountOptionsPanel` bails (`:1954`, `:1962`), +the catalog still maps `options`→10 but `_panelUi` has no id-10 entry, so +`TogglePanel(10)` returns false (`RetailPanelUiController.cs:141`) and the +toolbar button re-ghosts (`WindowManager.TryGet` fails). F11 silently no-ops +rather than falling through to `Host.ToggleWindow`. + +--- + +## Enumeration C — F11 history + +| commit / event | `KeyBindings.RetailDefaults()` | `InputAction.ToggleOptionsPanel` routed to | net effect of pressing F11 | +|---|---|---|---| +| `84512d3c` (Phase K.1a) | file created; retail preset not yet built | — | — | +| **`da189103`** (Phase K.1c, retail keymap cutover) | **adds `KeyChord(F11, None) → ToggleOptionsPanel`** (now `KeyBindings.cs:216`), sourced from `retail-default.keymap.txt:148` `ToggleOptionsPanel [ "" [ 0 DIK_F11 ] ]` | `_devTools.ToggleSettingsPanel()` | opened the ImGui-era Settings panel | +| Campaign V slice V11 | unchanged | `DevToolsGameplayCommands.ToggleSettingsPanel()` becomes an **empty method** (`GameplayInputCommandController.cs:70-72`, class doc `:53-59`) | **nothing** | +| **`9d26ecc6` (OP3)** | **unchanged — the row already existed** | `_retained.ToggleOptionsPanel()` → `RetailUiRuntime.ToggleWindow("options")` → `_panelUi.TogglePanel(10)` | opens/closes the retail Options panel | + +**Verification of the implementer's claim** (`git log -L 216,216:…/KeyBindings.cs`): +the binding was introduced by `da189103`, not by OP3. OP3 touches +`KeyBindings.cs` **not at all** — only the new pin in +`KeyBindingsRetailTests.cs:239`. The claim is accurate. + +**What stopped working:** nothing. Between V11 and OP3, F11 was a hard no-op. + +**Keybind conflicts:** `grep -n "F11" KeyBindings.cs` returns exactly one row. +No chord conflict introduced. `InputScope` puts F11 at the bottom of the +stack (`InputScope.cs:19`) — unchanged. + +**Dead `ToggleSettingsPanel` wiring:** untouched, as claimed — but now +**orphaned**. `GameplayInputCommandController` was its only production caller; +post-OP3 the only remaining references are the interface declaration +(`:50`), the empty implementation (`:70`), a comment (`:242`), and the test +fake (`GameplayInputCommandControllerTests.cs:141`). The commit comment says +"its no-op wiring elsewhere is untouched" — there is no *elsewhere*. Planned +for retirement in OP9; see NOTE 8. + +--- + +## Findings + +### MUST-FIX 1 — the six mouse-turning chat lines are routed to a 4-slot transient overlay on an **unverified** retail log type, and two of them can never be seen + +`MouseTurningSettingsMacro.Compute` can emit **six** lines +(`MouseTurningSettingsMacro.cs:58-104`). `RetailUiRuntime.ApplyMouseTurningSettingsMacro` +(`RetailUiRuntime.cs:1907-1908`) pushes every one of them through +`_bindings.Options.DisplaySystemMessage`, which is bound to +`d.Communication.AddText(text, RetailLogTextType.ClientLocal)` +(`InteractionRetainedUiComposition.cs:816-817`). + +`RuntimeCommunicationState.AddText` (`RuntimeCommunicationState.cs:197-201`) +sends `ClientLocal (0x1A)` to `SpewBox.Enqueue` and **returns** — it never +reaches the chat transcript. `SpewBoxState.MaxConcurrentItems = 4` +(`SpewBoxState.cs:77`), and `Tick` drains the whole pending queue in one +call, dropping the oldest on overflow (`:132-149`). Six lines enqueued in one +click therefore leave **four visible**: the first two — *Camera Stiffness* +and *Camera Adjustment* — are discarded before they are ever drawn. They also +expire after 5 s (`:88`). + +The routing itself is **not byte-verified**. The commit and register call the +*format strings* byte-verified (VAs `0x007A8A70` … `0x007A88D0`), and the +research doc's §4.4 table lists exactly those VAs — but neither the doc nor +the code records the `AddTextToScroll` **type argument** at +`0x0049E8F0..0x0049EB81`. This project has already been burned on precisely +this inference: `ClientTextRefusals.cs:117` documents a site where "the actual +site passes type `0` (Default), not `0x1A`". If retail passes `0` here, all +six lines belong in the scrolling chat window, all six are visible, and OP3's +routing is wrong in a way the user will see. + +Fix: either byte-verify the type at the call sites and route accordingly, or — +if the sweep is out of scope this slice — file a register row recording the +assumed `ClientLocal` routing plus the 4-slot truncation, per CLAUDE.md's +"any commit that introduces a deviation adds its register row IN THE SAME +COMMIT". Right now there is neither evidence nor a row. (AD-75 covers the +UA/RA `MessageBoxA` → interface-text substitution; it says nothing about the +mouse-turning macro's six lines.) + +### MUST-FIX 2 — the plan assigned this review a register ruling that OP3 did not file + +`docs/plans/2026-08-10-options-panel-campaign.md:379-382`: + +> 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). + +I deem it a divergence: retail exposes both a floating and a docked +`gmPanelUI` host and acdream mounts only `0x2100006E`, so no gmPanelUI child +— Options included — can be docked. The commit files AD-74/75/76 and TS-74 +but no row for this. It is inherited rather than introduced by OP3 (every +pre-existing panel already mounts floating-only), which is exactly why it has +gone unrowed until now, and why the plan routed the ruling here. One AD row, +cited at `RetailUiRuntime.MountOptionsPanel` / the shared host id, closes it. + +### SHOULD-FIX 1 — `IsGrounded` returns false outside player mode, so "Exit to Character Selection" is unusable in fly mode and emits a mid-air message that is not true; the code comment asserts the opposite + +`InteractionRetainedUiComposition.cs:322-324`: + +```csharp +IsGrounded: () => d.PlayerMode.IsPlayerMode + && d.PlayerController.Controller is { IsAirborne: false }, +``` + +with the comment (`:320-322`) claiming *"'on the ground' is meaningless +outside player mode, so the mid-air refusal never fires while flying/ +spectating."* The code does the reverse: outside player mode `IsGrounded()` +is **false**, so `RequestExitToCharacterSelection`'s `else` branch +(`RetailUiRuntime.cs:1871-1873`) fires `CantLogOffMidAir`. In fly/chase-camera +mode the button confirms, refuses, and shows a message that is factually +wrong. The underlying expression is the project's established `playerOnGround` +shape (same three lines at `:322-324` feed the item-drop gate), so the shape +is fine — the comment is inverted and the not-in-player-mode case is +unconsidered. Impact is contained (Exit Game right beside it still works, and +retail has no fly mode) but the comment must not survive as-is. + +### SHOULD-FIX 2 — Options is the only shared-geometry main panel that resizes horizontally and on all four edges; its width is silently discarded whenever a sibling is shown + +`RetailUiRuntime.cs:1970-1980` gives Options `ResizeX = true`, +`ResizableEdges = Left|Right|Top|Bottom`, `MinWidth = 300f`, +`MinHeight = 200f`. Every one of its nine shared-rectangle siblings is +`ResizeX = false`, `ResizableEdges = Bottom`, with no explicit constraints +(Enumeration A). Consequences, all through +`RetailPanelUiController`'s one `_mainPanelGeometry`: + +- A user widens Options to 450. `OnWindowResized` → `CaptureAndSynchronize…` + (`:224-247`) writes 450 into the shared rect; siblings ignore it + (`RetailWindowManager.ResizeTo:185` forces `width = frame.Width` when + `!ResizeX`). Open the character sheet, then reopen Options: + `PrepareMainPanelGeometry` → `ApplyWindowGeometry` (`:265-282`, `:306-314`) + resizes Options back to the sibling's 300. The width is lost. This + contradicts the OP3 gate script's own item 5 ("remembers its geometry + across a close/reopen"). +- The 300/200 minimums are invented, not authored — `DatConstraintSource` is + not passed, so `ResolveConstraint` (`RetailWindowFrame.cs:160-163`) uses + the literals. Because all ten panels are authored 300 wide, `MinWidth=300` + currently pins Options to exactly its authored width and no clamp feedback + loop can start; that is luck, not design. +- The four-edge resize also means Options is the only panel whose *top/left* + drag moves the shared rect origin for every sibling. + +Either match the sibling policy (`ResizeX = false`, bottom-only, drop the +literals) or make the whole shared group consistent — but not one panel with +its own rules inside a single-rectangle group. Needs the connected visual +gate either way; I cannot run the client. + +### SHOULD-FIX 3 — no test pins the two seams the review was asked about: mutual exclusion against a *real* existing panel, and the new dialog's queue behavior + +The new suites are thorough on the model (`OptionPageModelTests`, 20 facts) +and the controller's own content (`OptionsPanelControllerTests`, 15 facts), +and they do pin the F11 route on both sides +(`GameplayInputCommandControllerTests.cs:21` + `KeyBindingsRetailTests.cs:239`). +What is missing: + +- **Panel mutual exclusion with an existing panel.** `RetailPanelCatalogTests` + asserts catalog membership only. No test registers, say, panel 11 + (character) and panel 10 (options) in a `RetailPanelUiController` and + asserts that showing Options hides the character sheet — the exact + interaction the slice's headline claim ("shares retail's mutual exclusion + for free") rests on. The mechanism is exercised by + `RetailPanelUiControllerTests` with synthetic ids, so the risk is low, but + the claim is unpinned. +- **The confirmation dialog's queue key.** `RequestExitToCharacterSelection` + goes through `ShowConfirmation` → `MakeConfirmation` with the default + `queueKey = 2` (`RetailDialogFactory.cs:125-136`, `:10`), the same key every + existing confirmation uses — see NOTE 3. No test pins that the new dialog + shares the existing key rather than inventing one. +- No test asserts `ActivePageChanged` stays silent on a **dormant** Type-8 + host (AD-73's contract). See NOTE 2. + +Fixture/conformance suites are unaffected: `RetailLayoutFixtureGenerator` +gains one row (`+2` lines), `FixtureLoader` two new loaders, and no existing +fixture was regenerated (`git show --stat` shows only the new +`options_panel_2100006E_1000018D.json`). Existing catalog assertions in +`AppraisalUiControllerTests.cs:683-691` and +`EffectsUiControllerTests.cs:256-258` are negative assertions about +Examination/Effects and remain true. No count-based assertion over +`MountedPanels`/`ToolbarPanels` exists to go stale. + +### SHOULD-FIX 4 — the Urgent Assistance / Report Abuse bodies are three-line, ~110-character-URL messages delivered into a 4-slot, 5-second, centered transient overlay + +`OptionsPanelText.UrgentAssistanceUnavailable` / `ReportAbuseUnavailable` +(`OptionsPanelText.cs:106-118`) embed two `\n`s and the full +`support.turbine.com` URL, and go out through the same `ClientLocal` seam +(`OptionsPanelController.cs:171-174`). `AddText` only trims the ends +(`RuntimeCommunicationState.cs:195`), so the whole three-line body lands as a +**single** `SpewBoxEntry` that expires in 5 s. The *divergence* is registered +(AD-75), but its legibility is not: the user has 5 seconds to transcribe a +110-character URL from a transient overlay. Worth a look at the connected +gate; if it is unreadable, the honest fix is the chat transcript (which +scrolls back), not a longer timeout. + +--- + +### NOTE 1 — the mount-time `AfterApply` publish is harmless, but it does fire before any session exists + +`MountOptionsPanel` calls `controller.ActivateTabs()` (`RetailUiRuntime.cs:1958`) +during `Initialize()`, which fires `ActivePageChanged(0, Gameplay)` → +`OptionPage.OnShown()` → `Apply()` → `AfterApply` → +`_bindings.Options.CommandBus().Publish(new SaveCharacterOptionsRuntimeCmd())` +(`:1945-1946`). `DeferredLiveSessionUiAuthority.Commands` returns +`NullCommandBus.Instance` when unbound (`InteractionUiRuntimeSources.cs:222-231`), +so there is **no NRE and no wire traffic** at mount. Post-connect, the same +publish fires on every window show and every tab switch; the router's +`SaveCharacterOptionsIfDirty` is dirty-gated (`LiveSessionRuntimeFactory.cs:359-372`), +so it is a no-op unless the batched module is actually dirty. Retail-cited +(`PlayerOptionPage::SaveCurrentValues @0x004F2710`'s `SaveToServer(0)` tail). +No action needed — recorded because "opening a UI panel sends a character- +options save" is the kind of thing a future reader will flag. + +### NOTE 2 — `ActivePageChanged` cannot fire during dormant operation, but only because nobody calls `SwitchTo` + +The event is raised from exactly one place: the tail of `SwitchTo` +(`UiTabPanel.cs:212-213`), after `ActivePageElementId` is updated (so handlers +read the new value — which `OptionsPanelController.OnHidden` at `:217` relies +on). `SwitchTo`'s only callers are `ActivateTabBehavior`'s default switch +(`:186`) and the click handlers *that method registers* (`:174`). Dormant +Type-8 hosts (vendor `0x100000B8`, character-sheet root `0x10000227`, +spellbook root `0x100002A8`, combat `0x100000A2` — AD-73) never run either, +so the event never fires for them. **But** `SwitchTo` is public and its own +doc (`:193-195`) invites direct calls — a future controller calling +`SwitchTo` on a dormant host WOULD raise it. Since the only subscriber is +`OptionsPanelController` on its own instance (`:131`), and it unsubscribes in +`Dispose` (`:233`), there is no existing consumer to affect today. Existing +`UiTabPanel` consumers: none subscribe; the added field is 8 bytes/instance. + +### NOTE 3 — the new dialog respects the `OpenNextDialog` duplicate-key hazard because it does not introduce a key + +`RequestExitToCharacterSelection` reuses `ShowConfirmation` +(`RetailUiRuntime.cs:534-541`) → `MakeConfirmation` with the default +`queueKey = DefaultQueueKey = 2` (`RetailDialogFactory.cs:10`, `:128`). Every +confirmation in the codebase uses that same key (only one `QueueKey` constant +exists outside the factory: `RetailDialogData.cs:19`, the property id). The +chat digest's DO-NOT-RETRY hazard is the `Dictionary.Add` duplicate at +`OpenNextDialog`; the existing guard at `RetailDialogFactory.cs:332-333` +(`if (_activeQueued.ContainsKey(queueKey)) return;`) already covers the +re-entrant case, and OP3 adds no new key for it to miss. Correct by +construction. Behavioral consequence: clicking Exit-to-char-select while a +server-driven confirmation is up queues behind it, same as every other +client-side confirmation. + +### NOTE 4 — the Exit-Game / Escape / indicator-bar exit paths are genuinely shared and genuinely unchanged + +`ExitGame: _bindings.Indicators.EndCharacterSession` (`RetailUiRuntime.cs:1942`) +resolves to `d.Window.Close` (`InteractionRetainedUiComposition.cs:688`) — the +same delegate behind (a) the indicator bar's end-session button, which wraps +it in `RequestEndCharacterSession`'s confirmation (`:1844-1851`), and (b) the +Escape key's terminal branch, via a *separate* `GameplayWindowCommands(d.Window.Close)` +instance (`SessionPlayerComposition.cs:1200` → `GameplayInputCommandController.cs:269`). +OP3 adds a third caller and modifies neither existing one. The refactor that +extracted `ResolveEndCharacterSessionConfirmMessage` (`:1832-1841`) is +behavior-preserving for `RequestEndCharacterSession` — same DAT id +`0x23000001`, same hash, same fallback string, same `DatLock` scope. +Retail-faithfulness of "Exit Game has no confirmation" is confirmed by the +research doc's `RecvNotice_EndCharacterSession(0)` branch +(`2026-08-10-keyboard-config-and-gameplay-tab.md:151-154`): param 0 sets +`m_shouldQuitOnLogout = 1` and skips both the dialog and the mid-air check — +which is also why OP3 correctly applies the mid-air refusal to +Exit-to-char-select only. + +### NOTE 5 — settings-file schema and versioning are safe; the concurrent-write hazard is pre-existing and unchanged in kind + +`SaveCameraTurning` goes through the existing `SaveSection` +(`SettingsStore.cs:250-252`, `:658-702`), which preserves every non-target +top-level key as raw text (`:663-683`) and re-stamps `version: 2` +unchanged (`CurrentSchemaVersion` at `:37` is untouched). `cameraTurning` is +a new top-level key, so: older acdream builds ignore it on read and preserve +it on write; newer builds fall back to `CameraTurningSettings.Default` +per-field when the section or a key is missing (`:224-241`). **No schema break +for existing `settings.json` readers.** + +Concurrency: `SaveSection` is a non-atomic read-modify-`File.WriteAllText`, +and `SaveWindowLayout` is a *separate* `LoadMutableRoot`/`WriteMutableRoot` +path over the same file (`:435-462`). Interleaving either pair can clobber. +That hazard predates OP3; OP3 adds one more writer to the `SaveSection` side, +driven from a UI click handler on the same thread as the existing settings +saves. No new thread is introduced. Recorded, not charged to this slice. + +### NOTE 6 — `LoadCameraTurning`/`SaveCameraTurning` correctly bypass the cached-snapshot pattern, and the interface widening is fully covered + +`RuntimeSettingsController` gains two pass-throughs +(`RuntimeSettingsController.cs:444-467`) that read/write storage directly +rather than through the `RuntimeSettingsSnapshot` cache the +Display/Gameplay/Chat sections use. The implementer flagged this as +deliberate; it is correct here — a cached, change-notified copy exists to +drive live UI and there is no Camera/Input UI surface until OP6. The widened +`IRuntimeSettingsStorage` has exactly two implementers +(`JsonRuntimeSettingsStorage` at `:42`, the test fake at +`RuntimeSettingsControllerTests.cs:1280`), both updated. Save failures are +logged not thrown, matching every other `Set*` in the class. + +### NOTE 7 — the five persisted camera preferences are a second, disconnected store for a value acdream already owns + +`cameraTurning.mouseLookSensitivity` is persisted but never read by the +camera; the live sensitivity acdream actually uses lives in +`CameraPointerInputController` (`_chase.Sensitivity`, `_flySensitivity`, +`_orbitSensitivity` at `:110-111`, `:174-182`) and is adjusted by the +`AcdreamSensitivityUp/Down` actions. TS-74 mentions "(unwired) preferences" +in passing, but only in the context of the `UseMouseTurning` bit; the +five-preference store-without-consumer is not itself rowed. Low risk today +(nothing reads it), but OP6 will surface these in the Config tab, at which +point two stores for one concept becomes user-visible. Worth a sentence in +TS-74 rather than a new row. + +Related: `SetSingleCharacterOptionRuntimeCmd` publishes directly on the bus +(`RetailUiRuntime.cs:1900-1903`), matching the established CH3 pattern at +`RuntimeSettingsTargets.cs:321-322` including its documented silent-drop- +while-disconnected behavior. The macro's own wrinkle: the six chat lines are +emitted unconditionally, so a drop while disconnected still prints "Turn to +Face Camera was changed from FALSE … to TRUE" when neither the wire send nor +the local bit write happened (`SendIfActive` skips the whole lambda, so +`TrySetOption` never runs). The Options panel is only reachable in-world, so +the window is narrow. + +### NOTE 8 — `IDevToolsGameplayCommands.ToggleSettingsPanel` is now fully orphaned + +Zero production callers remain (Enumeration C). The commit comment at +`GameplayInputCommandController.cs:242-244` describes it as having "no-op +wiring elsewhere"; there is none. Slated for OP9 per the same comment — +worth confirming it actually appears on OP9's list rather than relying on the +comment. + +Minor inconsistency in the same area: `OptionsPanelController.Bind` logs a +loud warning when any of the seven Gameplay buttons fails to resolve +(`BindButton`, `:188-197`) but the close button (`:160-161`) fails silently +if `0x10000210` ever stops building as a `UiButton` — a panel you cannot +close by its X, with no log. One line to make it symmetric. + +--- + +## Verdict + +**APPROVE-WITH-FIXES.** + +The structural bet — register through `RetailPanelCatalog` + +`RetailPanelUiController` + the existing generic toolbar binder, rather than +building an Options-specific path — is the right one and is why the blast +radius on already-shipped panels is as small as it is. Both axes walk clean +on the mechanisms: exactly one toolbar button changed state, no other panel's +exclusion/stacking/deferred-restore behavior moved, the dialog queue key is +reused rather than invented, the settings schema is additive and +version-neutral, F11's binding predates the slice and its old target was a +proven no-op, and the new `UiTabPanel` event cannot fire on a dormant host. +The two MUST-FIX items are an unverified chat-routing decision with a +demonstrable line-loss consequence, and a register row the campaign plan +explicitly assigned to this review. Neither blocks the mechanism; both must +land before the connected gate is meaningful, since the first one changes +what the gate is supposed to observe. diff --git a/docs/research/2026-08-11-op3-review-mechanism.md b/docs/research/2026-08-11-op3-review-mechanism.md new file mode 100644 index 00000000..0f1d43d4 --- /dev/null +++ b/docs/research/2026-08-11-op3-review-mechanism.md @@ -0,0 +1,476 @@ +# 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. |