M1: the Character tab binds at startup, pre-session — rows seed from constructor words and nothing re-seeds on PlayerDescription; Reset (and tab-switch, which is OnHidden->Reset) then publishes the STALE baseline over server truth, and the one-shot UiLocked assignment can permanently disagree with the radar's polled read. M2: the Combat panel's three LEDs still write/read the orphaned GameplaySettings copy — decorative, wire-less, and visibly divergent from the Character tab's rows for the same PlayerOptions (the CH3 two-writable-copies mode); headless hardcodes for the same ids filed alongside. M3: ToggleUiLock's convergence guard (initialized converged, now comparing values from two different stores) can early-return past ApplyUiLock — one press flips the wire and radar but not the windows. S1: AddText is NOT acdream's transcript chokepoint — speech/tells, emotes, Turbine channels, combat text and @-replies write ChatLog directly, so the timestamp prefix reaches only ServerMessage/WeenieError lines and gate step 13's tell/say half cannot pass. S2: sky.vert never reads the fog-disable param — crisp terrain against a fogged horizon with the option on. S3/S4: missing register rows for the seven default-source changes (ViewCombatTarget's observable default flips) and the orphaned-but-written GameplaySettings; the timestamp format is culture-dependent where retail strftime is literal. S6: the two changed consumer files ship without their test files touched. Clean: scoped per-page searches structurally sound (fixture-verified), GetOptionBit thread-safe/allocation-free, the run-XOR byte-correct vs SetHoldRun @0x006b3370 with wire encoding untouched. Gate steps 3 and 13 cannot pass pre-fix — the OP4 gate must wait for the fix round. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
22 KiB
OP4 review — regression / blast-radius lens
Commit under review: 22b86b9f "feat(ui): Campaign OP slice OP4 — the Character tab"
Reviewer lens: regression + blast radius (both axes on every changed surface)
Method: read-only. Diff walk, pre/post consumer enumeration by git grep at
22b86b9f^ and 22b86b9f, fixture-tree analysis of
options_panel_2100006E_1000018D.json, decomp cross-check against
docs/research/named-retail/acclient_2013_pseudo_c.txt and acclient.h.
No build, no test run, no launch.
Verdict: APPROVE-WITH-FIXES. M1–M3 must land before the OP4 connected gate
is run — the gate script this commit ships (docs/research/2026-08-11-campaign-op-test-script.md
§OP4 steps 3 and 13) contains steps that cannot pass against this code, so
running it now burns a user session on a known-failing check.
1. Group-C re-point: per-option old-consumer enumeration
Seven ids moved from the client-local GameplaySettings record (settings.json)
to RuntimeCharacterOptionsState.GetOptionBit. For each: every read site of the
OLD value at 22b86b9f^, and whether it moved.
AutoTarget (0x0D, Options1 0x2000, batched, ClientDefault ON)
Old read site (22b86b9f^) |
Moved? |
|---|---|
Composition/FrameRootComposition.cs:599 → CombatCameraTargetSource(d.Settings, …) |
✅ → CharacterOptionCombatSettingsSource |
Composition/SessionPlayerComposition.cs:770 → LiveCombatAttackOperations(… d.Settings …) |
✅ |
Composition/InteractionRetainedUiComposition.cs:286 → LiveCombatTargetOperations(autoTarget: …) |
✅ |
UI/Layout/CombatUiController.cs:116 (LED click writes) / :193 (LED seed reads) |
❌ NOT moved |
Settings/RuntimeSettingsController.cs:236 public bool AutoTarget => Gameplay.AutoTarget |
❌ now dead (no consumer) |
Settings/RuntimeSettingsController.cs:422,433 (SetCombatGameplay VM mirrors) |
❌ still mirrors the orphan |
Headless/Hosting/HeadlessGameplayOperations.cs:127 AutoTarget => true (hardcoded) |
❌ never re-pointed |
AutoRepeatAttack (0x00, Options1 0x02, auto-save, ClientDefault ON)
| Old read site | Moved? |
|---|---|
Composition/SessionPlayerComposition.cs:770 → LiveCombatAttackOperations (→ RuntimeCombatAttackState:380,403,411) |
✅ |
UI/Layout/CombatUiController.cs:114 / :192 |
❌ NOT moved |
Settings/RuntimeSettingsController.cs:238 |
❌ now dead |
Headless/Hosting/HeadlessGameplayOperations.cs:126 AutoRepeatAttack => false |
❌ never re-pointed |
ViewCombatTarget (0x07, Options1 0x80, batched, ClientDefault OFF)
| Old read site | Moved? |
|---|---|
Combat/CombatCameraTargetSource.cs:44 (via injected source) |
✅ |
UI/Layout/CombatUiController.cs:118 / :194 |
❌ NOT moved |
Settings/RuntimeSettingsController.cs:240 |
❌ now dead |
⚠️ Observable-default flip. GameplaySettings.Default.ViewCombatTarget = true
(src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs:51) but bit
0x80 is CLEAR in retail's CharacterOptions1.Default = 0x50C4A54A
(→ CharacterOptionTable.cs:118 ClientDefault: false). A fresh install's
combat camera stops tracking the target by default. No register row filed.
VividTargetingIndicator (0x0E, Options1 0x8000, batched, ClientDefault ON)
| Old read site | Moved? |
|---|---|
Composition/InteractionRetainedUiComposition.cs:676 → VividTargetRuntimeBindings |
✅ (polled lambda) |
Panels/Settings/SettingsPanel.cs:322 |
n/a — SettingsPanel has no production construction site (new SettingsPanel( appears only in tests/AcDream.UI.Abstractions.Tests/…:40) |
CoordinatesOnRadar (0x14, Options1 0x400000, batched, ClientDefault ON)
| Old read site | Moved? |
|---|---|
Composition/LivePresentationComposition.cs:836 → radar snapshot |
✅ (polled lambda) |
LockUI (0x33, Options2 0x1000000, auto-save, ClientDefault OFF)
| Old read site | Moved? |
|---|---|
Composition/LivePresentationComposition.cs:837 uiLocked: (radar) |
✅ polled — converges live |
Composition/InteractionRetainedUiComposition.cs:477 host.Root.UiLocked = … |
⚠️ moved but one-shot at mount, never re-read → does NOT converge |
Net/LiveSessionRuntimeFactory.cs:387 ToggleUiLock read |
✅ reads the bit — but see M3 |
Settings/RuntimeSettingsController.cs:355,359,362,369 (SetUiLocked writer) |
❌ still writes the orphan, and is still called from ToggleUiLock |
Note: LockUI is deliberately NOT a Character-tab row
(CharacterOptionsPageControllerTests.ExcludedIds_HaveNoRow), and the OP4 gate
script's Group-C section (items 17–18) does not list it — so this re-point ships
ungated.
AcceptLootPermits (0x10, Options1 0x80000, auto-save, ClientDefault OFF)
| Old read site | Moved? |
|---|---|
Net/LiveSessionRuntimeFactory.cs:452 (ClientCommandController.Bindings.AcceptLootPermits) |
✅ |
Net/LiveSessionRuntimeFactory.cs:454 (SetAcceptLootPermits) |
✅ (now sends 0x0005 and writes the orphan) |
UI/ClientCommandController.cs:444,450 (`/consent on |
off`) |
Net/LiveSessionCommandRouter.cs:458,459 |
✅ pure forwarders |
ToggleRun (0x0A, Options1 0x400, batched, ClientDefault ON) — Group B
| Old read site | Moved? |
|---|---|
Input/DispatcherMovementInputSource.cs:77 hardcoded !walking |
✅ → RunAsDefaultMovement != walking |
Panels/Settings/SettingsPanel.cs:314 |
n/a (test-only surface) |
2. MUST-FIX
M1 — The Character tab is seeded before login and never re-seeded; Reset then writes the stale baseline over server truth
RetailUiRuntime.Initialize() (src/AcDream.App/UI/RetailUiRuntime.cs:371)
calls MountOptionsPanel(), which calls
CharacterOptionsPageController.Bind (RetailUiRuntime.cs:2010). That whole
chain runs inside lease.Mount(…)
(src/AcDream.App/Composition/InteractionRetainedUiComposition.cs:876), which
runs inside InteractionRetainedUiCompositionPhase — a startup phase of
GameWindow.OnLoad (src/AcDream.App/Rendering/GameWindow.cs:1231, chain at
:1325-1369). No network session exists yet.
So at CharacterOptionsPageController.cs:368:
bool initial = bindings.CurrentValue(spec.Id); // == GetOptionBit(...)
checkbox.Selected = initial;
var row_ = new BoolOptionRow(initial, entry.ClientDefault, apply: …);
initial is read from RuntimeCharacterOptionsState's constructor words
(RuntimeCharacterState.cs:671-672, DefaultOptions1 = 0x50C4A54A /
DefaultOptions2 = 0x00948700), not the character's server options.
PlayerDescription installs the real words later at
src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:223 (Options.Replace),
and the only subscriber to OnCharacterOptionsChanged is
SyncChatFromServerOptions (Net/LiveSessionRuntimeFactory.cs:323-324).
Nothing re-seeds the 50 rows. Consequences:
- Gate-script step 3 ("Confirm the checkboxes reflect your character's actual state … seeded from the live server bit at panel-mount time") is false for every option whose server value differs from the client constructor default.
- Worse — the
BoolOptionRow_savedbaseline is also stale. Take a batched option the server has ON but whose constructor default is OFF (e.g.SideBySideVitals). Row seeds_current = _saved = false. User clicks it →SetCurrentValue(true)→TrySetOptionsees the bit already true → no-op (correct). User changes their mind and clicks Reset (or just switches tab, which isOnHidden() → Reset(),OptionPageModel.cs:261) →Changed == true→RestoreSavedValue()→_apply(false)→SetSingleCharacterOptionRuntimeCmd(id, false)→ the server bit is turned OFF. A visually-idempotent "toggle then cancel" mutates real character state in the wrong direction. This is the "contract asserting a mechanism that does not exist" class the C4 closeout handoff flags as binding. host.Root.UiLocked = d.Character.Options.GetOptionBit(LockUI)(InteractionRetainedUiComposition.cs:477-486) has the same shape but is a one-shot assignment, so retained-window locking never converges to the server bit — while the radar'suiLockedlambda (LivePresentationComposition.cs:843-844) polls and does converge. Two consumers of one option, permanently able to disagree.
The fix shape is a re-seed hook on OnCharacterOptionsChanged (the seam CH3
already established for chat) that re-runs each row's
SetSeed(current) — i.e. rewrite _current, _saved, and checkbox.Selected
without invoking _apply. That verb does not exist on BoolOptionRow today.
M2 — Two writable copies: the Combat panel's three LEDs still drive the orphaned GameplaySettings
src/AcDream.App/UI/Layout/CombatUiController.cs:113-118 writes and :192-194
reads GameplaySettings.AutoRepeatAttack / AutoTarget / ViewCombatTarget,
wired in production at InteractionRetainedUiComposition.cs:644-648
(() => d.Settings.Gameplay, d.Settings.SetCombatGameplay) and mounted at
RetailUiRuntime.cs:366 (MountCombat()). Nothing gameplay-facing reads that
record any more — every behavioral consumer now reads the server bit. Net
effect:
- The retail Combat window's Repeat Attacks / Auto Target / Keep in View checkboxes are now decorative: clicking them changes combat behavior not at all, and never reaches the wire.
- They and the Character tab's rows for the same three retail
PlayerOptions read from different stores, so they will visibly disagree. RuntimeSettingsController.SetCombatGameplay(:415-442) keeps persisting the divergent copy to settings.json, so the disagreement survives restarts.
This is exactly the "two writable copies" failure mode the CH3 research warned
about, and it is the one old read site of the three combat options that the
re-point missed. The Combat panel's LEDs must route through
SetSingleCharacterOptionRuntimeCmd and seed from GetOptionBit, in this
slice or an immediately-following one.
M3 — ToggleUiLock's new dual write can silently drop the visual lock
src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:392-400:
bool locked = !_domain.Character.Options.GetOptionBit(CharacterOptionId.LockUI);
SendSingleCharacterOption((uint)CharacterOptionId.LockUI, locked);
_interaction.Settings.SetUiLocked(locked);
RuntimeSettingsController.SetUiLocked opens with
(src/AcDream.App/Settings/RuntimeSettingsController.cs:355):
if (Gameplay.LockUI == locked && _uiLockConverged) return;
with _uiLockConverged = true at construction (:185). Before this commit
locked was computed as !Gameplay.LockUI, so the guard could never trip.
Now locked is derived from a different store, so whenever the persisted
Gameplay.LockUI already equals the newly-computed value the method
early-returns and _runtimeTargets?.ApplyUiLock(locked) (:360) never runs —
the wire bit flips, the radar (polled) follows, and host.Root.UiLocked does
not. Concretely: a user with "lockUI": true in settings.json (set by
/lockui in any pre-OP4 session) presses the lock toggle once after login and
nothing happens to their windows, while the server is told the UI is locked.
3. SHOULD-FIX
S1 — DisplayTimeStamps only reaches a small subset of transcript lines
RuntimeCommunicationState.AddText (:219-227) is documented as "the single
chokepoint every producer of player-visible interface text must go through",
and retail's ClientSystem::AddTextToScroll @0x00563C50 genuinely is that
chokepoint. In acdream it is not. These producers write ChatLog directly and
therefore never get a timestamp:
| Producer | Site |
|---|---|
| All heard speech (say / tell / ranged) | Runtime/Session/LiveSessionEventRouter.cs:293 Chat.OnLocalSpeech |
| Emotes / soul emotes | LiveSessionEventRouter.cs:319,321 |
| Player-kill messages | LiveSessionEventRouter.cs:325 |
| Turbine channels (allegiance/general/trade/LFG/RP/society) | LiveSessionEventRouter.cs:~535-556 via RouteTurbineChat |
| All combat text | CombatChatTranslator (LiveSessionEventRouter.cs:306) writes social.Chat directly |
@age / @index / @clist / @hslist / allegiance-info replies |
Core.Net/GameEventWiring.cs:162,176,183,190,197 |
| App-side echoes | Net/LiveSessionRuntimeFactory.cs:165,169,418 |
| Headless chat | Headless/Hosting/HeadlessGameplayOperations.cs:242 |
Only ServerMessage (0xF7E0) and the WeenieError family route through AddText
(LiveSessionEventRouter.cs:310-317, GameEventWiring.cs:~655-668). The gate
script's own step 13 ("send/receive a new chat line … e.g. /who or any inbound
tell/say. The NEW line should be prefixed") is therefore unsatisfiable for the
tell/say half. Either move the remaining producers behind AddText or narrow
the register/gate wording to what is actually implemented.
Direction check (nothing leaks where it must not): the ClientLocal/SpewBox
branch returns before the prefix (:213-217) — correct and tested; there is no
chat-log-file writer in the tree (grep for ChatLogFile/WriteChatLog is
empty); AddText is display-only so nothing reaches the wire; the chat
filters in Panels/Chat/ChatCommandRouter.cs / ChatInputParser.cs match on
outbound input, not transcript lines, so a prefix cannot confuse them.
S2 — FogMode.Off is not honored by the sky shader
WeatherSystem forces fogMode = FogMode.Off (WeatherState.cs:326-327) and
the mesh/terrain fragment shaders respect it
(Rendering/Shaders/mesh_modern.frag:48-49 if (mode == 0) return lit;,
terrain_modern.frag:160). But Rendering/Shaders/sky.vert:159-162 computes
vFogFactor from uFogParams.x/.y only and never reads uFogParams.w;
sky.frag:62 then blends the dome toward the fog color. With Disable Distance
Fog ON the world is crisp to the horizon while the sky dome keeps its fog-color
band — a visible seam exactly where the option is supposed to remove one. This
is a visual-gate item (gate step 14) and needs the user's eyes either way, but
the shader asymmetry is a code fact, not a judgement call.
S3 — Observable-default changes are not registered
The register hunk adds/updates AP-194, TS-73, TS-75–TS-80. None of them
records that seven options changed their effective default source from
GameplaySettings.Default (or the user's persisted settings.json) to the
retail constructor word, nor that ViewCombatTarget's observable default flips
true → false, nor that GameplaySettings.{AutoTarget, AutoRepeatAttack, ViewCombatTarget, LockUI, VividTargetingIndicator, CoordinatesOnRadar, AcceptLootPermits, ToggleRun} are now orphaned-but-still-written. The campaign
contracted register rows for observable-default changes; these are the rows.
S4 — The timestamp format is culture-dependent
RuntimeCommunicationState.cs:225:
text = DateTime.Now.ToString("H:mm:ss ") + text;
No IFormatProvider, so : is CurrentCulture.DateTimeFormat.TimeSeparator.
Retail's CRT strftime("%#H:%M:%S ") emits literal colons unconditionally. On a
machine whose culture uses . (e.g. fi-FI) acdream renders 13.05.09 .
The new test AddText_TimestampsTrue_PrefixesTranscriptLine pins
^\d{1,2}:\d{2}:\d{2} … and would fail on such a machine — i.e. the bug is
also a latent CI/locale flake. Use CultureInfo.InvariantCulture.
S5 — The headless host's combat-option reads were not swept
src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs:126-127 still
hardcodes AutoRepeatAttack => false and AutoTarget => true. OP7 gave
headless bots a characterOptions config that writes these very bits, so a bot
configured with AutoRepeatAttack now sets the server bit while its own combat
loop still reads false. Same interface (ICombatGameplaySettingsSource
shape), same options, opposite direction from the graphical host after this
commit.
S6 — Test coverage gaps on the two changed consumers
The Runtime-level OFF paths are well covered (fog unbound/false/true, timestamps
unbound/false/true + SpewBox exemption, RunAsDefaultMovement unbound/true/false
live-poll). What is not covered:
DispatcherMovementInputSource.Capture()— the file changed (:83) buttests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cswas not touched. The existingCapturesDispatcherHeldStateAndRetailWalkModifieronly exercises option-unbound (⇒true) + walk-held. Nothing pins the four-way truth table (option × modifier), nor the|| AutoRunActiveoverride with the option OFF.- The per-page scoped Apply/Reset/Defaults wiring —
OptionsPanelControllerTests.cswas not touched, so the mechanism the commit message calls out (UiElement.FindDescendantscoping) has no test that a Chat-page Apply drives the Chat page's model rather than Character's. - Nothing pins the login-before-seed transition (M1) — unsurprising, since the mechanism does not exist.
4. NOTE
- N1 —
FindDescendantpromotion changed semantics, and the scoping is sound. The old privateUiTabPanel.FindDescendantskipped the root; the newUiElement.FindDescendant(src/AcDream.App/UI/UiElement.cs:312-334) matches the root first.UiTabPanel:162,163,205,208now calls the root-inclusive version — harmless here because the panel's own id (0x1000018D) is not a tab button or page id, but it is a real behavior change worth knowing. On the OP3 re-review's duplicate-id hazard: the committed fixtureoptions_panel_2100006E_1000018D.jsonhas 111 nodes / 38 unique ids and 16 duplicated ids (0x100002CE/CF/D0×16 each;0x100001FC/FD/FE×3). The scoped searches are genuinely scoped — each page slot is a direct child of the root and owns its own Apply/Reset/Defaults instance:/0x1000018D/0x10000211/{0x100001FC,FD,FE},/0x1000018D/0x10000213/{…},/0x1000018D/0x1000050C/{…}. A Character-page search structurally cannot reach a Chat-page button. Separately,0x100001FA/0x100001FB(the Character ListBox + scrollbar) are unique, soCharacterOptionsPageController's flatlayout.FindElementlookups (:280,:293) are unambiguous — but that is a property of the data, not of the code, and nothing asserts it. - N2 — dead surface.
RuntimeSettingsController.AutoTarget/AutoRepeatAttack/ViewCombatTarget(:236-240) now have zero consumers.GameplaySettingsState(Combat/LiveCombatAttackOperations.cs:23) survives only intests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs:16. - N3 — thread-safety is consistent; the binding fields are not volatile.
GetOptionBit(RuntimeCharacterState.cs:858-865) reads one word through the existingVolatile.Read-backedOptions1/Options2properties, one staticDictionarylookup, no lock, no allocation (CharacterOptionTableEntryis areadonly record struct,TryGetValueusesout). That matches every other read on the type. The three newFunc<bool>?source properties are plain auto properties written once in theGameWindowconstructor (Rendering/GameWindow.cs:649-678) before the render loop and the network thread exist, so publication is fine in practice but is not formally guaranteed.WeatherSystemis a get-only property assigned once (Runtime/World/RuntimeWorldEnvironmentState.cs:144,149), so the fog binding cannot be lost on session reset. Per-frame cost is ~3 dictionary lookups (fog + radar ×2) + 1 inCapture()— negligible, and none of them is on a Slice-I zero-allocation resolve path. - N4 —
FindCheckboxis direct-children-only.CharacterOptionsPageController.cs:385-392searches the row root and its direct children while the commit simultaneously promotes a recursiveFindDescendantfor exactly this class of lookup. If a template ever nests the checkbox one level deeper, all 50 rows silently degrade to a log line. - N5 — the timestamp weakens
ChatLog's text-keyed dedupe.ChatLog.OnSystemMessage(src/AcDream.Core/Chat/ChatLog.cs:266-278) dedupes on exact text within 1 s. Timestamped and untimestamped copies of the same logical line (one viaAddText, one via a direct producer from S1) can no longer match; two identical lines straddling a second boundary also stop matching. This is an acdream-only workaround, not retail behavior, so the consequence is a re-appearing duplicate, not a correctness break. - N6 — autorun ignores the new default; retail's own seam does not.
Run: (_movement.RunAsDefaultMovement != walking) || AutoRunActive(Input/DispatcherMovementInputSource.cs:83). The!= walkinghalf is byte-correct against retail:CommandInterpreter::SetHoldRun @0x006b3370computes(!hold_run) != (!UITogglesRun())— i.e.holdRun XOR ToggleRun— and passes that toCMotionInterp::set_hold_run, which is precisely where acdream'sMovementInput.Runlands (Runtime/Gameplay/PlayerMovementController.cs:1166-1168). The wire encoding is untouched and stays retail-correct in both option states:Run→IsRunning→HoldKey.Run/HoldKey.None+ForwardSpeed(Runtime/Gameplay/LocalPlayerOutboundController.cs:234-248), so no double-inversion. There is also no collision with theToggleRunLockkeybind latch — that drives_autoRunActive, a separate field, and the property's doc comment says so explicitly. What is newly reachable is the|| AutoRunActiveclause:CommandInterpreter::SetAutoRun @0x006b4850setsauto_runand callsApplyCurrentMovement()— it does not force run — so with the option OFF an autorunning acdream player runs where retail would walk. That||predates this commit (2026-07-30 movement-parity audit) but was unobservable while the default was hardcoded. - N7 — citation accuracy.
RuntimeLocalPlayerMovementState's doc comment names "retailPlayerOption RunAsDefaultMovement". No such member exists —acclient.h:4165spells id0xAToggleRun_PlayerOption, andRunAsDefaultMovementis ACE's name for the bit. The binding (GameWindow.cs:677) and the row'sRetailName("ToggleRun", which is what hashesID_PlayerOption_ToggleRun— confirmed at pseudo-C:171586) are both correct; only the prose is wrong. - N8 —
Defaultscan emit a burst of 21 immediate0x0005sends.OptionPage.Defaults()callsRestoreDefaultValue()on all 50 rows, each of which publishesSetSingleCharacterOptionRuntimeCmd.TrySetOptionno-ops on unchanged values, so the worst case is bounded by the auto-save set (21 ids). Retail does the same thing; recorded here only so a future wire-rate reviewer does not treat it as a bug.