acdream/docs/research/2026-08-11-op3-review-blast.md
Erik efe80d5a0d docs: OP3 dual-lens review findings — two APPROVE-WITH-FIXES
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 <noreply@anthropic.com>
2026-08-11 02:32:57 +02:00

443 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.