acdream/docs/research/2026-08-11-op4-rereview.md
Erik 4798302226 docs: OP4 re-review — REOPEN (narrow): timestamp seam regressed, 9/10 closed
MF-1/M1 CLOSED with the blast scenario walked (Reset now restores live
values; UiLocked converges on every seed). MF-2 CLOSED with a
load-bearing fixture check (all nine verb buttons author Ghosted and
lack DAT 0x0B — TrySetRetailState would otherwise short-circuit). M2
store retirement verified to zero remaining references; M3 walked; S2's
.spv hygiene verified by recomputing the compiler hash scheme.

R1 MUST-FIX: the timestamp now prefixes ChatEntry.Text (the BODY), so
six of ten chat kinds render 'Alice says, "13:05:09 hi"' — retail
composes the line FIRST and prepends the timestamp as a separate string
at display time (AddTextToScroll @0x00563c50 receives composed lines;
fprintf("%ls%ls" ts, text) @0x00563e5b). Fix at ChatVM's display
composition; the two new tests pin the defect and must be rewritten.
R2: the seed-event re-read for OPEN panels (Combat vs Character can
disagree until re-shown; a panel open across reconnect reopens the
wrong-direction Reset for that window) — wire the existing
OnCharacterOptionsChanged hook like LockUI or register it. R3: no test
drives the refresh widget push. R4: headless AutoRepeatAttack default
flip false->true unnamed in AP-196.

Coordinator fixes directly (second round) once OP5 frees the tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 05:48:07 +02:00

425 lines
24 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 OP4 — fix-round RE-REVIEW
**Commit under review:** `bc43fb1d` "fix(ui,runtime): OP4 review fixes — live
re-seed, enable-gating, Combat panel re-point, universal timestamps"
**Claim under test:** closure of every consolidated finding from
`docs/research/2026-08-11-op4-review-mechanism.md` (MF-1, MF-2, SF-1, SF-2,
SF-3, N-1..N-5) and `docs/research/2026-08-11-op4-review-blast.md`
(M1, M2, M3, S1S6, N1N8).
**Mode:** read-only. No build, no test run, no client launch. The only file
written is this one.
---
## Verdict
**REOPEN — narrow.** Nine of the ten consolidated findings are genuinely
closed, several of them better than the reviews asked for. **One fix
(S1/SHOULD-FIX, universal chat timestamps) introduced a NEW defect that is
worse than the gap it closed**: the prefix is applied to
`ChatEntry.Text` — the raw message body — instead of to the composed display
line, so every sender-formatted chat kind now renders the timestamp *inside
the quoted speech*:
```
Alice says, "13:05:09 hello" ← acdream after bc43fb1d
13:05:09 Alice says, "hello" ← retail
```
Six of the ten `ChatKind`s are affected (LocalSpeech, RangedSpeech, Channel,
Tell, Emote, SoulEmote); the four that happen to be correct (System, Combat,
Popup, and the AddText callers) are exactly the ones that were already
covered before this commit. **The new tests pin the wrong invariant**, so the
suite is green on a broken line format. Gate-script step 13 will fail on the
"inbound tell/say" half it was written for.
This is one localized fix (move the prefix to the composed-line seam) plus a
test rewrite. Nothing else in the round needs to change.
| Class | Count | Items |
|---|---|---|
| MUST-FIX | 1 | R1 — timestamp prefixes the message body, not the composed line |
| SHOULD-FIX | 3 | R2 — N-5 (server-echo refresh) neither wired nor registered, and this commit widened its blast; R3 — the `refresh` widget push has no test; R4 — headless `AutoRepeatAttack` default flips `false → true`, unnamed in AP-196 |
| NOTE | 3 | R5 — `refresh`-from-`SaveCurrentValue` is an adaptation, not retail's shape (comment reads otherwise); R6 — enable-gate tests cover the Character page only; R7 — `HeadlessGameplayOperations` combat options can now throw where they were constants |
---
## Per-finding closure check
### MF-1 / blast M1 — stale pre-login row seed — **CLOSED**
Traced end to end.
**The re-seed mechanism.** `BoolOptionRow` gained `read`/`refresh` delegates
(`src/AcDream.App/UI/Layout/OptionPageModel.cs:86-99`) and
`SaveCurrentValue` is now
(`src/AcDream.App/UI/Layout/OptionPageModel.cs:148-156`):
```csharp
if (_read is not null) { _current = _read(); _refresh?.Invoke(_current); }
_saved = _current;
```
Retail's `UIOption_Checkbox::SaveCurrentValue @0x004868e0` is verbatim
`m_current = vtable->GetValue(); m_saved = m_current;` (pseudo-C `:146936`),
and `UIOption_Checkbox::GetValue @0x00486f60` (`:147401`) resolves to
`PlayerModule::GetOption(m_playerOption)` at `00486ff8` — the live
server-synced word, exactly as the mechanism review claimed. The wiring
supplies the live reader and a widget push that deliberately bypasses `apply`
(`CharacterOptionsPageController.cs:383-385`: `read: () =>
bindings.CurrentValue(spec.Id)`, `refresh: value => checkbox.Selected =
value`), so a re-seed cannot echo back onto the wire. `UiButton.Selected`'s
setter only calls `UpdateVisualState()` (`UiButton.cs:162-171`) — no `Click`,
no `OnClick` — so the push is inert on the wire by construction, not just by
convention.
**The show path.** `RetailWindowFrame.Mount(... Visible = false ...)`
(`RetailUiRuntime.cs:2064`) means no `OnShown` fires at composition, so the
pre-login mount no longer matters. Opening the panel sets the frame visible →
`UiRoot.ElementVisibilityChanged``RetailWindowManager.OnElementVisibility
Changed``handle.NotifyVisibility(true)` (`RetailWindowManager.cs:276`) →
`Controller.OnShown()` (`RetailWindowHandle.cs:101`) →
`OptionsPanelController.OnShown()` (`:307-311`) → active `OptionPage.OnShown()`
`Apply()``SaveCurrentValue()` on all 50 rows. Tab-switch-in takes the
same path through `OnActivePageChanged` (`:283-295`). This matches retail's
`PlayerOptionPage::OnVisibilityChanged(true) → SaveCurrentValues` (pseudo-C
`:245374`, `:245757`).
**The exact blast-M1 scenario now behaves correctly.** Server ON, client
constructor default OFF, panel shown, row clicked (now a real toggle to OFF,
not a silent no-op), tab switched away → `OnHidden → Reset` → the row is
`Changed``RestoreSavedValue()` restores `_saved`, which the show re-seeded
to **true**, and `apply(true)` re-asserts the server's own value. The
wrong-direction mutation is gone. Pinned by
`Reset_AfterReseed_RestoresTheLiveValue_NotTheStaleConstructionDefault`
(`tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs`),
plus a 50-row convergence test and a "no `SetOption` during re-read" assertion.
**The read closure stays valid across sessions.** `CurrentCharacterOption` is
`id => d.Character.Options.GetOptionBit(id)`
(`InteractionRetainedUiComposition.cs:860`); `RuntimeCharacterState.Options` is
a get-only property assigned once (`RuntimeCharacterState.cs:105,118`), and
reconnect calls `Options.ResetSession` on that same instance (`:442`) rather
than replacing it — so the retained UI's captured delegate never goes stale.
**LockUI convergence.** `OnCharacterOptionsChanged` now also pushes the live
bit through `SetUiLocked` (`LiveSessionRuntimeFactory.cs:336-341`). It fires
from `LiveSessionEventRouter.cs:210-226` on **every complete
PlayerDescription** — login and reconnect — and deliberately not on a
trailer-truncated parse (OP7 MF-2, correct). Threading is safe: the router runs
under `WorldSession.Tick()`, the per-frame pump (`WorldSession.cs:1117-1123`),
i.e. the same thread the retained UI runs on, so the new
`UiRoot.UiLocked` write and its `UiLockChanged` fan-out
(`RetailWindowManager.cs:316-320`, which enumerates the window dictionary) are
not a cross-thread hazard.
### MF-2 — Apply/Reset enable-gating — **CLOSED**
`OptionsPanelController.Bind` now captures both buttons and installs the gate
per page (`:220-243`), running it once at bind time for retail's `PostInit →
OnOptionChanged(0)` tail; Defaults is wired but never gated. Verified against
the committed fixture that this actually works on all three pages: each of the
nine `0x100001FC/FD/FE` instances authors a **`Ghosted` state** and **does not**
carry DAT property `0x0B` (`ToggleBehavior`). That second fact is load-bearing
and easy to miss — `UiButton.TrySetRetailState` short-circuits `Normal`/
`Highlight` into a `Selected` flip for toggle buttons *without re-enabling*
(`UiButton.cs:212-216`), which would have left Apply/Reset permanently ghosted
after the first Apply. They are push buttons, so the `Ghosted → Enabled=false`
/ `!Enabled → Enabled=true` path (`:217-223`) is the one taken.
`UiElement.Pick` skips `!Enabled` subtrees (`UiElement.cs:586`) and
`UiButton.OnEvent`'s `Click` case returns early when `!Enabled` (`:494`), so a
ghosted button is genuinely unclickable. Five tests pin start-disabled,
click-enables, Apply-disables, Defaults-leaves-enabled, Defaults-never-gated.
### M2 — two writable copies (Combat panel) — **CLOSED**
The retirement is complete, not gestured. `grep` across `src/` and `tests/`
finds **zero** readers or writers of `GameplaySettings.{AutoTarget,
AutoRepeatAttack, ViewCombatTarget}`, `RuntimeSettingsController.{AutoTarget,
AutoRepeatAttack, ViewCombatTarget, SetCombatGameplay}`, or
`GameplaySettingsState` — all deleted, including the settings.json read/write
keys (`SettingsStore.cs`) and the dead ImGui `SettingsPanel` checkboxes. The
`ICombatGameplaySettingsSource` interface survives with
`CharacterOptionCombatSettingsSource` as its only implementation.
Convergence trace: the Combat LED click →
`CombatUiController.Bindings.SetOption` (`CombatUiController.cs:127-135`) →
`SetSingleCharacterOptionRuntimeCmd` (`RetailUiRuntime.cs:1203-1206`) →
`LiveSessionCommandRouter``SendSingleCharacterOption`
`RuntimeCharacterOptionsState.TrySetOption` (`RuntimeCharacterState.cs:806`).
The Character tab's row publishes the *identical* command. Reads are
`GetOptionBit` on both sides (`CombatUiController.SyncControls` `:209-211`;
`BoolOptionRow._read`). One store, one writer path — the divergence is closed.
They converge on the next refresh of whichever surface was not clicked
(`SyncControls` runs on `OnShown`/combat-mode/attack-state changes), which the
gate script's new step 20 states honestly ("toggling one and
reopening/refreshing the other"). See **R2** for the residual.
`HeadlessGameplayOperations` now reads the live bits
(`src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs:135-138`),
closing blast S5. AP-196 (`docs/architecture/retail-divergence-register.md:189`)
records the default-source change, the `ViewCombatTarget true→false` flip, the
three-field deletion, and the "other five stay as write-behind mirrors"
distinction truthfully; the AP header count moved 136 → 138 (`:178`).
### M3 — `SetUiLocked` convergence guard — **CLOSED**
`_lastAppliedUiLocked` (`RuntimeSettingsController.cs:188`) is a `bool?`
starting `null`, compared instead of `Gameplay.LockUI` (`:361`) and written
only after the runtime target actually accepted the push (`:366-367`), with the
same bookkeeping added to `SaveGameplay`'s own push (`:525-526`).
Walking the blast-M3 scenario — persisted `"lockUI": true`, fresh login, one
toggle press: login's `OnCharacterOptionsChanged` calls `SetUiLocked(false)`
(server bit clear); `null != false` → applies, `_lastAppliedUiLocked = false`.
The toggle then computes `locked = !GetOptionBit(LockUI) = true`
(`LiveSessionRuntimeFactory.cs:413-418`); `false != true`**`ApplyUiLock`
runs**. The old guard would have early-returned on the first press. Pinned by
`SetUiLocked_AppliesEvenWhenGameplayLockUIAlreadyMatches_IfNeverActuallyApplied`,
which also asserts the second identical call correctly no-ops.
### S1 — DisplayTimeStamps coverage — **NOT CLOSED (regressed): see R1**
Coverage *is* fixed — `ChatLog.Append` is a true chokepoint (one
`_buffer.Enqueue`, ten producers, all funnelling through it), and the three
bypass paths spot-checked (heard speech via `OnLocalSpeech`, Turbine channels
via `OnChannelBroadcast`, combat text via `OnCombatLine`) now all get a prefix.
The SpewBox stays exempt for free (`RuntimeCommunicationState.AddText:228-232`
returns before touching `Chat`), and moving the prefix behind
`OnSystemMessage`'s dedupe comparison actually *retires* blast N5's dedupe
concern. But the placement is wrong — full detail in **R1**.
### S2 — sky fog gate — **CLOSED**
`sky.frag:68-69` now reads `int fogMode = int(uFogParams.w); if (uApplyFog >
0.5 && fogMode != 0)`, matching `mesh_modern.frag`/`terrain_modern.frag`'s
`mode == 0` convention; `uFogParams` is already in the frag shader's UBO
(`sky.frag:50`).
**.spv hygiene claim verified.** `git show --stat` for
`src/AcDream.App/Rendering/` lists exactly `sky.frag`, `sky.frag.spv`, one
manifest line, and `GameWindow.cs`'s comment edit — no collateral shader
churn. And the manifest hash is genuinely correct: recomputing the compiler's
own scheme (`tools/ShaderCompiler/Program.cs:214-220` — UTF-8 of the source
with `\r\n → \n`) over `sky.frag` yields
`2ddf210d69b0c4a3c0870eecfb0ccba2097d93b365729739402bfe91e3b120d0`, exactly
the new manifest value (cross-checked against `mesh_modern.frag`'s untouched
entry to confirm the scheme).
### S4 / SF-1 — culture-invariant format, doc downgrade, AP-197 — **CLOSED**
`FormatTimestampPrefix` (`ChatLog.cs:468-469`) is
`DateTime.Now.ToString(@"H\:mm\:ss ", CultureInfo.InvariantCulture)` — colons
escaped so they are literals rather than `DateTimeFormatInfo.TimeSeparator`,
matching the CRT `wcsftime(u"%#H:%M:%S ")` retail actually calls
(pseudo-C `00563e24`). A `fi-FI` test exists at both layers. The
"byte-verified" overclaim is downgraded to "BN-sourced … NOT byte-verified —
wire doc U6" in both doc comments, and AP-197
(`retail-divergence-register.md:190`) records the ignored per-character
`GenericQualitiesData` key-1 format override with an accurate risk column.
N-4's AP-194 anchor-column typo is fixed (`:186`, `UIOption_Checkbox::
SetPlayerOption @0x00486e80`, with the correction annotated).
N-7's `RunAsDefaultMovement` prose now cites `ToggleRun_PlayerOption` at all
three sites.
### SF-2 / SF-3 / S6 — tests and gate script — **CLOSED** (with R3, R6)
- Six header keys hash-pinned by `[Theory]`, each also asserted present in
`Groups` (so a rename can't quietly orphan the pin).
- A real `MouseDown/MouseUp/Click` sequence on the built `0x10000219`
checkbox, asserting both `Selected` and the published `SetOption` — this
pins the `ToggleBehavior` dependency that all 50 LEDs rest on.
- The `option × modifier` truth table (4 rows) plus `AutoRunActive` with the
option OFF, the latter honestly labelled a current-behavior pin rather than
a retail-correctness claim (N6 stays open as a question, correctly).
- Gate script step 8 now states the logout-flush mechanism and both outcomes;
new steps 19 (enable-gating) and 20 (Combat/Character cross-check) exist and
are worded to match what the code actually does.
Not verifiable read-only: the commit's "13,044 passed / 4 skipped" claim.
---
## Residuals
### R1 — MUST-FIX — the timestamp prefixes the message body, not the composed line
`ChatLog.Append` (`src/AcDream.Core/Chat/ChatLog.cs:436-444`) rewrites
`entry.Text`:
```csharp
if (DisplayTimestampsSource?.Invoke() == true)
entry = entry with { Text = FormatTimestampPrefix() + entry.Text };
```
But `ChatEntry.Text` is the *message body*, not the display line. The display
line is composed later, from `Sender` + `Text`, in
`ChatVM.FormatEntry` (`src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs:251-300`)
— the same method both retained chat windows render through
(`ChatWindowController.cs:681` and `FloatingChatWindowController.cs:272`, via
`RecentLinesDetailed()` at `ChatVM.cs:334-351`). Result, per kind:
| Kind | Rendered after `bc43fb1d` | Retail |
|---|---|---|
| LocalSpeech / RangedSpeech | `Alice says, "13:05:09 hi"` | `13:05:09 Alice says, "hi"` |
| Channel | `[Trade] Bob says, "13:05:09 wts"` | `13:05:09 [Trade] Bob says, "wts"` |
| Tell | `Regal tells you, "13:05:09 psst"` | `13:05:09 Regal tells you, "psst"` |
| Emote / SoulEmote | `* Caith 13:05:09 waves` | `13:05:09 * Caith waves` |
| System / Combat / Popup | correct (Text *is* the line) | correct |
**Retail is unambiguous here, in two independent places.**
`ClientSystem::AddTextToScroll @0x00563c50` (pseudo-C `:368347`) receives an
already-composed line in `arg2`, formats the timestamp into a **separate**
string (`00563e24 wcsftime(... u"%#H:%M:%S " ...)``s_NullBuffer_4`, which is
initialized to the empty buffer at `00563dcd`), and passes the two as distinct
`StringInfo`s to `ECM_UI::SendNotice_DisplayFinalStringInfo(arg3, &text,
&timestamp, arg5)` at `00563f2b`; the log-file branch writes
`fprintf(..., "%ls%ls\n", timestamp, text)` at `00563e5b` — timestamp first,
whole line second. And the caller side confirms the line is composed *before*
the call: `ClientCommunicationSystem::Handle_Communication__HearSpeech`
builds `sprintf("%s %s\n", speakerPhrase, text)` at `00571533` and only then
calls `AddTextToScroll` at `0057154d` (pseudo-C `:382204`ff).
**The new tests encode the defect rather than catching it.**
`ChatLogTests.DisplayTimestampsSource_AppliesToEveryProducer` (`:381`) and
`RuntimeCommunicationStateTests.DisplayTimestampsSource_ForwardsToChat_
TimestampingEveryProducer_NotJustAddText` (`:314`) both assert
`^\d{1,2}:\d{2}:\d{2} hi$` on the **raw `ChatEntry.Text`** of a `LocalSpeech`
entry — the exact string that will render inside the quotes. No test asserts a
rendered line, so the suite cannot go red on this.
**Consequence for the gate.** Script step 13 asks the user to "send/receive a
new chat line (e.g. `/who` or any inbound tell/say)" and expects a prefix. The
`/who` half (System) passes; the tell/say half shows the timestamp inside the
quotation marks. This burns a gate round on a known-wrong line format.
**Fix shape.** Prefix at the composed-line seam, not the entry: apply it in
`ChatVM.FormatEntry`/`RecentLinesDetailed` (retail's own model is a separate
leading field, so a render-time prefix is the faithful analog and also keeps
the stored transcript clean for the runtime event stream). Retarget the tests
onto `FormatEntry` output for at least one sender-composed kind per shape
(speech, tell, channel, emote) plus one bare kind (System), and keep the
culture test at whichever layer owns the formatting.
### R2 — SHOULD-FIX — N-5 (no refresh on the server's own option echo) is still neither wired nor registered, and this commit widened its blast radius
Mechanism review N-5 said: wire `OnCharacterOptionsChanged` to refresh the
visible page, **or** file a register row. Neither happened. `grep` finds no
register row mentioning `SendNotice_RefreshOptionsPanel` /
`SendNotice_PlayerOptionChanged` / a deferred options refresh.
This is no longer purely cosmetic after `bc43fb1d`, because the commit added a
second surface reading the same store:
1. **Cross-panel disagreement.** With the Combat window open, clicking
"Automatically Repeat Attacks" on the Character tab changes the bit but the
Combat LED keeps its old art until the next `SyncControls`
(`CombatUiController.cs:228` `OnShown`, combat-mode change, or attack-state
change) — and vice versa. Both now read one store, so this is a stale
*repaint*, not a stale *value*; it is nonetheless a new user-visible
inconsistency introduced by this commit.
2. **Panel open across the seed.** MF-1 re-seeds on show/tab-switch only, not
on the `PlayerDescription` seed itself — the very hook this commit wired for
`LockUI` (`LiveSessionRuntimeFactory.cs:336-341`). A Character tab that is
already open when the seed lands keeps constructor-default rows *and*
constructor-default `_saved` baselines, which reopens the M1 "Reset writes
the wrong direction" failure for that window. Same for a reconnect
(`Options.ResetSession` → fresh `PlayerDescription`) with the panel left
open.
Cheapest closure: have `OnCharacterOptionsChanged` also call
`OptionsPanelController.OnShown()`-equivalent re-seed on the visible page (the
hook is already wired and already proven safe on this thread), and call
`CombatUiController.SyncControls()` from the same place. Otherwise, file the
register row N-5 asked for and say which slice owns it.
### R3 — SHOULD-FIX — the `refresh` widget push has no test
MF-1's stated fix instruction was to assert "the row **and the checkbox** both
converge on the next `OnShown()`". The three new re-seed tests assert
`row.Current` / `row.Saved` only; no test reads `checkbox.Selected` after a
re-seed (the only `checkbox.Selected` assertions in the file are in the new
click test, `CharacterOptionsPageControllerTests.cs:630,636`). The `refresh`
delegate — the half that makes the LED art match the re-read value, and the
half a future refactor is most likely to drop — is production-only. One
assertion on the first row's checkbox inside
`OnShown_ReSeedsRow_FromLiveBindingValue_ChangedBehindItsBack` closes it.
### R4 — SHOULD-FIX — headless `AutoRepeatAttack`'s observable default flips `false → true`, unnamed in AP-196
`HeadlessGameplayOperations.AutoRepeatAttack` was the literal `false`; it now
reads the live bit (`:135-136`), whose pre-seed value is the retail
constructor default **ON** (`CharacterOptionTable.cs:111`,
`Add(AutoRepeatAttack, true, 0x00000002u, true, true)` — auto-save, client
default `true`). Every existing headless bot that never configured
`characterOptions` therefore starts auto-repeating attacks where it previously
did not. That is retail-correct and desirable, but it is an observable-default
change of exactly the class AP-196 was filed to record, and AP-196 names only
`ViewCombatTarget`'s flip. Add the sentence (or a Slice-K-facing note) so a
future "why is my bot chain-attacking?" report lands on the row instead of on
an investigation.
### R5 — NOTE — `refresh`-from-`SaveCurrentValue` is an acdream adaptation, and the doc comment reads as if it were retail's shape
Retail's `SaveCurrentValue @0x004868e0` does **not** call `Refresh()` — only
`RestoreSavedValue @0x00486900` and `RestoreDefaultValue` do (pseudo-C
`:146936`, `:146950`). Retail does not need it: its options word is seeded
before the panel's rows exist, so `m_current` can never drift from the art.
acdream's re-read *can* change `_current` with no other write, so the push is
required — a justified adaptation. The `refresh` param doc
(`OptionPageModel.cs:79-85`) opens "Retail's own `Refresh()` push of the
re-read value onto the widget … invoked ONLY from `SaveCurrentValue`'s re-read
path", which a future reader grepping `0x004868E0` will find contradicted by
the decomp. One clause ("acdream-only — retail's `SaveCurrentValue` needs no
push because its rows are built after the seed") removes the trap.
### R6 — NOTE — the enable-gate tests cover the Character page only
`GetCharacterPageButtons` scopes to `0x10000211`; the Chat (`0x1000050C`) and
Config (`0x10000213`) pages get the same gate installed by the same loop but
are unpinned. The fixture confirms all nine buttons have the required shape
(no `0x0B`, `Ghosted` present), so this is coverage, not a defect — but a
one-line `[Theory]` over the three page ids would also pin the per-page
scoping the slice's own commit message calls out.
### R7 — NOTE — `HeadlessGameplayOperations`'s combat options can now throw
`AutoRepeatAttack`/`AutoTarget` call `RequireRuntime()`, which throws
`InvalidOperationException` when unbound (`:272-275`); they were constants and
could never throw. `_runtime` is assigned once in `Bind` and never cleared
(`:46,65`), and both properties are only read from runtime-attached combat
flows, so this is not reachable today. Recorded so a future teardown-ordering
change doesn't rediscover it as a crash.
---
## Blast radius of the fixes themselves (checked, clean except R1/R2/R4)
- **`ChatLog.DisplayTimestampsSource`** is a new public property on a Core
type; the only binder is `GameWindow.cs:662` (graphical host). Headless never
binds it — `HeadlessGameplayOperations`'s only chat write
(`:252 CommunicationOwner.Chat.OnSystemMessage`) goes through the same
`Append` but with the source null, so bot event streams and the 67 Headless
tests are untouched. No plugin-abstraction surface references `ChatLog` or
`ChatEntry`. `RuntimeCommunicationState.DisplayTimestampsSource` became a
forwarding property, so every existing caller compiles unchanged.
- **`CombatUiController.Bindings` rewiring** — exactly one production
construction site (`RetailUiRuntime.cs:1199-1206`) and one
`CombatRuntimeBindings` site (`InteractionRetainedUiComposition.cs:644-646`),
both updated; `OptionsRuntimeBindings` likewise has a single construction
site, so `MountCombat`'s new `_bindings.Options` dependency cannot be null.
Tests construct through a fake with the same shape.
- **Deleted `RuntimeSettingsController` members** — zero remaining references
anywhere in `src/` or `tests/`. `GameplaySettings`'s three positional
parameters were removed, which is a compile-time forcing function (AP-196
says so explicitly); stale `autoTarget`/`autoRepeatAttack`/`viewCombatTarget`
keys in an existing `settings.json` are simply ignored on read and dropped on
write.
- **`SetUiLocked` on every seed** now persists a server-derived `lockUI` into
`settings.json` and updates the Settings VM draft from the session route.
That is one save per session start (the guard no-ops afterwards), on the same
thread as the existing `SyncChatFromServerOptions` call beside it, and it
makes the persisted value track the server — consistent with AP-196's
"write-behind mirror" framing. No re-entrancy: `ApplyUiLock` terminates at
`UiRoot.UiLocked = locked` (`RuntimeSettingsTargets.cs:192`) and never
publishes a wire command.