acdream/docs/research/2026-08-11-op9-review.md
Erik 289bf5bc6e docs: OP9 review — APPROVE-WITH-FIXES
Combined mechanism-faithfulness x regression/blast-radius pass over
371197a3 (OP9 retirement of the dead F11 settings surface +
GameplaySettings), per the OP5/OP7 single-reviewer precedent for
closeout-shaped slices.

Retirement verified correct: zero production readers of GameplaySettings
or SettingsVM existed pre-commit (checked against the pre-commit tree,
not the diff), SetUiLocked first-call/repeat-call behavior is provably
unchanged by deleting _uiLockConverged, the wire paths
(SetAcceptLootPermits 0x0005, ToggleUiLock, PlayerDescription
convergence) are untouched, the settings.json unknown-key carry-forward
is real, and the AP-196 register edit reconciles (143 -> 142 active,
29 -> 30 retired, total unchanged).

MUST-FIX 1: SaveAudio -> ApplyAudio (OP6 live-apply, live consumer in
ConfigOptionsPageController) lost its only assertion when
SettingsViewModelSavePreservesSectionAndTargetOrder was deleted.
SHOULD-FIX 2-5: stale architecture-doc seam naming SettingsVM; three
dead residues (uncallable private SaveCharacter, orphaned
IngressShutdownRoots.Settings, writerless CharacterSettings path);
test delta enumerates to -84, not the claimed -80 "exactly"; dangling
comment referencing the deleted assertion. Two nits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 13:35:55 +02:00

391 lines
21 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.

# Campaign OP slice OP9 — combined-lens review (mechanism × blast radius)
**Target:** `371197a3` — *refactor(settings): OP9 — retire the dead F11
settings surface + fully-superseded GameplaySettings*
**Reviewer:** combined mechanism-faithfulness × regression/blast-radius pass
(one reviewer, OP5/OP7 precedent for closeout-shaped slices).
**Base:** `1a57f96e` (campaign tip after the OP8 merge). Worktree clean at
review time; HEAD == the target commit.
## VERDICT: APPROVE-WITH-FIXES
The retirement itself is correct. Every claim in the commit message's
per-file disposition list that bears on **live behavior** verifies against
the post-commit source: no production code path read `GameplaySettings`, no
production code path constructed `SettingsVM`, `SetUiLocked`'s observable
contract is unchanged, the wire behavior of `SetAcceptLootPermits` /
`ToggleUiLock` / the PlayerDescription convergence is untouched, and the
settings.json unknown-key carry-forward mechanism is real. The register
edit is arithmetically and semantically correct.
Five findings, none of them a behavior regression: one lost regression
guard on a live mechanism (MUST-FIX 1), one stale **architecture** doc
seam (SF-2), three dead residues the retirement created and left (SF-3),
a test-delta claim that does not reconcile (SF-4), and a dangling
comment (SF-5). Plus two nits.
---
## MUST-FIX 1 — `SaveAudio` → `ApplyAudio` (OP6's live-apply) lost its only test
`RuntimeSettingsController.SaveAudio` pushes the saved snapshot into the
live engine — `src/AcDream.App/Settings/RuntimeSettingsController.cs:420`
(`_runtimeTargets?.ApplyAudio(audio);`), documented at `:407-413` as the
Campaign OP slice OP6 change that made Audio-tab edits take effect
without a relaunch. Its live production consumer is
`ConfigOptionsPageController`'s Sound rows (`ConfigOptionsPageController.cs:103`).
Pre-commit, exactly one test asserted that push: the now-deleted
`SettingsViewModelSavePreservesSectionAndTargetOrder`, whose ordered event
list contained `"target-audio"` with an explicit OP6 comment
(`371197a3~1:tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs:296-312`).
Post-commit, `grep -rn ApplyAudio src tests` shows **no assertion anywhere**
that `SaveAudio` invokes `IRuntimeSettingsTargets.ApplyAudio`. The fake
still records the event —
`tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs:817-820`
(`events.Add("target-audio");`) — and nothing ever reads it. The two
surviving audio tests (`ComputeEffectiveCategoryVolumes_*`, `:241-278`)
test the static volume helper, not the controller push. `ApplyStartup`'s
own audio call (`RuntimeSettingsController.cs:226`) is still covered by
`StartupApplyIsOrderedExactlyOnceAndRuntimeBindingDoesNotReplay` (`:54`),
so this gap is specific to the **save** path.
This is the class the slice contract cannot absorb: the commit asserts
"no behavior change to anything live, verified", and one live mechanism is
now unverified. Note the contrast — the *other* three target pushes were
kept covered, deliberately or not:
| Push | Post-commit coverage |
|---|---|
| `SaveDisplay``ApplyDisplayWindowState` | implicit but real — `DisplayTargetFailurePreservesEstablishedStoreThenPublishBoundary` (`:566`) sets `ThrowOnDisplay` and asserts the resulting state boundary, which only happens if the target was called |
| `ReapplyQualityPreset``ApplyQuality` | positive (`:633`, `:653`) |
| `SetUiLocked``ApplyUiLock` | positive (`:672-679`, `:694`, `:702`) |
| `SaveChat``SetChatOpacity` | positive — `SaveChat_PushesOpacityToRuntimeTargets_LiveApply_NoRestart` (`:338`) |
| **`SaveAudio``ApplyAudio`** | **none** |
**Fix (minimal):** in `RuntimeSettingsControllerTests`, add the missing
positive assertion in the same shape as the chat-opacity test — bind a
`FakeRuntimeTargets`, call `controller.SaveAudio(controller.Audio with
{ Master = 0.25f })`, assert the recorded events contain `"target-audio"`
(ideally assert the *value* forwarded, per the `:236-240` comment's own
lesson about M2's inversion).
---
## SHOULD-FIX 2 — the architecture doc still names `SettingsVM` as a live seam
`docs/architecture/code-structure.md:209-212`:
> `WorldRenderFrameBuilder -> RuntimeWorldFrameSettingsPreview ->
> IRuntimeSettingsPreviewSource -> RuntimeSettingsController -> optional
> SettingsVM` for the live settings draft preview applied before world drawing.
This sits inside a present-tense list of current presentation seams
("These are presentation state/command seams…", `:217`), not a historical
narrative. It is the one surviving reference that would send a future
reader looking for a type that no longer exists, and CLAUDE.md's
architecture rule ("when the architecture doc and reality diverge, update
one or the other") makes it in-scope for the same commit.
Secondary, lower severity, same file: `:738-739` — "The controller also
preserves unrelated unsaved Gameplay draft fields when combat preferences
change" is now false. It is inside the Slice-8 checkpoint-G/H ledger
narrative, so it reads as dated history; a one-clause "(retired at
Campaign OP slice OP9)" is enough.
Everything else the repo-wide sweep turned up is genuinely historical and
correctly left alone: `docs/plans/2026-07-22-gamewindow-slice-8-*.md`,
`memory/project_gamewindow_decomposition.md:81/85/181`,
`docs/research/2026-08-1*-op{3,4,6,8}-*.md`,
`docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md`,
`docs/research/2026-08-10-character-options-map.md`,
`docs/ISSUES.md:15859`, and the `docs/research/2026-05-21-a6-captures/*.log`
run logs.
---
## SHOULD-FIX 3 — three dead residues left behind by the retirement
The slice deleted the *callers* without sweeping what became unreachable:
1. **`RuntimeSettingsController.SaveCharacter(CharacterSettings)`** —
`src/AcDream.App/Settings/RuntimeSettingsController.cs:547-566`. Private,
**zero callers** post-commit (its only caller was the `saveCharacter`
callback handed to the deleted `CreateViewModel`). C# emits no warning
for an unused private method, so the build stays green while the method,
its `_defaultCharacter` promotion branch, and the whole write half of
the `CharacterSettings` store are unreachable:
`IRuntimeSettingsStorage.SaveCharacter` (`:31`),
`JsonRuntimeSettingsStorage.SaveCharacter` (`:68`), and
`SettingsStore.SaveCharacter` now have no production writer.
(`ActiveToonKey` is genuinely live — `InteractionRetainedUiComposition.cs:507`
and `:612` key per-character window layouts off it — so only the
`Character`/`SaveCharacter` half is dead. `RuntimeSettingsController.Character`
likewise has no production reader.)
The commit also deleted that method's only test
(`CharacterSaveUsesActiveToonAndDefaultSaveBecomesResetContext`) without
re-targeting it — consistent with the code being dead, but it means the
code is now both uncallable and untested rather than removed.
2. **`IngressShutdownRoots.Settings`** —
`src/AcDream.App/Rendering/GameWindowLifetime.cs:75`, captured at
`src/AcDream.App/Rendering/GameWindow.cs:1670`. Its **only** reader was
the `Soft("settings view model", …UnbindViewModel())` stage this commit
deleted; `grep` finds no other `ingress.Settings` use. Harmless (a
reference held until shutdown) but it is now a shutdown-roots member
that roots nothing.
Either delete these in the OP9 follow-up, or — if `CharacterSettings`
persistence is meant to come back on a later Options-panel tab — say so in
a one-line comment so the next reader does not re-derive that it is dead.
The commit message's "CharacterSettings … UNTOUCHED per the campaign
contract" covers keeping the *record and store*; it does not explain the
now-uncallable controller method.
---
## SHOULD-FIX 4 — the 80 test delta does not reconcile; static enumeration gives 84
The commit states the delta "is exactly the deleted SettingsVM/
SettingsPanel/GameplaySettings test surface". Enumerating the touched
files at both commits:
| File | Removed | Added |
|---|---:|---:|
| `tests/.../Panels/Settings/GameplaySettingsTests.cs` (deleted) | 3 | — |
| `tests/.../Panels/Settings/SettingsVMTests.cs` (deleted) | 43 | — |
| `tests/.../Panels/Settings/SettingsPanelTests.cs` (deleted) | 29 | — |
| `tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs` | 13 | 6 |
| `tests/.../Panels/Settings/SettingsStoreTests.cs` | 4 | 2 |
| `tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs` | 2 (InlineData) | 2 (InlineData) |
| `tests/.../Rendering/GameWindowSlice8BoundaryTests.cs`, `tests/.../Input/InputDispatcherCaptureTests.cs` | 0 | 0 |
| **Net** | **94** | **10** |
**84**, not 80. All three deleted files declare `public sealed class`
(discoverable), none carries `[Theory]`/`[MemberData]`/`Skip`, and the
skip count is 4 on both sides — so no discovery artifact explains the gap.
The baseline is quoted twice in the immediately preceding commits
(`f1d50207`, `1a57f96e`: 13,155 passed / 4 skipped), so either that
baseline is off by 4 or the post number is. Nothing in the diff adds four
tests elsewhere.
This is an evidence-accounting defect, not a behavior defect — **no live
behavior test is unaccounted for by it** (the one real coverage loss is
MUST-FIX 1, which is inside the 13-removed RuntimeSettingsControllerTests
bucket). Re-measure and restate, or drop the word "exactly".
For the record, the 13 removed `RuntimeSettingsControllerTests` cases
disposition cleanly: 6 were re-targeted onto the surviving API
(`CharacterContext*`, `DraftPreview*`, `RuntimeTargetLoans*`,
`SetUiLocked_*`, `SyncChatFromServerOptions*`, `UnboundRuntimeTargets*`),
5 were SettingsVM-draft-only, 1 (`UiLockPersistenceFailureCanRetry…`)
pinned a retry against a save that no longer exists — and its
target-failure sibling `UiLockTargetFailureCanRetryTheSameRequestedValue`
(`:683`) survives and still pins the real invariant — 1 covered the now-dead
`SaveCharacter` (SF-3), and 1 (`SettingsViewModelSavePreservesSectionAndTargetOrder`)
carried the `"target-audio"` assertion (MUST-FIX 1).
---
## SHOULD-FIX 5 — dangling comment referencing a deleted assertion
`tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs:236-240`:
> "The FakeRuntimeTargets-based `"target-audio"` assertion **above** (and its
> predecessor before this rework) only ever checked that ApplyAudio fired…"
There is no longer any `"target-audio"` assertion above (or anywhere); what
is now immediately above is
`ConcreteRuntimeTargetPublishesSetSingleCharacterOptionOntoTheBus`. Reword
when fixing MUST-FIX 1 — the two are the same edit.
---
## NIT 6 — "bound through … `CharacterOptionsPageController`" is imprecise for 2 of the 13
Commit §4 and the AP-196 retirement note both say all 13 members "were
ALREADY bound through `CharacterOptionTable`/`CharacterOptionsPageController`'s
server-bit seam (the OP4 Character tab)". All 13 are in the table —
verified one by one at `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs`
`:117,119,121,123,125,127,130,131,132,158,160,161,162` — but **two are
deliberately not Character-tab rows** (`CharacterOptionsPageControllerTests.cs:26`
names the exclusion set: `AppearOffline`, `UseMouseTurning`, `LockUI`, per
research §2.7):
- `LockUI` — bound through `/lockui` + the radar control:
`LiveSessionRuntimeFactory.cs:419-421` (server-bit read, `0x0005` send,
`SetUiLocked` visual push), `InteractionRetainedUiComposition.cs:492`
(mount-time seed), `:647` (radar binding).
- `UseMouseTurning` — bound through the Gameplay-tab mouse macro:
`InteractionRetainedUiComposition.cs:843-845` (`IsUseMouseTurningEnabled`
live server-bit read) and `RetailUiRuntime.cs:2009-2019` (wire send).
Note this is the *server* bit, correctly distinct from the Config tab's
own client-local `CameraTurningSettings.UseMouseTurning` (TS-74,
`ConfigOptionsPageController.cs:605-612`).
The **substance holds** — all 13 have a live server-bit home and none was
orphaned — only the sentence's channel attribution is loose. Worth
correcting in the register row, which is the durable artifact.
## NIT 7 — OP9's own bookkeeping is still outstanding
`docs/plans/2026-08-10-options-panel-campaign.md:428` still reads
`| OP9 | — | | | |`, and `:9` still says "ACTIVE — planned 2026-08-10. No
slices landed yet" (stale for the whole campaign, pre-existing). §OP9's
bullet list (`:352-370`) also owes the ISSUES sweep (#358 retest), the
CLAUDE.md Current-state paragraph, and the memory digest update. None of
that is a defect in *this* commit — it is the closeout half of the slice —
but it should not be forgotten when OP9 is declared complete.
---
## Verified clean (mechanism lens)
1. **`RuntimeSettingsController` live core** — all present and unaltered:
storage load/save for Display/Audio/Chat/Character (`:170-181`),
`ResolvedQuality`/`ReapplyQualityPreset` (`:369-383`), `ApplyStartup`
(`:213-235`), `SaveAudio`'s OP6 live-apply (`:414-427`), public
`SaveDisplay` (`:391-405`, still the `ConfigOptionsPageController` seam),
`IRuntimeSettingsTargets.SetSingleCharacterOption` (`:115`) and
`SetChatOpacity` (`:124`), `ServerOptionsSeeded` /
`NotifyServerOptionsSeeded` (`:542-545`), `SetUiLocked` (`:292-299`).
The only removals are `Gameplay`, `SetAcceptLootPermits`, the private
`SaveGameplay`, and the four `_viewModel?` notification calls.
**`SetUiLocked` first-call / repeat-call equivalence — verified.**
The old guard was `if (_lastAppliedUiLocked == locked && _uiLockConverged)
return;` with `_uiLockConverged` initialised **true** and
`_lastAppliedUiLocked` initialised **null**. On the first call
`(bool?)null == locked` is `false`, so the conjunct never mattered: the
first `ApplyUiLock` push happened then and happens now. Deleting
`_uiLockConverged` therefore cannot have changed whether the first push
fires. Repeat same-value calls returned early then (after a successful
`SaveGameplay` set `_uiLockConverged = true`) and return early now.
Failure ordering is also preserved: `_lastAppliedUiLocked` is still
assigned *after* `ApplyUiLock`, so a throwing target leaves the guard
un-advanced and the same value can be retried — pinned by
`UiLockTargetFailureCanRetryTheSameRequestedValue` (`:683-704`). The one
behavioral difference is that a *storage* failure no longer forces a
redundant re-push on the next same-value call; there is no storage call
left to fail, and `ApplyUiLock` is idempotent. All three call sites
traced and unchanged: PlayerDescription convergence
(`LiveSessionRuntimeFactory.cs:339-340`), `ToggleUiLock` (`:419-421`),
radar/Character-tab path (`InteractionRetainedUiComposition.cs:647`).
2. **`IRuntimeSettingsPreviewSource`** — the "already always false in
production" claim is **true**. Searching the *pre*-commit tree
(`git grep … 371197a3~1`) for `CreateViewModel` / `CreateViewModelBinding`
/ `new SettingsVM` / `new SettingsPanel` returns **only** test files plus
the controller's own definitions. `_viewModel` was therefore always
`null` in production, so `HasDraftPreview` was always `false` and
`DisplayPreview`/`AudioPreview` always fell through to
`Display`/`Audio` — exactly what they now return unconditionally. Both
consumers see identical values: `WorldRenderFrameBuilder.cs:370-380`
(the whole draft block was already dead in production) and
`LiveObjectFrameController.cs:59-62` (`SettingsParticleRangeSource`,
which reads `DisplayPreview` unconditionally — unchanged). Third
consumer `InteractionRetainedUiComposition.cs:685`
(`DisplayPreview.ShowFps`) likewise unchanged.
3. **`GameplayInputCommandController`** — diff confirms exactly three cases
touched. `AcdreamToggleDebugPanel` (`:190-197`) and `ToggleChatEntry`
(`:208-213`) keep `return true`, matching the prior no-op's handled
contract; `ToggleOptionsPanel` (`:214-224`) still calls
`_retained.ToggleOptionsPanel()` with only the comment reworded. No
other `InputAction` case changed, and the removed constructor parameter
is the only signature change (`SessionPlayerComposition.cs:1194`
updated). Worth recording: the return value is inert in production —
`GameplayInputActionRouter.HandleCommand` (`:170-171`) discards it and
the command controller is the **last** stage of the priority graph — so
the `true`-vs-`false` question could not have been observable either
way. The new `RetiredDevToolsCommand_IsConsumedWithoutClaimingATypedOwner`
theory (`GameplayInputCommandControllerTests.cs:43-56`) re-targets the two
deleted mapping rows rather than dropping them. No `ToggleSettingsPanel`
`InputAction` exists (`grep` over `AcDream.UI.Abstractions` is empty), so
`KeyBindings.RetailDefaults()` and the keymap fixtures needed no edit —
claim confirmed.
4. **`LiveSessionRuntimeFactory`** — `SetAcceptLootPermits` still sends
`0x0005` via `SendSingleCharacterOption` (`:494-496`); only the
`_interaction.Settings.SetAcceptLootPermits(value)` write-behind line was
removed, and it had no reader. `AcceptLootPermits` still reads the server
bit (`:491-493`). `ToggleUiLock` (`:415-422`) and the PlayerDescription
`OnCharacterOptionsChanged` convergence (`:337-345`) are byte-identical
apart from comments.
5. **settings.json migration** — mechanism verified at
`SettingsStore.SaveSection` (`src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs:643-687`):
every top-level key other than the target section and `"version"` is
carried forward by `prop.Value.GetRawText()`, so an orphaned
`"gameplay"` block survives *every* subsequent save, and no `Load*`
method reads it. The two new tests
(`SettingsStoreTests.cs:219-260`) pin exactly that, not something weaker:
the first proves a leftover block does not disturb Display/Audio/Chat
loads; the second writes an unrelated section and asserts the raw
`"gameplay"` / `"lockUI": true` / `"showHelm": false` text is still in the
file afterwards. The re-targeted coexistence and window-position/layout
tests correctly substitute a Display assertion for the deleted Gameplay
one, preserving each test's actual point.
## Verified clean (blast lens)
- **Forward references.** Repo-wide sweep (`src`, `tests`, `tools`,
`scripts`, `docs`, `.claude`) for `SettingsVM`, `SettingsPanel`,
`RuntimeSettingsViewModelBinding`, `CreateViewModel`,
`IDevToolsGameplayCommands`, `DevToolsGameplayCommands`,
`GameplaySettings`, `LoadGameplay`, `SaveGameplay`,
`SetAcceptLootPermits` (the controller method), `ToggleSettingsPanel`,
`UnbindViewModel`: the only *code* hits are OP9's own explanatory
comments, plus `ICombatGameplaySettingsSource` substring matches
(`RuntimeCharacterState.cs:850`) and an unrelated
`DebugVM CreateViewModel()` local helper in
`tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs:29`. One
vacuous-but-harmless residue: `GameWindowSlice8BoundaryTests.cs:319`
still asserts `DoesNotContain(".LoadGameplay()")` (and `:328/:330`
`_persistedGameplay`/`_settingsVm`) — negative source-text guards that
can no longer fail wrongly; leaving them is fine.
- **No string/reflection lookup** of the deleted panel: `acdream.settings`
(the old `SettingsPanel.Id`) appears nowhere in the repo.
- **`IPanel`/`IPanelRenderer` survive with real implementers** —
`ChatPanel.cs:34`, `DebugPanel.cs:22`, `VitalsPanel.cs:16`. Claim
confirmed.
- **Headless / UI Studio / plugin surfaces** (the three the implementer's
15-file list did not cover): `src/AcDream.Headless`,
`src/AcDream.Plugin.Abstractions`, `src/AcDream.Runtime`,
`src/AcDream.Core*`, `tools/`, `scripts/`, `.claude/`**zero** hits for
any deleted type. `src/AcDream.UI.ImGui/` is an empty leftover directory,
not in `AcDream.slnx`.
- **`SettingsDevToolsComposition` survives correctly.** Despite its name it
is a live composition phase (`ApplyStartup`,
`SettingsDevToolsComposition.cs:53`); only its doc comment needed the
update it got. The plan's "retire … `SettingsDevToolsComposition` wiring"
bullet referred to the ImGui wiring already removed at V11.
- **Register (§7 of the brief).** `AP-196` is struck through with a
substantive retirement note (`retail-divergence-register.md:192`), the
section-3 header moves 143 → 142 (`:178`), and the counts verify by
enumeration: active `| AP-` rows 143 → 142, retired `| ~~AP-` rows
29 → 30, total row count unchanged. No other row cites a deleted type —
the only other `GameplaySettings` hit is `TS-73` (`:367`), a substring
match inside `ICombatGameplaySettingsSource`.
- **Gate script (§9 of the brief).**
`docs/research/2026-08-11-campaign-op-test-script.md` is clean: every
"F11" and "Gameplay tab" reference is to the retail Options panel
(post-OP3), not to the deleted ImGui surface. No step exercises anything
this commit removed.
---
## Fix list (ordered, for the fixer)
1. Add the `SaveAudio``ApplyAudio` assertion to
`RuntimeSettingsControllerTests`; reword the `:236-240` comment in the
same edit (MUST-FIX 1 + SF-5).
2. Update `docs/architecture/code-structure.md:209-212` (drop the
`-> optional SettingsVM` link) and tag `:738-739` as OP9-retired (SF-2).
3. Delete `RuntimeSettingsController.SaveCharacter` (`:547-566`) and
`IngressShutdownRoots.Settings` (`GameWindowLifetime.cs:75`,
`GameWindow.cs:1670`), or document why they stay (SF-3).
4. Re-measure and restate the test delta, or drop "exactly" (SF-4).
5. Correct the LockUI/UseMouseTurning channel wording in the AP-196
retirement note (NIT 6); fill the OP9 plan-ledger row at closeout
(NIT 7).