acdream/docs/research/2026-08-11-op2-rereview-mechanism.md
Erik 5242de9f15 fix(ui): OP2 re-review closure (coordinator pass) — AP-195, tooltip port, zero-children pin
Closes the mechanism lens's REOPEN (one MUST-FIX) and both lenses' small
residuals on the OP2 rework (b236a442); the blast lens re-review was
CLOSED outright. Fable-direct per the two-failure escalation rule.

- AP-195 filed: UIOption_CheckboxBitfield64 ports HALF of Refresh
  @0x004859C0 — the ANY-set checkbox predicate is exact, but the ALL-set
  LED media swap (P0x10000082=0x06004D17 / P0x10000083=0x06004D19) and
  the ListBox self-sizing tail (ResizeTo/CalculatePaperSize — the block
  IS a UIElement_ListBox in retail) are unported, and the block's row
  stacking is a second divergent implementation beside UiTemplateListBox.
  All due at OP5 before the Chat tab's connected gate; the IsSet doc
  comment now names both halves instead of quoting only the ported one.
- Row tooltips: UiButton gains settable TooltipText surfaced through the
  shared GetTooltipText hover pipeline (UiCatalogSlot's pattern);
  UiCheckboxBitfield64.AddChild applies the row tooltip retail stamps in
  CreateChildren @0x00485DF0, and documents that the 0x10000084 row-index
  attribute stamp is deliberately replaced by the typed mask closure.
- AD-73 addendum: UiTemplateListBox.ConsumesDatChildren=true is inert
  only while no authored Type-5 element carries children — that premise
  is now conformance-PINNED across all 32 fixtures (a future DAT
  regeneration surfacing an authored child fails the build instead of
  silently dropping it).
- Plan doc: OP2's contract names UiTabPanel.cs (retail UIElement_Panel),
  not the fictional-class-named UiTabControl.cs; ledger records OP1 and
  OP2 both CLOSED.

Full Release suite: 12,871 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 01:26:29 +02:00

376 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Campaign OP slice OP2 rework — MECHANISM-lens FOCUSED RE-REVIEW
**Date:** 2026-08-11
**Rework under review:** `b236a442` — "fix(ui): OP2 rework — dormant UiDatElement
subclasses, fixed Panel/CheckboxBitfield64 mechanism"
**Round-1 findings being closed:** `docs/research/2026-08-11-op2-review-mechanism.md`
(verdict REJECT on `df9c7a35`)
**Mode:** READ-ONLY. No build, no test run, no launch. Every claim is a quote from
committed source, a decode of a committed fixture, or an extract from
`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
## VERDICT: **REOPEN — one MUST-FIX, narrowly scoped**
All four *behavioural* MUST-FIXes and all five SHOULD-FIXes from round 1 are
genuinely closed, with regression tests that would actually catch a relapse. This is
**not** a redo — it is a one-item fix round.
The one thing that fails is the tail of round-1 MUST-FIX 6, and it is the **same
failure mode that caused the round-1 REJECT, on the same element**: the commit
asserts a completeness claim about `0x10000520`'s authored data and about retail's
`Refresh` that the data and the decomp both refute.
- `UiCheckboxBitfield64.cs:194-201`'s doc comment quotes
`UIOption_CheckboxBitfield64::Refresh @0x004859C0` as if the ANY-bit predicate were
the whole function. It is not. `Refresh` computes **both** an all-set and an any-set
result and uses them for **two different things** (§2.2 below). The all-set half
drives a per-row LED media swap between the element's two authored RenderSurface
DIDs — `P0x10000082 = 0x06004D17` and `P0x10000083 = 0x06004D19`, both still present
in the regenerated fixture, both still unread anywhere in `src/AcDream.App`
(verified by grep: zero hits outside one prose mention).
- That unported half has **no register row**. Round-1 MUST-FIX 6 asked for exactly
this ("Also missing rows for … the dropped `0x64`/`0x10000082`/`0x10000083` authored
data"). `0x64` is now consumed; the two DIDs are not, and the register gained only
AD-73 (tab dormancy) while TS-72 was retired.
Fix = one AP row naming the unported `Refresh` media branch + the unported
`ResizeTo(GetWidth(), CalculatePaperSize(0,-1))` tail, and a corrected doc comment
that stops presenting a half-quoted `Refresh` as complete. The port itself belongs to
OP5 with the consumer; nothing is user-reachable today.
---
## 1. Finding-by-finding closure check
### 1.1 `UiTabPanel` — **CLOSED** (all five sub-checks)
| Round-1 item | Rework | Evidence |
|---|---|---|
| MUST-FIX 5 — cite `UIElement_Panel`, not the fictional `UIElement_TabControl` | ✔ | `src/AcDream.App/UI/UiTabPanel.cs:8-21` names `UIElement_Panel::Create @0x0046C6B7`, `SetupTabPageHash @0x0046C2E0`, `Update @0x0046BD00`, `OpenTab @0x0046BE20`, `InqTabFromPage @0x0046BEB0`, `ListenToElementMessage @0x0046BF90`, and explicitly records the naming correction. `DatWidgetFactory.cs:110-119` carries the same correction at the factory arm. Class renamed `UiTabControl``UiTabPanel`; the old file is deleted. |
| Switching semantics preserved | ✔ | `UiTabPanel.cs:184-199` is byte-equivalent to the round-1 reading of `UIElement_Panel::Update @0x0046BD00`: exactly one page `Visible`, every tab button driven `Open`/`Closed` via `RetailTabBinding.SetOpen``RetailUiStateIds.Open`/`Closed` = retail `0xC`/`0xB`. Nothing in this commit touched `RetailTabBinding.cs` or `RetailUiStateIds`. |
| SHOULD-FIX 8 — recursive descendant resolution | ✔ | `FindDescendant` (`UiTabPanel.cs:208-219`) is a depth-first descendant walk used for **both** the button (`:149`) and the page (`:150`, `:191`, `:194`), matching retail's `GetChildRecursive` at `0x0046C4EE` / `0x0046BD00`. `FindDirectChild` is gone. |
| SHOULD-FIX 9 — no invented `_tabs[0]` fallback | ✔ | `UiTabPanel.cs:172-173``if (defaultEntry is { } def) SwitchTo(def.PageElementId);` with no `??=`. **Independently re-verified against the decomp this round:** `SetupTabPageHash` zeroes `m_OpenTabToken`/`m_OpenPageToken` at `0x0046C312`/`0x0046C318`, seeds `ebp_1 = 0` at `0x0046C31E`, only writes `ebp_1`/`var_3c` inside the `0x32`-true branch at `0x0046C4DF`/`0x0046C4E3`, and tail-calls `Update(this, var_3c, ebp_1)` at `0x0046C5EB`. With nothing authoring `0x32`, that is `Update(0,0)` against zeroed tokens, and `Update`'s guard `arg2 != m_OpenPageToken && arg3 != m_OpenTabToken` is false → **no switch**. Confirmed. |
| SHOULD-FIX 7 — reader skips malformed entries | ✔ | `ElementReader.cs:526``if (buttonId == 0u \|\| pageId == 0u) continue;` with the retail filter cited inline at `:520-525` (`InqProperty(0x30) @0x0046C3E5`, `InqProperty(0x31) @0x0046C3FC`). I re-read both sites: a missing `0x30` jumps `label_46c510` and a missing `0x31` jumps the same, both landing on `label_46c594` (next entry) without touching either hash. Match. |
Round-1 SHOULD-FIX 3's "a silently-empty table and a wholly-unresolvable table are
indistinguishable" is also addressed: `UnresolvedEntries` (`:118`) plus a `[D.2b]`
diagnostic line (`:154-157`).
Two retail behaviours in `SetupTabPageHash` remain unported and are now *newly*
visible because the rework reads that function properly — both inert on today's data,
both NOTE (§3).
### 1.2 `UiCheckboxBitfield64` — **CLOSED on all three named sub-checks**
**(a) Rows built from the authored template via `AddItemFromTemplateList(0)`.** ✔
`UiCheckboxBitfield64.AddChild` (`:140-180`) resolves `Templates[0]` through
`TemplateResolver`, finds `0x10000219` by recursive descendant search
(`FindCheckboxRecursive`, `:182-192`), and stamps label/checked/click onto that
widget. The synthesized `ElementInfo{Type=1}` and the invented `RowHeight = 14f` are
**deleted**; `BuildCheckboxBitfield64` is deleted from the factory and the arm is now
`0x10000044u => new UiCheckboxBitfield64(info.TemplateList)`
(`DatWidgetFactory.cs:162`). Row geometry now comes from `row.Height`
(`:173-176`) — the authored 272×20.
This matches retail `CreateChildren @0x00485DF0` as I re-read it this round:
`AddItemFromTemplateList(&this->vtable, 0, nullptr)` per row → `GetChildRecursive(row,
0x10000219)``DynamicCast(1)``SetAttribute_Int(cb, 0x10000084, i)`
`SetStringInfo``SetTooltip`.
**Fixture-verified independently** (I decoded `options_2100002B.json` myself, not the
commit message): `0x10000520` is `T=0x10000044`, 272×100, **zero** children, empty
`StateMedia`, and authors exactly
`TemplateList = [{0x2100002B, 0x10000521}]`; `0x10000521` is `T=3`, 272×20, one child
`0x10000219`. The conformance test pins that exact pair
(`OptionsPanelLayoutConformanceTests` `…Bitfield64Template_BuildsAsEmptyUiCheckboxBitfield64`).
**(b) `IsSet` is retail's ANY-bit predicate.** ✔ `:200-201`
`(CurrentLow & lowMask) != 0 || (CurrentHigh & highMask) != 0`. Matches
`Refresh @0x00485A11`: `ebx = ((eax_2 | ecx_2) != 0) ? 0 : 1`
`SetAttribute_Bool(cb, 0xE, ebx == 0)`.
**(c) A multi-bit test that actually discriminates.** ✔
`UiCheckboxBitfield64_MultiBitMask_IsSet_UsesAnyBitNotAllBits` seeds current
`0x00000040` against Combat's real two-bit mask `0x00600040` and asserts
`row.Selected == true`. Under the old all-bits predicate `IsSet` returns false and
`Assert.True(row!.Selected)` fails — the test genuinely discriminates, unlike the
single-bit `Error` case it replaced (which is retained and now also pins that the row
resolves to `TemplateCheckboxElementId`). It further pins that clicking clears
**both** mask bits, i.e. the AND-NOT half.
`ToggleRow` (`:209-224`) is unchanged and remains correct: `turnOn = !IsSet(...)`
derives from the bitfield, not from `Selected`, so a `ToggleBehavior` self-toggle on
the resolved `UiButton` cannot double-flip it.
### 1.3 TS-72 retirement — **PARTIALLY CLOSED (the MUST-FIX)**
The retirement itself is legitimate. TS-72's stated unknown was the click-toggle bit
math; that is now decomp-confirmed
(`ListenToElementMessage @0x00485AE0``BitUtils::SetBitsOnOrOff @0x004857D0`:
`arg3 != 0` → OR-in both halves, else AND-NOT both halves). The retirement note at
`docs/architecture/retail-divergence-register.md:350` states plainly that the toggle
half was always right and the checked-state half was wrong and is now fixed. Accurate,
and written per register convention (struck row retained with its original text; the
section header's active count moved 41 → 40).
**What was swept:** the note ends "nothing about its own click/checked mechanism
remains genuinely unverified". Verified is not the same as ported. Reading `Refresh`
in full (§2.2) shows a decomp-confirmed, unported presentation half of the checked
mechanism, on the same element whose "authors nothing" claim caused round 1's REJECT,
with the two authored DIDs it consumes still sitting unread in the fixture. That gap
has no row anywhere in the register.
AD-73 is correct as far as it goes but is scoped to `UiTabPanel` only (§1.4).
### 1.4 The dormancy shape — **CLOSED**, with one undocumented shape change
**Overrides cannot leak pre-activation.** I enumerated every member both subclasses
add or override:
| | `UiTabPanel` | `UiTemplateListBox` |
|---|---|---|
| base | `UiDatElement` (`:79`) | `UiDatElement` (`:81`) |
| `OnDraw` | not overridden → `UiDatElement`'s authored-media draw | not overridden |
| `ClickThrough` | base ctor sets `true` (`UiDatElement.cs:138`) | same |
| `IUiDatStateful` | inherited unchanged | inherited unchanged |
| `ConsumesDatChildren` | **not** overridden → `false`, children recurse | **overridden `true`** (`:145`) ← the one change |
| `AddChild` | not overridden | not overridden |
| import-time hook | `OnChildrenAttached` is an **empty body** (`:126-128`) | none |
| side-effecting members | `ActivateTabBehavior()` / `SwitchTo()` — explicit calls only | `AddItemFromTemplateList()`; `Scroll` getter materialises the viewport (`:107`) |
`UiDatElement` itself changed only `sealed``public class` (`:43`) and `_info`
`protected readonly ElementInfo Info` (`:57`) plus mechanical renames. No behavioural
edit in the base. ✔
**The one shape change:** `UiTemplateListBox.ConsumesDatChildren => true` is *not*
identical to the pre-OP2 fallback, which recursed into dat children. I verified the
class doc's justifying claim myself by walking every committed fixture: **every**
Type-5 element in the corpus has zero authored children —
`character 0x1000023D` / `0x10000532`, `effects_positive`/`effects_negative
0x10000123`, `examine 0x10000149` / `0x10000335` / `0x1000032D`,
`mini_game 0x10000174`, `spellbook 0x10000464`, and the three Options ListBoxes
`0x100001FA` / `0x10000200` / `0x1000050D`. So the change is inert today and the claim
holds. It is a latent divergence (a future Type-5 that authors children loses them
silently), and it is not mentioned in AD-73 or anywhere in the register — NOTE, §3.
**Tab-table entry counts survived the new `0x30`/`0x31` skip** — character 3, combat
8, spellbook 2, vendor 3, options 4, i.e. the same counts round 1 recorded. The filter
is inert on real data, exactly as retail's is.
**AD-73 states the adaptation truthfully.** Register `:108`. I checked each claim:
- "Retail … unconditionally activate the authored default page for ANY instance that
carries a tab table" — correct, and I confirmed the trigger this round:
`SetupTabPageHash` has exactly one caller, `UIElement_Panel::OnSetAttribute
@0x0046C6DC`, fired when property `0x2E` is applied. Per-instance, unconditional.
- Oracle citations `SetupTabPageHash @0x0046C2E0`, `Update @0x0046BD00`,
`OpenTab @0x0046BE20` — all three verified present at those addresses in
`acclient_2013_pseudo_c.txt`.
- The four named hosts and their controller ownership match the fixture decode
(character `0x10000227` tab=3, spellbook `0x100002A8` tab=2, vendor `0x100000B8`
tab=3, combat `0x100000A2` tab=8, plus the tab-table-less vendor backdrop
`0x1000008D`).
- The Risk column names the real failure mode (a forgotten `ActivateTabBehavior()`
leaves every page slot visible at once, silently). Accurate.
AD-73 covers only `UiTabPanel`. `UiTemplateListBox`'s dormancy (lazy viewport +
`ConsumesDatChildren`) rides on the same adaptation and is unregistered — NOTE, §3.
### 1.5 Reader-level tests — **CLOSED**
`tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs:354-481` adds five tests that
build a synthetic `ElementInfo`, stuff a raw `UiPropertyValue` into
`States[DirectStateId].Properties.Values[0x2E/0x64/0x72]`, and call
`ElementReader.ApplyCanonicalLegacyProjection(info)` directly.
Would they catch what the fixture tests miss? **Yes, decisively.** The fixture tests
deserialize `ElementInfo.TabTable`/`TemplateList`/`ScrollbarElementId` as already-
projected JSON fields; `LayoutImporter.Build` never re-runs the projection at load, so
deleting the body of `ReadTabTable` would not fail one fixture test. These five drive
the reader functions themselves. Coverage:
- `ReadTabTable_DecodesButtonPageDefaultInAuthoredOrder` — struct-member ids, authored
order, `0x32` present/absent.
- `ReadTabTable_SkipsEntriesMissingButtonOrPage` — the new retail filter, with a
well-formed entry *after* two malformed ones, so an early-`break` regression fails too.
- `ReadTemplateList_DecodesLayoutDidAndElementIdInAuthoredOrder``0x63`/`0x62` pair,
order.
- `…ScrollbarLinkage_DecodesEnumProperty` / `…AbsentPropertyStaysZero` — the `0x72`
value and its absent case.
Gap (NOTE, not a miss of the round-1 finding): none of the five exercises
`ReadStructMemberId`'s `Enum`/`DataId`/`Integer` kind coercion — every test uses
`Enum`. A regression narrowing that coercion would still slip through.
The blast-radius pins (`OP2ReworkBlastRadiusConformanceTests.cs`) are the other half
of the answer: six built-widget tests across the five pre-existing Type-8 elements and
one representative Type-5, asserting `BehaviorActive == false`,
`ActivePageElementId == 0`, `ClickThrough == true`, non-zero `ActiveMedia()` on the
vendor backdrop, 16 children still present and all `Visible` on combat, and
`Empty(Children)` + `ContentHeight == 0` on the effects list. Those are exactly the
properties the dormancy model rests on.
---
## 2. New mechanism infidelity introduced or newly exposed by the rework
### 2.1 **MUST-FIX** — `Refresh`'s all-set branch is unported, its two authored DIDs are unread, and neither the doc comment nor the register says so
`UIOption_CheckboxBitfield64::Refresh @0x004859C0`, in full, per row:
```
lowHit = m_current.low & mask.low; // eax_2 @0x004859F9
highHit = m_current.high & mask.high; // ecx_2 @0x004859FB
allSet = (lowHit == mask.low && highHit == mask.high); // var_d_1 @0x004859FF..0x00485A0A
ebx = ((lowHit | highHit) != 0) ? 0 : 1; // anySet @0x00485A11
row = UIElement_ListBox::GetItem(this, i); // @0x00485A21
cb = UIElement::GetChild(row, 0x10000219); // @0x00485A35
UIElement::SetAttribute_Bool(cb, 0xE, ebx == 0); // checked <=> ANY set ← ported ✔
if (ebx == 0) { // @0x00485A51 — only when ANY set
led = UIElement::GetFirstChildElement(cb); // @0x00485A5A (0x10000328)
if (led) {
if (allSet && GetAttribute_DataID(this, 0x10000082, &v)) // @0x00485A69/0x00485A7C
UIElement::SetMediaImageForState(led, v, 1, 6); // @0x00485AA7
else if (GetAttribute_DataID(this, 0x10000083, &v)) // @0x00485A9A
UIElement::SetMediaImageForState(led, v, 1, 6);
}
}
```
Retail therefore renders **three** row states, not two: unchecked, checked-partial
(`0x10000083`), checked-complete (`0x10000082`). Eight of the thirteen Chat filter
masks are multi-bit (Gameplay `0x83912021`, Combat `0x00600040`, Magic `0x00020080`,
AreaSpeech `0x00001004`, Tells `0x00000018`, Allegiance `0x00040C00`, …), so the
partial state is reachable in ordinary play, not a corner case.
Evidence of the gap:
- `src/AcDream.App/UI/UiCheckboxBitfield64.cs:194-201` quotes `Refresh` as
`checked = (lowHit | highHit) != 0` and stops there — the `allSet` computation and
the entire media branch are absent from both the code and the citation.
- `RefreshRowVisuals` (`:226-230`) writes only `row.Toggle.Selected`.
- `grep -i '0x10000082\|0x10000083\|10000328\|0x10000084' src/AcDream.App/**/*.cs`
returns exactly **one** hit, a prose mention at `UiCheckboxBitfield64.cs:28`. The two
DIDs are read by no code path; `ElementReader` does not project them onto
`ElementInfo` either.
- They are unambiguously authored: decoded from `options_2100002B.json`, element
`0x10000520` DirectState carries `P0x10000082 = DataID(0x06004D17)` and
`P0x10000083 = DataID(0x06004D19)` alongside `P0x64`.
- `docs/architecture/retail-divergence-register.md` gained AD-73 only. No AP/TS row
names this.
Adjacent and in the same fix: retail's `CreateChildren` tail is
`ResizeTo(GetWidth(), CalculatePaperSize(0, -1))` at `0x00485E87`/`0x00485E97` — the
block sizes itself to its rows because it is a non-scrolling `UIElement_ListBox`
nested inside the Chat page's scroller. `UiCheckboxBitfield64` tracks `_contentHeight`
(`:53`, `:174-175`) but never applies it to `Height`, so the block stays at its
authored 272×100 while thirteen 20 px rows need 260 px. Also unported, also unrowed.
**Required:** one AP row covering both (unported `Refresh` media branch + unported
`ResizeTo`/`CalculatePaperSize`), naming `0x10000082`/`0x10000083`/`0x10000328` and
pointing at OP5 as the consumer gate; and a corrected `IsSet` doc comment that quotes
`Refresh` completely instead of half. The port itself can land at OP5 with the
consumer — nothing user-reachable observes it today.
### 2.2 SHOULD-FIX — `AddChild` drops retail's tooltip and per-row index stamp
`CreateChildren @0x00485E50`/`@0x00485E65` calls `SetStringInfo(cb, siLabel)` **and**
`SetTooltip(cb, siTooltip)`; `@0x00485E3E` stamps `SetAttribute_Int(cb, 0x10000084,
i)`. acdream sets `checkbox.Label` (`:169`) and stores `tooltip` in the `Row` record
(`:178`) but never applies it; there is no settable tooltip on `UiElement` (only a
virtual `GetTooltipText()` returning null, `UiElement.cs:454`), so this needs a
one-line override or a settable property. The `0x10000084` index stamp is *correctly*
replaced by a mask closure (`:171`) — equivalent and better typed; call that out in
the doc rather than leaving it silently absent.
### 2.3 SHOULD-FIX — the ListBox mechanism is now implemented twice, divergently
`UIOption_CheckboxBitfield64` **is** a `UIElement_ListBox` in retail (it calls
`AddItemFromTemplateList`/`GetItem`/`CalculatePaperSize`/`ListenToElementMessage` on
itself; `PostInit` tail-calls `UIElement_Scrollable::PostInit`). The rework's class
doc argues against reusing `UiTemplateListBox` because "retail's block does not itself
scroll" — an assertion with no citation, and one the `CalculatePaperSize`/`ResizeTo`
tail partly contradicts (the block *does* do ListBox self-sizing). The result is two
independent row-stacking implementations that already differ: `UiTemplateListBox`
stacks into a lazily-created `UiScrollablePanel`
(`UiTemplateListBox.cs:172-187`), `UiCheckboxBitfield64` stacks directly onto itself
(`:173-176`). Not a defect today; it is the seam where §2.1's `ResizeTo` gap and any
future ListBox fix will diverge. Either reuse the class or record why in the register
alongside §2.1's row.
---
## 3. NOTES
1. **`UiTemplateListBox.ConsumesDatChildren => true`** (`:145`) is the one place the
dormant subclass is not byte-identical to the `UiDatElement` fallback. Inert today
(all ten pre-existing Type-5 elements have zero authored children — verified across
every committed fixture), unregistered, and silent if a future Type-5 authors
children. One sentence in AD-73 or its own row.
2. **`UiTemplateListBox.Scroll`** (`:107`) is a side-effecting getter — reading it
materialises and attaches the viewport, breaking dormancy without any explicit
activation call. `ContentHeight` (`:111`) and `LineHeight` (`:116-124`) are both
null-safe; `Scroll` is the odd one out. Nothing reads it today.
3. **AD-73 scopes dormancy to `UiTabPanel` only.** `UiTemplateListBox`'s lazy viewport
is the same adaptation against retail's always-scrollable ListBox and rides
uncovered.
4. **`SetupTabPageHash`'s early return is unported.** At `0x0046C4EE`/`0x0046C4F5`
retail does `GetChildRecursive(this, tabId)` and, on a miss, **returns outright**
abandoning the remaining entries and never calling `Update`. `UiTabPanel` logs the
miss, keeps iterating, and still performs the default switch (`:149-173`). Inert on
today's data (every options entry resolves) and arguably the better diagnostic, but
it is a divergence the round-1 SHOULD-FIX 3 response introduced.
5. **`tab != page` and `MasterPropertyId == 0x2F` filters still unimplemented**
(`0x0046C3A6`, `0x0046C46B`). Retail adds the hash entries either way and only
gates the default-flag read and `SetMouseVisible(1)` on `tab != page`. Harmless
today. Carried from round-1 NOTE 12.
6. **`SetMouseVisible(1)` per tab button** (`0x0046C4FB`) and
**`BroadcastElementMessage(this, 0x2C, …)`** after each `Update` remain unported.
OP3 needs the second for research §3.6's apply/revert semantics. Carried from
round-1 NOTE 14.
7. **`0x71` (horizontal scrollbar) still unread**; `ScrollbarElementId` is specifically
the *vertical* link (`UIElement_Scrollable::OnSetAttribute @0x00474630`) and is
still not named as such. Carried from round-1 NOTE 13.
8. **`SaveCurrentValue`/`RestoreSavedValue`/`Changed`** (`@0x004856A0``@0x00485700`)
are unported on `UiCheckboxBitfield64` — the Apply/Cancel triad OP5 needs. Not
introduced here; `SetDefaultValue`/`RestoreDefaultValue` shipped in OP2 as-is.
9. **`SpriteResolve` and `LabelFont` are now dead** on `UiCheckboxBitfield64` — the
factory no longer sets `SpriteResolve` (the row subtree carries its own resolver)
and `LabelFont`'s own doc says it is unused (`:80-82`). Harmless; delete at OP5.
10. **The campaign plan is stale.**
`docs/plans/2026-08-10-options-panel-campaign.md:181` still says "Create
`src/AcDream.App/UI/UiTabControl.cs`" — the exact fictional-class name this round
corrected — and still cites "lane A §5" for the switching contract when the tab
table is §1.3 and its host §1.4 (round-1 NOTE 18, unaddressed). OP3/OP8 will read
that line.
11. **Reader tests use only `Enum`-kind struct members**, so `ReadStructMemberId`'s
`Enum`/`DataId`/`Integer` coercion tolerance is unpinned.
12. **`IUiChildrenAttachedListener` now has exactly one implementer with an empty
body** (`UiTabPanel.cs:126-128`). Still the right hook for OP3's activation
plumbing; just no longer load-bearing in this commit.
13. Research §1.5's "`0x10000521` … UNKNOWN consumer" / §10.5 U10 are now closed by
the fixture + `CreateChildren`, and the research doc still says UNKNOWN. Carried
from round-1 NOTE 17.
---
## 4. What I re-derived independently this round
Everything below was read from primary source, not taken from the commit message:
| Claim | Source |
|---|---|
| `SetupTabPageHash` has exactly one caller, `OnSetAttribute` on property `0x2E` | `acclient_2013_pseudo_c.txt` `0x0046C6C0``0x0046C6DC` (grep: 2 hits total for the symbol) |
| Zeroed tokens + `Update(0,0)` ⇒ no default switch | `0x0046C312`, `0x0046C318`, `0x0046C31E`, `0x0046C4DF`, `0x0046C5EB`; `Update` guard `0x0046BD00` |
| Missing `0x30`/`0x31` skips the entry | `0x0046C3E5`, `0x0046C3FC``label_46c510``label_46c594` |
| `Refresh` computes all-set AND any-set, and uses all-set for the LED media swap | `0x004859F9``0x00485AA7` (full function read) |
| `SetBitsOnOrOff` = OR-on / AND-NOT-off | `0x004857D0``0x0048580B` |
| `CreateChildren` = per-row `AddItemFromTemplateList(0)` + `GetChildRecursive(0x10000219)` + index/label/tooltip + `ResizeTo(CalculatePaperSize)` | `0x00485DF0``0x00485E97` |
| `0x10000520` authors `P0x64 = [{0x2100002B, 0x10000521}]`, `P0x10000082 = 0x06004D17`, `P0x10000083 = 0x06004D19`, zero children, empty StateMedia | decode of committed `options_2100002B.json` |
| `0x10000521` is `T=3`, 272×20, one child `0x10000219` | same |
| Every Type-5 element in every committed fixture has zero authored children | walk of all 32 committed fixtures |
| Tab-table entry counts unchanged post-regeneration (character 3 / combat 8 / spellbook 2 / vendor 3 / options 4) | same walk |
| Vendor `0x1000008D` is `T=8` with no tab table (the backdrop) | same walk |
Fixture decodes used a throwaway reader in the scratchpad. Nothing in the repository
was modified except this document.