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>
24 KiB
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, S1–S6, N1–N8).
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 ChatKinds 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):
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 inGroups(so a rename can't quietly orphan the pin). - A real
MouseDown/MouseUp/Clicksequence on the built0x10000219checkbox, asserting bothSelectedand the publishedSetOption— this pins theToggleBehaviordependency that all 50 LEDs rest on. - The
option × modifiertruth table (4 rows) plusAutoRunActivewith 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:
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
StringInfos to ECM_UI::SendNotice_DisplayFinalStringInfo(arg3, &text, ×tamp, 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 :382204ff).
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:
- 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:228OnShown, 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. - Panel open across the seed. MF-1 re-seeds on show/tab-switch only, not
on the
PlayerDescriptionseed itself — the very hook this commit wired forLockUI(LiveSessionRuntimeFactory.cs:336-341). A Character tab that is already open when the seed lands keeps constructor-default rows and constructor-default_savedbaselines, which reopens the M1 "Reset writes the wrong direction" failure for that window. Same for a reconnect (Options.ResetSession→ freshPlayerDescription) 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.DisplayTimestampsSourceis a new public property on a Core type; the only binder isGameWindow.cs:662(graphical host). Headless never binds it —HeadlessGameplayOperations's only chat write (:252 CommunicationOwner.Chat.OnSystemMessage) goes through the sameAppendbut with the source null, so bot event streams and the 67 Headless tests are untouched. No plugin-abstraction surface referencesChatLogorChatEntry.RuntimeCommunicationState.DisplayTimestampsSourcebecame a forwarding property, so every existing caller compiles unchanged.CombatUiController.Bindingsrewiring — exactly one production construction site (RetailUiRuntime.cs:1199-1206) and oneCombatRuntimeBindingssite (InteractionRetainedUiComposition.cs:644-646), both updated;OptionsRuntimeBindingslikewise has a single construction site, soMountCombat's new_bindings.Optionsdependency cannot be null. Tests construct through a fake with the same shape.- Deleted
RuntimeSettingsControllermembers — zero remaining references anywhere insrc/ortests/.GameplaySettings's three positional parameters were removed, which is a compile-time forcing function (AP-196 says so explicitly); staleautoTarget/autoRepeatAttack/viewCombatTargetkeys in an existingsettings.jsonare simply ignored on read and dropped on write. SetUiLockedon every seed now persists a server-derivedlockUIintosettings.jsonand 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 existingSyncChatFromServerOptionscall beside it, and it makes the persisted value track the server — consistent with AP-196's "write-behind mirror" framing. No re-entrancy:ApplyUiLockterminates atUiRoot.UiLocked = locked(RuntimeSettingsTargets.cs:192) and never publishes a wire command.