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>
30 KiB
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'sClientSystem::AddTextToScroll @0x00563C50chokepoint — after the both-ends trim and after theClientLocal (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.SnapshotforcesFogMode.Off(WeatherState.cs:326-327),FogMode.Off == 0(SkyState.cs:15), the UBO carries it asFogParams.w(SceneLightingUbo.cs:130), and both world shaders gate on it —mesh_modern.frag:48-49andterrain_modern.frag:160-161bothif (mode == 0) return lit;. The shader-gate claim is real. ✅ (See N-3.) - Run as Default Movement.
DispatcherMovementInputSource.cs:84computesRun: (_movement.RunAsDefaultMovement != walking) || AutoRunActive. This is byte-correct and better-evidenced than the commit claims: retail'sCommandInterpreter::SetHoldRun @0x006B3370(:699110-699118) computesecx = (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) isPlayerModule::ToggleRun, i.e. option0x0A, 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) readsGetOptionBitlive for AutoTarget/AutoRepeatAttack/ViewCombatTarget and is substituted at both construction sites (SessionPlayerComposition.cs:770,FrameRootComposition.cs:599-603).VividTargetingIndicator,CoordinatesOnRadar,LockUIandAcceptLootPermitslikewise (InteractionRetainedUiComposition.cs:286-288,485-487,687-694,LivePresentationComposition.cs:840-847). Toggling writes throughTrySetOption(local bit first, then wire) per CH3 — verified for the two non-panel entrances too:ToggleUiLockandSetAcceptLootPermitsnow both callSendSingleCharacterOptionbefore 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:
CharacterOptionsPageController.Bindreads 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.Bindis called fromRetailUiRuntime.MountOptionsPanel(RetailUiRuntime.cs:2010), which runs insideInitialize()(:371) — i.e. once, when the retained UI is composed.- The retained UI is composed by the startup graph:
GameWindow.OnLoad()(GameWindow.cs:1231) →GameWindowCompositionPipeline.Run(… InteractionRetainedUiCompositionPhase …)(:1326). This runs at window load, before anyWorldSessionexists. - Pre-login,
RuntimeCharacterOptionsStateholds its constructor defaults —_options1 = DefaultOptions1 (0x50C4A54A),_options2 = DefaultOptions2 (0x00948700)(RuntimeCharacterState.cs:645-647, 671-672). The class even carries a_hasServerSeedflag (:676) that the panel never consults. - 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>? readctor parameter (OP4 already has the exact reader:bindings.CurrentValue(spec.Id)); SaveCurrentValue()becomes_current = _read?.Invoke() ?? _current; _saved = _current;— retail@0x004868E0verbatim;- push the re-read value onto the widget (retail's
Refresh()), i.e. the row's existingapply-sidecheckbox.Selected = valueneeds an equivalent on the save path, orBoolOptionRowgains arefreshcallback. Do not route the re-read throughapply— 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 UiButtons 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).
- 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 byDateTimeFormatInfo.TimeSeparator. On a culture that uses.(fi-FI and friends) both production output and the test'sAssert.Matches(@"^\d{1,2}:\d{2}:\d{2} …")(tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs, the OP4 block) change. Retail's CRTstrftimeemits a literal:. UseCultureInfo.InvariantCulture(or escape asH\:mm\:ss) and make the test culture-explicit. - The "byte-verified" claim is wrong. The doc comment at
RuntimeCommunicationState.cs:100-104calls"%#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:941open-unknown U6 — "The default timestamp format string"%#H:%M:%S "is BN-sourced, not byte-verified" (and:314-316repeats 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. - 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) callsboolRow.SetCurrentValue(true)directly. Nothing drivescheckbox.OnClick, so nothing pins theToggleBehaviordependency the whole LED interaction rests on (§1.5 above). If DAT property0x0Bon0x10000219ever 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 bothSelectedand the publishedSetOption. - Only one of the seven string keys is hash-pinned.
HearPkDeathMessages_RetailNameHash_MatchesByteVerifiedStringIdpins0x0D16E9A3. 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 aHeaderKeyliteral 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 @0x00563520callsSaveToServer(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 —
FindCheckboxignores the retail element id.CharacterOptionsPageController.cs:384-391returns the firstUiButtonamong the row root's direct children. Retail isGetChildRecursive(row, 0x10000219)(:245616). Correct for today's single-child template, but the commit just promotedUiElement.FindDescendantfor 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.UiTabPanelcalls it asFindDescendant(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 (host0x10000208vs buttons0x1000020D…and pages0x10000211…), 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-162computesvFogFactorfromuFogParams.xywith nomode == 0guard, unlike the two world shaders. So distant terrain stops fading but the dome's horizon band still blends to fog colour. Retail'sLScape::m_fFogEnabled = !valuemay 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 saysPlayerModule::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
GetValuere-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 hasCM_UI::SendNotice_PlayerOptionChanged/SendNotice_RefreshOptionsPanel(structure doc §3.7) and acdream already has the hook —LiveSessionRuntimeFactory'sOnCharacterOptionsChanged(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
- MF-1 (
BoolOptionRowGetValuere-read + widget refresh) — blocks the gate. - MF-2 (enable-gating) — blocks the gate's own acceptance criteria.
- SF-3 (script items 8/10 + the new enable-gate step) — do this with MF-2 so the script and the code land consistent.
- SF-1, SF-2 — same round, cheap.
- 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.