docs: OP4 mechanism review — APPROVE-WITH-FIXES (converges with blast on the seed bug)

MF-1 converges with blast M1: rows seed once at startup pre-login and
never re-read; retail's own schedule is the fix (UIOption_Checkbox::
SaveCurrentValue @0x004868E0 = m_current = GetValue() live, m_saved =
m_current, on every OnShown — predicted by OP3 review note N2). MF-2:
the OnOptionChanged Apply/Reset ghosting seam (Ghosted 0x0D / Normal,
PostInit runs it once so both start disabled) is wired by NOTHING in
production. The Defaults correction is independently VERIFIED from the
decomp (SetPlayerOption @0x00486e80 sets m_default from
GetDefaultOptionValue; InqDefaultGameplayOptionProperty's only caller is
the Chat tab's slider path — the plan's U1 directive would have made
Defaults restore nothing). Cross-page button collision confirmed from
the fixture (0x100001FC/FD/FE x3). 18 rows traced end-to-end; six header
hashes + 49 label globals hand-verified. SHOULD: culture-dependent
timestamp separator + overclaimed byte-verification; no
OnClick/ToggleBehavior-driven LED test; gate item 8's logout-flush false
failure. NOTEs incl. sky-dome fog and no mid-session echo repaint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 04:51:50 +02:00
parent 0acb341c29
commit 85afaae5fd

View file

@ -0,0 +1,545 @@
# Campaign OP slice OP4 — MECHANISM-FAITHFULNESS review
**Commit under review:** `22b86b9f` "Campaign OP slice OP4 — the Character tab"
**Lens:** mechanism faithfulness (retail behaviour vs. the port). The
regression/blast-radius lens is a separate document.
**Contract:** `docs/plans/2026-08-10-options-panel-campaign.md` §4 OP4 + §2 D3/D7.
**Oracles:** `docs/research/2026-08-10-character-options-map.md` §2/§7.1;
`docs/research/2026-08-10-options-panel-structure.md` §3/§6/§7/§10.4;
`docs/research/2026-08-10-set-character-options-wire.md`;
`docs/research/named-retail/acclient.h`;
`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
**Mode:** read-only. No build, no test run, no client launch. The only file
written is this one.
---
## Verdict
**APPROVE-WITH-FIXES** — with one hard sequencing condition:
> **The connected gate must NOT be scheduled until MUST-FIX 1 lands.** The
> Character tab's 50 LEDs are seeded once, at *window startup*, before any
> network session exists, from `RuntimeCharacterOptionsState`'s
> constructor default word — so the panel cannot show the logged-in
> character's real options. Test-script items 3, 6, 7 and 8 all assert the
> opposite and will fail on the first run.
The *substance* of the slice is right and is the best-evidenced work in the
campaign so far. The 50-row binding is exact, the DEFAULTS CORRECTION is
independently verified and the plan's own §1 note was genuinely wrong, the
cross-page button-id collision is real and the scoped fix is correct, the
Group-B/C consumers are correctly anchored, and the register bookkeeping is
honest — including two corrections (TS-75, the U1 defaults source) where the
implementer refused to wire a plausible-but-wrong seam. Both MUST-FIX items
are contained, retail-anchored, and touch three files.
| Class | Count | Items |
|---|---|---|
| MUST-FIX | 2 | MF-1 stale row seed (never reads live option word); MF-2 Apply/Reset enable-gating not wired |
| SHOULD-FIX | 3 | SF-1 timestamp culture + over-claimed "byte-verified"; SF-2 test gaps (widget click path, header hashes); SF-3 test-script false-failure in item 8 + the enable-gate items |
| NOTE | 5 | N-1 `FindCheckbox` ignores the retail id; N-2 `FindDescendant` semantics changed on promotion; N-3 sky dome still fogs; N-4 AP-194 anchor mislabel; N-5 no refresh on server-side option echo |
---
## 1. What I verified as CORRECT (evidence first)
These are recorded because several are load-bearing for later slices and
because a future reader must not "rediscover" them as bugs.
### 1.1 The 50-row binding — 18 rows traced end-to-end
`src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs:101-186`
(`Groups`) reproduces `docs/research/2026-08-10-options-panel-structure.md`
§7's authored sequence **exactly**: group sizes 3 / 15 / 6 / 11 / 7 / 7+1,
row-for-row in authored order, six header keys in authored order. Every
`RetailName` matches `acclient.h:4162-4218`'s literal enumerator spelling
(I diffed all 49 against the header).
I traced **18** rows (the brief asked for 15) row → `PlayerOption` id →
word/mask → auto-save flag → client default → wire route, against
`CharacterOptionTable` (`src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs`)
and, independently, against the two byte-verified jump tables transcribed in
character-options-map.md §1.3 (`IsAutoSaveOption`, VA `0x0059A62C`) and §1.4
(`GetDefaultOptionValue`, VA `0x005D2A5C`). Note both tables are *inverted*
(`0x00``jump_table[0]``return 1`); the table's columns decode
correctly under that inversion in all 18 cases.
| Row | id | word · mask | auto-save | ClientDefault | wire route |
|---|---|---|---|---|---|
| Keep Combat Targets in View | 0x07 | O1 `0x80` | no (idx 7 = 01) | false | dirty → `0x01A1` |
| Salvage Multiple | 0x22 | O2 `0x80` | no | false | dirty |
| Use Main Pack as Default | 0x29 | O2 `0x4000` | no | false | dirty |
| Vivid Targeting Indicator | 0x0E | O1 `0x8000` | no | **true** (idx 0x0E = 00) | dirty |
| Display 3D Tooltips | 0x08 | O1 `0x100` | no | true | dirty |
| Show Coordinates By the Radar | 0x14 | O1 `0x400000` | no | true | dirty |
| Disable Distance Fog | 0x30 | O2 `0x200000` | no (idx 0x30 = 01) | false (past table end) | dirty |
| Show Your Helm | 0x2F | O2 `0x100000` | **yes** (idx 0x2F = 00) | false (past end) | `0x0005` |
| Show Your Cloak | 0x32 | O2 `0x800000` | **yes** | false (past end) | `0x0005` |
| Ignore Allegiance Requests | 0x01 | O1 `0x04` | yes | false | `0x0005` |
| Ignore Fellowship Requests | 0x02 | O1 `0x08` | yes | **true** | `0x0005` |
| Share Fellowship XP | 0x0F | O1 `0x40000` | yes | true | `0x0005` |
| Accept Corpse Looting Permissions | 0x10 | O1 `0x80000` | yes | false | `0x0005` |
| Automatically Repeat Attacks | 0x00 | O1 `0x02` | yes | true | `0x0005` |
| Listen to Society Chat | 0x2E | O2 `0x80000` | yes | false (past end) | `0x0005` |
| Display Timestamps | 0x21 | O2 `0x40` | no | false | dirty |
| Run as Default Movement | 0x0A | O1 `0x400` | no | true | dirty |
| Listen to PK death messages | 0x34 | O2 `0x02000000` | no (ACE-sourced) | false | dirty — D3 wire+store only, AP-193 |
The click path is `checkbox.OnClick` (`:381`) → `BoolOptionRow.SetCurrentValue`
→ the row's `apply` closure (`:371-380`) → `Bindings.SetOption`
`SetSingleCharacterOptionRuntimeCmd` (`RetailUiRuntime.cs:2029-2031`) →
`LiveSessionCommandRouter.cs:173-177``LiveSessionRuntimeFactory.cs:347-355`
`SendSingleCharacterOption``RuntimeCharacterOptionsState.TrySetOption`
(`RuntimeCharacterState.cs:806-842`), which writes the local bit first and then
*either* `sendAutoSave` (`0x0005`) *or* `MarkDirty()`. That is retail's
`CPlayerModule::OnChanged @0x0059A8E0` step order exactly. The batched half
flushes through `OptionPage.AfterApply``SaveCharacterOptionsRuntimeCmd`
(`RetailUiRuntime.cs:1987-1988`) → `TryFlush``SendSetCharacterOptions`. ✅
D3's 50th row is wire+store only: no consumer, `HearPkDeathMessages` appears
in no Group-B/C binding, and AP-193 carries the ACE-sourced mapping. ✅
The 50th row's label key is right: I computed
`compute_str_hash("ID_PlayerOption_HearPKDeaths")` with the exact algorithm at
`DatStringResolver.cs:68-82` and got **`0x0D16E9A3`**, matching structure doc
§7's byte-verified string id. ✅
### 1.2 The DEFAULTS CORRECTION is correct — the plan's §1 "U1 closed" note was wrong
This is the highest-stakes claim in the commit and it holds. Read directly
from the pseudo-C rather than from the implementer's summary:
`acclient_2013_pseudo_c.txt:147331`
`UIOption_Checkbox::SetPlayerOption @0x00486e80`, guarded at `00486ea3` by
`if (this->m_propName == 0 && …)`, contains at `00486f2d`:
```
this->m_default = PlayerModule::GetDefaultOptionValue((__return_1 + 4), this->m_playerOption);
```
and `PlayerOptionPage::AddToggleOption(enum PlayerOption) @0x004F2A00`
(`:245616`) calls `SetPlayerOption` then `OptionPage::RegisterOption` for
*every* row on this tab. So the Character tab's `m_default` **is**
`GetDefaultOptionValue` — i.e. `CharacterOptionTable.ClientDefault`, the table
OP1 already ported. Structure doc §7's "biggest open unknown" is genuinely
closed by this.
The plan's §1 note ("U1 closed: `UIOption::InqDefaultGameplayOptionProperty
@0x004ef8d0` … the Defaults button restores DAT-authored values") is
**mis-scoped, and the implementer's rejection of it is right**. The pseudo-C
has exactly one caller of `InqDefaultGameplayOptionProperty` — line `:145204`,
inside `UIOption_Slider::SetGameplayOptionProperty @0x00485030`, the
`m_propName`-bound path. The only three `SetGameplayOptionProperty` call sites
in the whole binary are `gmChatOptionsUI::InitOptions`' two sliders
(`0x10000080`, `0x10000081` at `:170766`, `:170797`) and its filter bitfields
(`0x1000007F` at `:170826`, `:170882`). So the claim that DAT collection
`0x78000001` holds only those three Chat-tab entries is *consistent with the
decomp by construction*, and — more importantly — it is **not load-bearing**:
every Character-tab row takes the `m_propName == 0` PlayerOption branch, so
the DBProperty mechanism cannot reach this tab regardless of what the
collection contains. Had the implementer followed the plan's note, the
Defaults button would have restored nothing at all (no PlayerOption-range keys
exist in that collection).
AP-194's amendment (`docs/architecture/retail-divergence-register.md:186`)
states this truthfully: it confirms the directive (use the `ClientDefault`
column) was followed, cites the new independent trace, and names the
Chat/Config `m_propName` mechanism as the thing that does *not* apply here. ✅
### 1.3 The cross-page button-id collision is real — verified from the fixture
Counted occurrences of each element id in the committed host fixture
`tests/AcDream.App.Tests/UI/Layout/fixtures/options_panel_2100006E_1000018D.json`:
| Element | Occurrences |
|---|---|
| `0x100001FA` Character ListBox | **1** |
| `0x100001FB` Character scrollbar | **1** |
| `0x100001FC` Apply | **3** |
| `0x100001FD` Reset | **3** |
| `0x100001FE` Defaults | **3** |
| `0x10000200` Config ListBox | 1 |
Three physical instances each of Apply/Reset/Defaults (Character, Chat,
Config — matching structure doc §3.1's "identical geometry AND identical
element ids", all base `0x21000040/0x100002CD`). A flat
`ImportedLayout.FindElement` would have wired all three pages' buttons to
whichever instance the flat index happened to hold. The per-page scoped
`UiElement.FindDescendant(pageRoot, id)` in
`OptionsPanelController.cs:214-236` is the correct fix and matches retail's
own `GetChildRecursive(<the gm*UI element>, 0x100001FC)` scoping in
`PlayerOptionPage::OnOptionChanged @0x004F27D0`. The Character ListBox and
scrollbar ids are unique, so the flat lookups at
`CharacterOptionsPageController.cs:246,256` are safe. ✅
### 1.4 All six group-header keys hash to §7's byte-verified string ids
I ran `DatStringResolver.ComputeHash`'s algorithm by hand over the six
`HeaderKey` literals:
| Key | Computed | structure doc §7 |
|---|---|---|
| `ID_CharacterOption_UIBehavior_Section` | `0x06489B6E` | `0x06489B6E` ✅ |
| `ID_CharacterOption_UIDisplay_Section` | `0x0A9BC99E` | `0x0A9BC99E` ✅ |
| `ID_CharacterOption_Grouping_Section` | `0x0CBAAFAE` | `0x0CBAAFAE` ✅ |
| `ID_CharacterOption_OtherPlayers_Section` | `0x0872DFFE` | `0x0872DFFE` ✅ |
| `ID_CharacterOption_CharacterBehavior_Section` | `0x08674D5E` | `0x08674D5E` ✅ |
| `ID_CharacterOption_Chat_Section` | `0x0987FE8E` | `0x0987FE8E` ✅ |
And all 49 `ID_PlayerOption_<Name>` / `_Help` label keys the controller
constructs at `:343,352` exist verbatim as globals in the retail binary
(grepped out of `acclient_2013_pseudo_c.txt`; the truncated 32-char variants
alongside them are the known BN preview artifact). **No invented user-visible
English anywhere** — every label, tooltip and header resolves by name against
table `0x23000003`, and an unresolved string degrades to *no text*
(`:328-333`, `:346-351`) rather than a fabricated caption. ✅
### 1.5 Separator cadence, template indices, and the LED click contract
`Bind` emits header → rows → separator per group (`:264-277`), i.e. 6 headers
and 6 separators = structure doc §7's "5 interior separators + 1 trailing"
(§7's PK-death note independently confirms the trailing `AddSeperator` after
`HearSocietyChat`). Template indices 0/1/2 match §1.5's authored arrays, and
index 2 is literally the constant in `AddToggleOption`'s own
`AddItemFromTemplateList(m_pOptionBox, 2, nullptr)` at `:245616`. ✅
The LED click contract works because the toggle template's checkbox
`0x10000219` authors DAT property `0x0B` = true (fixture
`options_2100002B.json:406,428-431``UiButton.ToggleBehavior`), so
`UiButton` self-flips `_selected` at MouseUp (`UiButton.cs:486`) before the
`Click` event invokes `OnClick`, and `checkbox.OnClick = () =>
row_.SetCurrentValue(checkbox.Selected)` (`:381`) therefore reads the *new*
value. ✅ (See SF-2 — nothing pins this.)
### 1.6 Consumers
- **Display Timestamps.** Prefixed at
`RuntimeCommunicationState.AddText` (`:224-225`) — retail's
`ClientSystem::AddTextToScroll @0x00563C50` chokepoint — *after* the
both-ends trim and *after* the `ClientLocal (0x1A)` early return, which is
exactly retail's order (the CH2 doc comment on the same method already
records that retail "skips the timestamp prefix … for this type"). Format
`"H:mm:ss "` is the correct .NET spelling of `%#H:%M:%S ` (non-padded 24h
hour, padded minute/second, trailing space). Prefix applies only when the
bit is on and only to *new* lines. ✅ (See SF-1 for two caveats.)
- **Disable Distance Fog.** `WeatherSystem.Snapshot` forces
`FogMode.Off` (`WeatherState.cs:326-327`), `FogMode.Off == 0`
(`SkyState.cs:15`), the UBO carries it as `FogParams.w`
(`SceneLightingUbo.cs:130`), and both world shaders gate on it —
`mesh_modern.frag:48-49` and `terrain_modern.frag:160-161` both
`if (mode == 0) return lit;`. The shader-gate claim is real. ✅ (See N-3.)
- **Run as Default Movement.** `DispatcherMovementInputSource.cs:84`
computes `Run: (_movement.RunAsDefaultMovement != walking) || AutoRunActive`.
This is byte-correct *and better-evidenced than the commit claims*: retail's
`CommandInterpreter::SetHoldRun @0x006B3370` (`:699110-699118`) computes
`ecx = (hold_run == 0); edx_3 = (UITogglesRun() == 0); eax_3 = (ecx != edx_3)`
— an XOR of the modifier against the option, which is precisely the port's
`!=`. `ACCmdInterp::UITogglesRun @0x0058B180` (`:405377`) is
`PlayerModule::ToggleRun`, i.e. option `0x0A`, which is the id GameWindow
binds (`GameWindow.cs:676-678`). With the option ON the expression collapses
to the pre-OP4 `!walking`, so the default path is bit-identical. ✅
- **Group C re-point.** `CharacterOptionCombatSettingsSource`
(`LiveCombatAttackOperations.cs:31-63`) reads `GetOptionBit` live for
AutoTarget/AutoRepeatAttack/ViewCombatTarget and is substituted at both
construction sites (`SessionPlayerComposition.cs:770`,
`FrameRootComposition.cs:599-603`). `VividTargetingIndicator`,
`CoordinatesOnRadar`, `LockUI` and `AcceptLootPermits` likewise
(`InteractionRetainedUiComposition.cs:286-288,485-487,687-694`,
`LivePresentationComposition.cs:840-847`). Toggling writes through
`TrySetOption` (local bit first, then wire) per CH3 — verified for the two
non-panel entrances too: `ToggleUiLock` and `SetAcceptLootPermits` now both
call `SendSingleCharacterOption` before the legacy settings mirror
(`LiveSessionRuntimeFactory.cs:394-401,476-481`), which closes the two
divergences character-options-map.md §0 named. ✅
### 1.7 Register bookkeeping
TS-73's narrowing is **truthful and appropriately scoped** — it explicitly
says neither closed case lives inside `TrySetOption`'s own switch, so the
row's literal title claim stays accurate while the user-observable symptom is
recorded as fixed for `0x07`/`0x30` only.
TS-75's `ForcedDayGroupIndex` correction is **verified**:
`RuntimeWorldDayGroupDefinition(Name, ChanceOfOccur, SkyObjectCount, Sky)`
(`src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs:5-14`) is a
probability-weighted *weather-variety* group from Region `0x13000000`; the
field's only consumers are `RuntimeOptions.cs:55,122` /
`GameWindow.cs:639` env-var plumbing. Wiring `PersistentAtDay` to it would
have silently changed the character's weather odds while leaving night
falling — the row is right to call that "an incorrect fix masquerading as a
correct one". TS-76..TS-80 are each honest no-consumer rows with the correct
retail anchors; TS-80 correctly identifies `FellowshipShareXP` as the one
client-sourced option whose *packet field*, not the stored bit, is what ACE
reads. Counts are consistent. ✅
---
## 2. MUST-FIX
### MF-1 — The 50 rows are seeded once at *window startup*, before login, and never re-read the live option word
**The panel cannot display the logged-in character's options.**
Chain of evidence:
1. `CharacterOptionsPageController.Bind` reads each row's initial value once,
at build time: `bool initial = bindings.CurrentValue(spec.Id);`
`checkbox.Selected = initial;``new BoolOptionRow(initial, …)`
(`CharacterOptionsPageController.cs:368-380`). Nothing re-reads it after
that.
2. `Bind` is called from `RetailUiRuntime.MountOptionsPanel`
(`RetailUiRuntime.cs:2010`), which runs inside `Initialize()`
(`:371`) — i.e. once, when the retained UI is composed.
3. The retained UI is composed by the **startup** graph:
`GameWindow.OnLoad()` (`GameWindow.cs:1231`) →
`GameWindowCompositionPipeline.Run(… InteractionRetainedUiCompositionPhase …)`
(`:1326`). This runs at window load, before any `WorldSession` exists.
4. Pre-login, `RuntimeCharacterOptionsState` holds its constructor defaults —
`_options1 = DefaultOptions1 (0x50C4A54A)`, `_options2 = DefaultOptions2
(0x00948700)` (`RuntimeCharacterState.cs:645-647, 671-672`). The class even
carries a `_hasServerSeed` flag (`:676`) that the panel never consults.
5. So every LED shows ACE's *character-creation* default word — which is
neither the character's real state nor even retail's own Defaults column
(AP-194: the two disagree on `0x2D`/`0x2F`/`0x32`).
Consequences on the connected gate as scripted:
- Item 3 ("confirm the checkboxes reflect your character's actual state …
should show checked on open") — fails for every option the character has
ever changed.
- Item 6 ("relog … rows should still read whatever you left them at (server
echo)") — fails; the rows never see the echo.
- Items 7/8 (batched persist-vs-revert, judged by re-reading the row) — the
row is not a valid readout of persisted state.
There is no wire corruption (`TrySetOption` no-ops on an unchanged bit,
`RuntimeCharacterState.cs:812-814`, and `Reset` only touches `Changed` rows),
and the *consumers* are all correct because they poll `GetOptionBit` live.
The damage is confined to the panel's own display and baseline — but that is
the entire deliverable of this slice.
**Retail's own mechanism already fixes this, and the OP3 mechanism review
predicted it.** `docs/research/2026-08-11-op3-review-mechanism.md:424-427`
(note N2) reads: *"`SaveCurrentValue` re-reads the widget in retail.
`0x004868E5`: `m_current = vtable->GetValue(); m_saved = m_current;`.
`BoolOptionRow.SaveCurrentValue` commits its own `_current`. **Equivalent only
while every mutation goes through `SetCurrentValue`.***" OP4 is the slice that
violates that precondition — the option word has many other writers (the
PlayerDescription seed, `/consent`, `@join`, `ToggleUiLock`, headless
commands, the fellowship mutual-exclusion recursion inside `TrySetOption`
itself).
And `UIOption_Checkbox::GetValue @0x00486f60` is not a widget-local read:
character-options-map.md §1.2 establishes it branches
`m_playerOption != Invalid → PlayerModule::GetOption`, i.e. **the live
server-synced option word**. Since `OptionPage.OnShown() => Apply()`
(`OptionPageModel.cs:256`) runs on the initial default-tab activation, on
every tab switch in, and on every window (re)open, porting `SaveCurrentValue`
faithfully makes the panel self-correct on exactly retail's schedule.
**Fix.** Give `BoolOptionRow` the `GetValue` reader it is missing and use it
in `SaveCurrentValue`:
- add a `Func<bool>? read` ctor parameter (OP4 already has the exact reader:
`bindings.CurrentValue(spec.Id)`);
- `SaveCurrentValue()` becomes `_current = _read?.Invoke() ?? _current;
_saved = _current;` — retail `@0x004868E0` verbatim;
- push the re-read value onto the widget (retail's `Refresh()`), i.e. the
row's existing `apply`-side `checkbox.Selected = value` needs an
equivalent on the save path, or `BoolOptionRow` gains a `refresh` callback.
Do **not** route the re-read through `apply` — that would send the value
back on the wire.
Pin it with a test that changes the binding's underlying value *behind* the
row and asserts the row and the checkbox both converge on the next
`OnShown()`. Add a second test for the pre-login case (bind with the
constructor default word, seed the state, show the page, assert all 50 rows
match).
### MF-2 — Apply/Reset enable-gating is never wired in production
Structure doc §3.4 (`PlayerOptionPage::OnOptionChanged @0x004F27D0`):
```
eax = GetChildRecursive(<the gm*UI element>, 0x100001FC); // Apply
eax_1 = GetChildRecursive(<the gm*UI element>, 0x100001FD); // Reset
if (OptionPage::Changed(...) == 0) { SetState(0x0D) on both } // disabled
else { SetState(0x01) on both } // enabled
```
plus "`PostInit` calls `InitOptions()` then `OnOptionChanged(0)` so the
buttons start disabled", and "**Defaults (`0x100001FE`) is never
enable-gated**". §10.4's *"Behaviours that MUST be ported, not improved"*
lists this as item 3.
`OptionPage.OnOptionChanged` exists (`OptionPageModel.cs:204`), fires as the
last statement of `Apply`/`Reset`/`Defaults` (`:227,240,250`) and on every
live edit via `AttachPageNotify` (`:211`) — but **nothing in `src/` ever
assigns it**. Grep across `src/` and `tests/`: the only assignments are in
`OptionPageModelTests.cs`. OP4 is the slice that wired the three buttons
(`OptionsPanelController.cs:214-236`) and owns the first page that has them;
`OptionPageModel.cs:201-202` says in so many words "OP4-6 bind buttons to this
seam".
Everything needed is already present: `UiButtonStateMachine.Ghosted == 13`
(= retail `0x0D`) and `Normal == 1` (= `0x01`)
(`UiButtonStateMachine.cs:17,23`), and `UiButton.TrySetRetailState`
(`UiButton.cs:209-223`) already implements `Ghosted → Enabled = false` and the
inverse. `UiButton.OnEvent` already refuses clicks when `!Enabled`
(`:494`).
**Fix.** In `OptionsPanelController.Bind`'s per-page loop, capture the
resolved Apply and Reset `UiButton`s and set
`page.OnOptionChanged = () => { apply.TrySetRetailState(page.Changed ?
UiButtonStateMachine.Normal : UiButtonStateMachine.Ghosted); reset.Try…; }`,
then invoke it once at bind time so the pair starts disabled (retail's
`PostInit` tail). Leave Defaults untouched. Pin: (a) both start disabled on a
freshly-bound page, (b) one LED click enables both, (c) Apply disables both,
(d) Defaults leaves them enabled when it actually changed something and
(e) the Defaults button is never disabled.
Without this the connected gate silently accepts a missing MUST-PORT
behaviour — and the script's own "what to report" bullet ("Any Reset/Defaults
button that stays permanently disabled") reads as though the author believed
the gating shipped.
---
## 3. SHOULD-FIX
### SF-1 — Timestamp: culture-dependent separator, an over-claimed "byte-verified", and the ignored per-character format string
Three separate problems in one two-line consumer
(`RuntimeCommunicationState.cs:224-225`).
1. **Culture.** `DateTime.Now.ToString("H:mm:ss ")` uses the current culture,
and `:` in a .NET custom date/time format string is the *time-separator
placeholder*, replaced by `DateTimeFormatInfo.TimeSeparator`. On a culture
that uses `.` (fi-FI and friends) both production output and the test's
`Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} …")`
(`tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs`,
the OP4 block) change. Retail's CRT `strftime` emits a literal `:`. Use
`CultureInfo.InvariantCulture` (or escape as `H\:mm\:ss `) and make the
test culture-explicit.
2. **The "byte-verified" claim is wrong.** The doc comment at
`RuntimeCommunicationState.cs:100-104` calls `"%#H:%M:%S "` "the
byte-verified CRT strftime string". The wire oracle says the opposite:
`docs/research/2026-08-10-set-character-options-wire.md:941` open-unknown
**U6** — *"The default timestamp format string `"%#H:%M:%S "` is
BN-sourced, not byte-verified"* (and `:314-316` repeats it). The campaign's
§8 process rule is that decomp claims are byte-verified against the
PDB-paired binary; this one asserts verification the source explicitly
withholds. Either verify it (the operand decode recipe is in structure
doc's appendix) or downgrade the comment to "BN-sourced (wire doc U6)".
Note U6's own "only matters when the Chat tab implements timestamp
formatting" caveat is now stale — OP4 shipped the consumer.
3. **The per-character format is ignored.** Retail reads the format from
`GenericQualitiesData::InqString(m_pPlayerOptionsData, 1, &m_TimeStampFormat)`
(wire doc `:311-313`), and acdream's parser reads-and-discards it
(`:647`, "timestamp string (`0x80`) | read, discarded | ❌ | never sent").
Hardcoding the constructor default is defensible, but it is a divergence
with no register row. File one (or fold it into TS-77's neighbourhood).
### SF-2 — Two conformance gaps in an otherwise strong test suite
The 52 tests are good — both-directions row conformance, the independently
transcribed authored order, the `ClientDefault` pin, the tab-hide revert
against real fixtures. Two things a mechanism reviewer would want pinned:
- **The widget click path is never exercised.**
`ClickingARow_PublishesSetOption_WithTheAuthoredId`
(`CharacterOptionsPageControllerTests.cs`, "Behavioral" section) calls
`boolRow.SetCurrentValue(true)` directly. Nothing drives
`checkbox.OnClick`, so nothing pins the `ToggleBehavior` dependency the
whole LED interaction rests on (§1.5 above). If DAT property `0x0B` on
`0x10000219` ever fails to import, all 50 rows go inert and the suite stays
green. Add one test that dispatches MouseDown/MouseUp/Click at the built
checkbox and asserts both `Selected` and the published `SetOption`.
- **Only one of the seven string keys is hash-pinned.**
`HearPkDeathMessages_RetailNameHash_MatchesByteVerifiedStringId` pins
`0x0D16E9A3`. The six header keys are not pinned, even though structure doc
§7 gives all six byte-verified string ids. I verified all six by hand (§1.4)
— they are correct today — but a typo in a `HeaderKey` literal would
produce a silently blank header that only the user's eye catches. A
six-row `[Theory]` is three minutes of work.
### SF-3 — Test-script items that can generate a false failure, and items that test nothing
- **Item 8** ("Toggle a batched row WITHOUT clicking Apply, then relog. The
row should revert to its PRE-toggle value") is only true if the page is
hidden first. Structure doc §3.5: `CPlayerSystem::LogOffCharacter
@0x00563520` calls `SaveToServer(pm, 0)` — logout flushes the dirty blob.
In acdream the same holds (`GameRuntime.cs:333`, the shared flush body). A
batched toggle left un-reverted therefore *does* persist. It happens to
revert in the common path because reaching Exit Game requires switching to
the Gameplay tab (→ `OnHidden``Reset`), but a user who closes the client
another way will see the opposite of what the script promises. Add "close
the Options panel (F11) first" as an explicit step, or state both outcomes.
- **Items 10 and the "what to report" enable/disable bullets** ask the user
to judge Apply/Reset enable-gating that does not exist (MF-2). Once MF-2
lands, add an explicit step: "on first open of the Character tab, Apply and
Reset are greyed out; click any LED and they light up; click Apply and they
grey out again; Defaults is never greyed out."
- **Item 3** as written is a good check and will *correctly* fail today —
that is the MF-1 detector. Keep it exactly as-is.
Otherwise the section is thorough, states retail-expected outcomes per step,
and its "Explicitly NOT in scope" block correctly pre-empts the OP3 R1 lesson
for the nine no-consumer rows and the two weather/daylight rows.
---
## 4. NOTE
- **N-1 — `FindCheckbox` ignores the retail element id.**
`CharacterOptionsPageController.cs:384-391` returns the first `UiButton`
among the row root's direct children. Retail is
`GetChildRecursive(row, 0x10000219)` (`:245616`). Correct for today's
single-child template, but the commit *just promoted*
`UiElement.FindDescendant` for exactly this purpose — use it with the
retail id and delete the bespoke helper.
- **N-2 — `FindDescendant`'s semantics changed during promotion.**
`UiTabPanel`'s private version skipped the root; `UiElement.FindDescendant`
(`UiElement.cs:325-337`) matches the root itself first. `UiTabPanel` calls
it as `FindDescendant(this, entry.ButtonElementId/PageElementId)`
(`UiTabPanel.cs:162-163,205-208`), so a tab host whose own id collided with
a button/page id would now resolve to itself. No collision exists today
(host `0x10000208` vs buttons `0x1000020D…` and pages `0x10000211…`), but
the behaviour change rode along uncommented in a commit whose message
describes the promotion as a pure move.
- **N-3 — the sky dome still fogs when Disable Distance Fog is on.**
`sky.vert:159-162` computes `vFogFactor` from `uFogParams.xy` with no
`mode == 0` guard, unlike the two world shaders. So distant terrain stops
fading but the dome's horizon band still blends to fog colour. Retail's
`LScape::m_fFogEnabled = !value` may or may not reach its sky path — not
established either way. TS-73's "0x30 DisableDistanceFog … now behave
correctly" is therefore slightly overstated; the gate script's item 14
wisely limits the expectation to "distant terrain/objects", so this is not
a false-failure risk, only a register-precision one.
- **N-4 — AP-194's anchor column mislabels the symbol.** The prose correctly
says `UIOption_Checkbox::SetPlayerOption @0x00486e80`; the "Retail anchor"
column at the end of the row says `PlayerModule::SetPlayerOption
@0x00486e80`. Same address, wrong class — worth fixing in a register that
exists to be grepped by symbol name.
- **N-5 — no refresh on the server's own option echo.** Even after MF-1's
`GetValue` re-read lands, a change made *while the Character tab is
visible* (typing `/consent on`, a mid-session PlayerDescription, a plugin)
will not repaint the LED until the next hide/show. Retail has
`CM_UI::SendNotice_PlayerOptionChanged` / `SendNotice_RefreshOptionsPanel`
(structure doc §3.7) and acdream already has the hook —
`LiveSessionRuntimeFactory`'s `OnCharacterOptionsChanged`
(character-options-map.md §4.1). Wiring it is small; if it is deferred,
file a register row rather than leaving it implicit.
---
## 5. Fix-order recommendation
1. MF-1 (`BoolOptionRow` `GetValue` re-read + widget refresh) — blocks the gate.
2. MF-2 (enable-gating) — blocks the gate's own acceptance criteria.
3. SF-3 (script items 8/10 + the new enable-gate step) — do this *with* MF-2
so the script and the code land consistent.
4. SF-1, SF-2 — same round, cheap.
5. N-1..N-5 — coordinator's call; N-4 is a one-word register edit and N-5
wants either a wire-up or a row before OP9's reconciliation.