docs: OP2 dual-lens review findings — double REJECT (re-classing blast radius)

Blast lens: the unguarded Type-8 arm re-classes five pre-existing
elements in four shipped panels (vendor backdrop sprite stops drawing;
character sheet + spellbook swallow clicks; IUiDatStateful propagation
severed), the Type-5 guard's premise is factually wrong (ten shipped
elements author non-empty 0x64), and the 27 stale fixtures make the
harness structurally blind to both — the green suite was true but
meaningless. Merge semantics, fixture provenance, hermeticity, and the
children-attached hook all verified clean.

Mechanism lens: readers decode correctly through the canonical
effective-state path; Type 8's real retail class is UIElement_Panel
(Update @0x0046BD00 — switching behavior confirmed faithful); both
claimed structural identities (UIOption_Slider = horizontal scrollbar,
UIOption_Menu = UiMenu shape) CONFIRMED from fixture fingerprints;
U10 closed (0x10000521 is the bitfield row template, consumed by
CreateChildren @0x00485DF0 via AddItemFromTemplateList(0)); TS-72 is
backwards — the toggle math is decomp-confirmed right, IsSet's all-bits
predicate is confirmed wrong (retail Refresh checks ANY mask bit).
Missing: GetChildRecursive resolution, no-0x32 means NO default switch,
SetupTabPageHash's malformed-entry filters.

Rework round 1 follows: UiTabControl/UiTemplateListBox become
UiDatElement subclasses with dormant behavior (controller-activated),
bitfield rows build from the authored template, IsSet goes any-bit,
all 32 fixtures regenerate as the acceptance gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 00:29:08 +02:00
parent df9c7a35eb
commit 26b119354d
2 changed files with 956 additions and 0 deletions

View file

@ -0,0 +1,376 @@
# OP2 review — regression / blast-radius lens
**Commit under review:** `df9c7a35` "feat(ui): Campaign OP slice OP2 — tab control,
template ListBox, UIOption widget mappings"
**Reviewer lens:** what OP2 breaks OUTSIDE its own scope (Options panel).
**Date:** 2026-08-11
**Verdict: REJECT** — two independent MUST-FIX regressions on shipped panels, and a
test harness structurally incapable of observing either.
---
## 0. Executive summary
OP2's own acceptance criterion is written into the code it added:
> `src/AcDream.App/UI/Layout/DatWidgetFactory.cs:103`
> "*(OP2 acceptance: no behavior change to any existing widget)*"
That criterion is **falsified on two independent axes**, and the justification for one
of them is a factual claim about the codebase that is demonstrably wrong:
> `DatWidgetFactory.cs:99-101` — "*an ORDINARY Type-5 ListBox that authors no template
> array (**none currently reach this factory** …) keeps falling to the generic
> UiDatElement fallback unchanged*"
**Ten** Type-5 elements reach that factory today, in five shipped panels, and **every
one of them authors a non-empty template array**. Separately, **five** Type-8 elements
in four shipped panels change widget class.
Total blast radius: **15 elements across 7 shipped, user-gated panels** — character
sheet, combat, spellbook/component book, vendor, examination, effects (positive +
negative), mini-game — silently change widget class in production.
The 12,770-test green suite is a **structural false negative**: the fixture path never
calls the code that populates the new fields, so every fixture-driven test takes the
pre-OP2 branch while production takes the new one.
This is the failure mode `docs/research/2026-08-05-c4-closeout-handoff.md` names as
binding: *a contract asserting a mechanism that does not exist*, plus *inferring a fact
you can observe*.
The new Options-panel work itself (fixtures, tab table read, template list read, the
four `UIOption_*` mappings, the 25 tests) is sound. The problem is entirely in the
un-gated widening of two `switch` arms onto pre-existing authored data.
---
## 1. Fixture-wide enumeration (the requested sweep)
Method: parsed all **32** committed fixtures under
`tests/AcDream.App.Tests/UI/Layout/fixtures/` (27 pre-existing + 5 new), walked every
`ElementInfo` node, and recorded (a) the node `Type`, (b) whether any state authors dat
property `0x2E` / `0x64` / `0x72`, and (c) whether the JSON carries the new serialized
fields.
### 1.1 Type-8 (`UIElement_TabControl`) — 6 occurrences, 5 of them pre-existing
| Fixture | Element | Children | Authors `0x2E`? | Tab entries | Pre-OP2 widget | Post-OP2 widget |
|---|---|---|---|---|---|---|
| `character_2100002E.json` | `0x10000227` (**panel ROOT**) | 7 | yes | **3** | `UiDatElement` | `UiTabControl` |
| `combat_21000073.json` | `0x100000A2` | 16 | yes | **8** | `UiDatElement` | `UiTabControl` |
| `spellbook_21000034.json` | `0x100002A8` (**panel ROOT**) | 5 | yes | **2** | `UiDatElement` | `UiTabControl` |
| `vendor_21000012_100000B7.json` | `0x100000B8` | 6 | yes | **3** | `UiDatElement` | `UiTabControl` |
| `vendor_21000012_100000B7.json` | `0x1000008D` | 0 | **no** | 0 | `UiDatElement` | `UiTabControl` |
| `options_2100002B.json` | `0x10000208` | 10 | yes | 4 | *(new)* | `UiTabControl` ✅ |
Authored tab tables actually present in the pre-existing panels (verbatim from the
fixtures, `{0x30 button, 0x31 page, 0x32 isDefault}`):
- character `0x10000227`: `(0x10000228→0x1000022B, default)`, `(0x10000229→0x1000022C)`,
`(0x100003F8→0x100003F9)`
- combat `0x100000A2`: 8 rows, `(0x10000063→0x1000006A, default)``(0x10000382→0x10000383)`
- spellbook `0x100002A8`: `(0x100002A9→0x100002AC, default)`, `(0x100002AA→0x100002AD)`
- vendor `0x100000B8`: `(0x100000B9→0x1000007C, default)`, `(0x100000BA→0x10000084)`,
`(0x100000BB→0x1000008D)`
### 1.2 Type-5 (`UIElement_ListBox`) — 16 occurrences, 10 of them pre-existing
**Every pre-existing Type-5 element authors a NON-EMPTY property `0x64`.**
| Fixture | Element | `0x64` rows | `0x72` | Controller constant | Post-OP2 (production) |
|---|---|---|---|---|---|
| `character_2100002E.json` | `0x1000023D` ×2 | **5** | `0x1000023E` | `CharacterStatController.ListBoxId` | `UiTemplateListBox` |
| `character_2100002E.json` | `0x10000532` | **1** | `0x10000533` | — | `UiTemplateListBox` |
| `effects_positive_2100001B.json` | `0x10000123` | **1** | `0x10000124` | `EffectsUiController.ListId` | `UiTemplateListBox` |
| `effects_negative_2100001B.json` | `0x10000123` | **1** | `0x10000124` | `EffectsUiController.ListId` | `UiTemplateListBox` |
| `examine_2100006B_100005F2.json` | `0x10000149` | **1** | — | `AppraisalUiController.CreatureStatsListId` | `UiTemplateListBox` |
| `examine_2100006B_100005F2.json` | `0x1000032D` | **1** | — | `AppraisalUiController.SpellFormulaListId` | `UiTemplateListBox` |
| `examine_2100006B_100005F2.json` | `0x10000335` | **1** | — | `AppraisalUiController.CreatureExtraListId` | `UiTemplateListBox` |
| `mini_game_2100001E.json` | `0x10000174` | **1** | — | — | `UiTemplateListBox` |
| `spellbook_21000034.json` | `0x10000464` | **2** | `0x10000465` | `SpellbookWindowController.ComponentListId` | `UiTemplateListBox` |
| `options_*` (3 new) | `0x100001FA` / `0x10000200` / `0x1000050D` | 3 / 8 / 9 | yes | *(new)* | `UiTemplateListBox` ✅ |
### 1.3 The four `0x100000xx` UIOption class ids — **zero** pre-existing occurrences
| Class id | Occurrences | Files |
|---|---|---|
| `0x10000036` CheckboxSlider | 2 | `options_2100002B.json` only |
| `0x10000037` Slider | 4 | `options_2100002B.json` only |
| `0x10000038` Menu | 1 | `options_2100002B.json` only |
| `0x10000044` CheckboxBitfield64 | 1 | `options_2100002B.json` only |
**Clean.** These four arms are the only genuinely additive part of the switch change.
No pre-existing layout carries any of them.
### 1.4 New-field presence per fixture (fixture-provenance check)
`System.Text.Json` with `IncludeFields = true` serializes *every* public field, so a
fixture generated by the current generator necessarily contains `Outline`,
`OutlineColor`, `TabTable`, `TemplateList`, and `ScrollbarElementId` on every node.
| Group | Files | `Outline`/`OutlineColor` | `TabTable`/`TemplateList`/`ScrollbarElementId` |
|---|---|---|---|
| The 5 new Options fixtures | `options_2100002B`, `options_gameplay_2100002A`, `options_character_21000028`, `options_chat_2100005C`, `options_config_21000029` | **present** | **present** |
| `chat_2100006f.json` | regenerated at CH6a | present | absent |
| The other 26 pre-existing | all remaining | **absent** | **absent** |
---
## 2. MUST-FIX findings
### MUST-FIX 1 — Type-8 arm silently re-classes 5 elements in 4 shipped panels
**Site:** `src/AcDream.App/UI/Layout/DatWidgetFactory.cs:110`
```csharp
8 => new UiTabControl(info.TabTable),
```
Unlike the Type-5 arm, this one carries **no guard at all** — every Type-8 element in
every layout now becomes a `UiTabControl`.
`UiTabControl` (`src/AcDream.App/UI/UiTabControl.cs:42`) derives from bare `UiElement`.
`UiDatElement` (`src/AcDream.App/UI/Layout/UiDatElement.cs:31`) is
`UiElement, IUiDatStateful` and its constructor sets `ClickThrough = true`
(`UiDatElement.cs:111`). Four independent consequences:
**(a) Lost background art — a straight rendering regression.**
Vendor `0x1000008D` is 800×20 and authors a DirectState sprite:
`StateMedia[""] = (100687632, DrawMode 1)`. `RetailUiRuntime.cs:2287` documents it by
name: *"backdrop element `0x1000008D` provides its own fill"*. It has **zero children**
and **no `0x2E` property** — it is pure chrome that happens to carry retail Type 8.
`UiTabControl` has no media plumbing whatsoever, so **the vendor panel's backdrop fill
stops drawing.** This is the clearest single defect in the commit.
**(b) `ClickThrough` inverts `true → false` on all five.**
`UiElement.ClickThrough` is an auto-property defaulting to `false`
(`UiElement.cs:159`); `UiElement.HitTest` gates on it at `UiElement.cs:582-583`
(`if (ClickThrough) return null; return OnHitTest(...) ? this : null;`), and
`OnHitTest` defaults to a plain bounds test (`UiElement.cs:421-422`). Two of the five
are **panel roots** (character `0x10000227`, spellbook `0x100002A8`), so the entire
character sheet and the entire spellbook now claim every pointer hit inside their
bounds instead of passing unclaimed clicks through to the window shell behind them.
**(c) Lost `IUiDatStateful` state propagation.**
`LayoutImporter.cs:168-169` runs `stateful.TrySetRetailState(...)` only for
`IUiDatStateful` widgets; `UiDatElement.TrySetRetailState` recurses into children
(`UiDatElement.cs:98-99`). `UiTabControl` does not implement the interface, so the
authored default-state propagation into the 7 / 16 / 5 / 6 children of these four tab
hosts silently stops. This is the exact mechanism the comment at
`LayoutImporter.cs:164-167` says the hook exists to preserve.
**(d) Import-time visibility + click rebinding on controller-owned tabs.**
`UiTabControl.OnChildrenAttached` (`UiTabControl.cs:65-81`) runs *during*
`LayoutImporter.Build`, before any controller wiring, and calls
`RetailTabBinding.SetClick` (which also sets `ClickThrough = false` on each tab button,
`RetailTabBinding.cs:16`) plus `SwitchTo(default)``page.Visible = active` and
`RetailTabBinding.SetOpen`. All four panels already own these tabs
(`CharacterStatController.cs:428-432`, `SpellbookWindowController.cs:138-139`,
`VendorUiController.cs:587-589`).
Mitigating detail, and it is *luck*, not design: on 3 of the 4 panels the tab table's
ids do **not** match the resolved child ids, so `FindDirectChild` misses and `SwitchTo`
is inert — vendor's table names pages `0x1000007C/0x10000084/0x1000008D` while the
controller and the actual children use `0x100000BC/0x100000C4/0x100000CD`; combat's
table names `0x1000006A..0x10000383` while the children are `0x100000AA..0x100005C3`.
Only spellbook and character resolve, and both controllers happen to re-assert
visibility afterwards. **Nothing in the commit establishes or tests this ordering.**
Worth flagging on its own: `CharacterStatController.cs:439-449` carries an explicit
comment — *"WHY this cannot be done in the importer … The controller is the correct and
only place for initial tab-page selection"* — that OP2 now contradicts for four panels
without amending it.
### MUST-FIX 2 — Type-5 guard rests on a false premise; 10 elements flip in production
**Site:** `src/AcDream.App/UI/Layout/DatWidgetFactory.cs:104-105`
```csharp
5 when info.TemplateList.Count > 0 => new UiTemplateListBox(
info.TemplateList, info.ScrollbarElementId),
```
The guard is correct *in the fixture path* and useless *in production*, because
`info.TemplateList` is populated by different code on the two paths:
- **Fixture path:** `FixtureLoader.LoadInfos` (`FixtureLoader.cs:253-265`) deserializes
straight into `ElementInfo` and hands it to `LayoutImporter.Build`.
`ApplyCanonicalLegacyProjection` is **never called**. Stale fixtures carry no
`TemplateList` key → the field initializer's empty list survives → guard is `false`
→ old `UiDatElement` behavior. Tests are green.
- **Production path:** `LayoutImporter.ToInfo` calls
`ElementReader.ApplyCanonicalLegacyProjection(info)` (`LayoutImporter.cs:465`), which
runs `info.TemplateList = ReadTemplateList(info)` from property `0x64`. Guard is
`true` for all ten → `UiTemplateListBox`.
Section 1.2 shows all ten pre-existing Type-5 elements author 15 valid
`{0x63 layoutDid, 0x62 elementId}` rows. Six of them are named controller constants
(`AppraisalUiController.CreatureStatsListId` / `CreatureExtraListId` /
`SpellFormulaListId`, `CharacterStatController.ListBoxId`, `EffectsUiController.ListId`,
`SpellbookWindowController.ComponentListId`).
Consequences: same `IUiDatStateful` loss and same media loss as MUST-FIX 1, plus a
**spurious child injection** — `UiTemplateListBox`'s constructor unconditionally does
`base.AddChild(_viewport)` (`UiTemplateListBox.cs:94`), so every one of these ten list
boxes gains a full-size anchored `UiScrollablePanel` as its first child. Since
`UiPanel` defaults `ClickThrough = false` (confirmed by the comment at
`UiPanel.cs:157-159`), that transparent viewport participates in hit-testing across the
whole list-box rect, competing with the `UiItemList` each controller adds afterwards on
an untested Z-order tie-break.
Mitigating: the consumers all use `FindElement(...)` returning `UiElement?` with an
`is UiItemList` test plus an else-branch (`EffectsUiController.cs:90-94`,
`SpellbookWindowController.cs:196-199`, `AppraisalUiController.cs:169`,
`:241-243`, `:313-315`), so no controller *fails to mount*. The damage is confined to
rendering, hit-testing, and state propagation — but it is unreviewed and unmeasured.
### MUST-FIX 3 — the harness cannot observe either change (the root enabler)
`ApplyCanonicalLegacyProjection` has exactly two call sites, both DAT-only:
`LayoutImporter.cs:465` (`ToInfo`) and `ElementReader.cs:410` (`Merge`). The fixture
path reaches neither. Therefore:
- Every fixture-driven test exercises the **pre-OP2** branch for all 15 affected
elements while production exercises the **post-OP2** branch. The "12,770 passed /
0 failed, zero regressions" claim in the commit message is true and meaningless for
this change.
- The one existing test that touches an affected element,
`RetailFixtureConformanceTests.cs:100`
(`{ FixtureLoader.LoadCharacterInfos, 0x10000227u, 8u, 300f, 600f, 60 }`), asserts the
**`ElementInfo.Type`**, not the built widget class — it already knew `0x10000227` is
Type 8 and still could not catch this.
- `RetailLayoutFixtureGenerator.cs` now regenerates **all 32** layouts. The moment the
coordinator runs `ACDREAM_REGENERATE_UI_FIXTURES=1`, the 27 stale fixtures gain
populated `TabTable`/`TemplateList`, and the suite flips to production shapes for all
15 elements in one commit, with no test written to explain the diff.
Answering the task's forward-looking question directly: **no committed test depends on
fields present only in new-shape fixtures.** The exposure is the inverse and worse —
tests currently depend on those fields being *absent*.
**Minimum fix shape (recommendation, not a mandate):** narrow both arms so they cannot
reach a pre-existing element — e.g. gate on the Options LayoutDesc ids, or on the
authored-tab-table ids actually resolving to direct children, or (cleanest) require
`info.TabTable.Count > 0 && every ButtonElementId/PageElementId resolves` before
choosing `UiTabControl`, and make `UiTabControl`/`UiTemplateListBox` carry the same
media + `IUiDatStateful` behavior `UiDatElement` provides. Whatever the shape, land a
test that builds the four affected pre-existing layouts **through the DAT-path
projection** (call `ApplyCanonicalLegacyProjection` over the fixture tree before
`Build`) and asserts the resolved widget class for all 15 ids.
---
## 3. SHOULD-FIX
### SHOULD-FIX 1 — `UiTemplateListBox` injects a hit-testable viewport unconditionally
`UiTemplateListBox.cs:94` (`base.AddChild(_viewport)`) runs in the constructor, before
`LayoutImporter.BuildWidget` appends the element's own dat children. The widget does not
set `ConsumesDatChildren`, so authored children become **siblings of the viewport rather
than rows inside it** — silently correct today only because all three Options ListBoxes
author zero children, and a latent trap for OP3+ the moment one doesn't. Combined with
`UiPanel`'s `ClickThrough = false` default this is the mechanism behind MUST-FIX 2's
hit-testing exposure.
### SHOULD-FIX 2 — the tab table's ids are base-layout ids on inherited layouts
`UiTabControl.FindDirectChild` (`UiTabControl.cs:105-111`) matches
`child.DatElementId` against the raw `0x2E` values. On three of the four pre-existing
panels those values do not correspond to any child in the *incorporated* tree (§2,
MUST-FIX 1(d)) — strongly suggesting property `0x2E` carries **base-layout** ids that
`LayoutImporter.IncorporateChildren` remaps. The Options panel happens to author its
tab table with post-incorporation ids, so OP2's own tests pass. This will bite OP3+
directly, because the Options pages mount cross-layout via `BaseLayoutId`/`BaseElement`
— exactly the case where the remap applies. Worth resolving now rather than
rediscovering it as an OP4 mystery.
### SHOULD-FIX 3 — silent no-resolve is indistinguishable from correct behavior
`OnChildrenAttached` and `SwitchTo` both treat a `FindDirectChild` miss as a no-op
(`UiTabControl.cs:70-72`, `:95-99`). A tab control whose entire table fails to resolve
(combat, today) is behaviorally identical to one with no table at all. Given
SHOULD-FIX 2, a diagnostic line on unresolved entries would have surfaced this during
implementation.
---
## 4. NOTEs (verified clean, or informational)
**NOTE 1 — `ElementReader.Merge` handles the three new fields correctly.** Verified.
`Merge` (`ElementReader.cs:344-412`) does *not* add scalar merge rules for `TabTable`,
`TemplateList`, or `ScrollbarElementId`, and does not need to: it merges the **state
property tables** first (`:400-408`, `UiStateInfo.Merge` giving derived-wins per
property) and then calls `ApplyCanonicalLegacyProjection(m)` at `:410`, which recomputes
all three from the merged effective state. Base-only `0x64` survives; derived `0x64`
replaces base wholesale (correct — arrays are whole-value properties, not concatenated);
neither is doubled. **No existing `Merge` behavior changed** — the new field reads are
appended after the existing `Outline`/`OutlineColor` block in
`ApplyCanonicalLegacyProjection` and touch no prior line. This addresses focus point 2
in full: nothing here can drop or double a template list for the OP3+ cross-layout
mounts.
**NOTE 2 — fixture-drift handling is exactly as described.** `git show --stat df9c7a35`
lists 5 fixture files, all `A` (added), zero `M`. The 27 pre-existing fixtures are
byte-untouched by this commit. And per §1.4 the 5 new fixtures **do** carry
`Outline`/`OutlineColor` (plus the three new fields), proving they came from the current
generator rather than being hand-built or stale-sourced. This focus-3 MUST-FIX condition
is **satisfied**.
**NOTE 3 — the 25 new tests are hermetic.**
`OptionsPanelLayoutConformanceTests.cs` contains exactly 25 `[Fact]`s (matching the
commit message) and zero `IDatReaderWriter` / `DatCollection` references; every test
loads a committed fixture through `FixtureLoader`. The generator is env-gated
(`RetailLayoutFixtureGenerator.cs:42-50`, early-returns unless
`ACDREAM_REGENERATE_UI_FIXTURES == "1"`), so it is inert on a machine without DATs. The
csproj copies fixtures by wildcard (`AcDream.App.Tests.csproj:28`,
`UI\Layout\fixtures\*.json`), so the five new files need no registration. **The suite
remains green without the machine DAT.** Focus point 5: clean.
**NOTE 4 — `IUiChildrenAttachedListener` cannot double-fire.** The hook is invoked
exactly once per widget instance, at `LayoutImporter.cs:174-175`, immediately after that
widget's own child loop and before `BuildWidget` returns. Depth-first ordering means a
nested listener fires before its ancestor, which is the correct order for
cross-reference resolution. Nested `LayoutImporter.Build` calls from
`UiTemplateListBox.TemplateResolver` create fresh widget instances, so no re-entry
hazard. Focus point 4's "fire twice / wrong subtree" concern: **not present.**
**NOTE 5 — wheel / drag / clipping forwarding is structurally fine.** Because
`_viewport` is anchored `Left|Top|Right|Bottom` and `UiElement.HitTest` walks children
before gating on the parent (`UiElement.cs:571-583`), pointer and wheel events land on
the `UiScrollablePanel` directly — no forwarding shim is needed, and the scroll model,
per-row clipping, and `LineHeight` quantum are the same `UiScrollablePanel` code the
character sheet and CH6 already exercise. The anchor-capture behavior for a viewport
constructed at 0×0 inside a sized host is *not* covered by any of the 25 tests; low risk,
but unmeasured.
**NOTE 6 — `UiOptionToggleSlider` first-match binding.**
`UiOptionToggleSlider.OnChildrenAttached` (`UiOptionToggleSlider.cs:33-42`) binds the
first `UiButton` and first `UiScrollbar` child. The `else if` is harmless (a child cannot
be both types). Zero pre-existing layouts carry class `0x10000036`, so no blast radius —
noted only because the pattern silently picks the first match rather than the authored
`0x10000219` / `0x1000021C` ids the class doc cites.
**NOTE 7 — per-element allocation in the hot projection path.**
`ApplyCanonicalLegacyProjection` now allocates two fresh `List<>` instances
(`ReadTabTable`, `ReadTemplateList`, `ElementReader.cs:490-497`) for **every element on
every call**, including the ~99% that author neither property, and `Merge` calls it once
per inheritance level. Import is startup-only and not on a frame path, so this is
cosmetic — but an early `TryGetEffectiveProperty` bail before allocating would be free.
**NOTE 8 — TS-72 is correctly filed.** The register hunk
(`docs/architecture/retail-divergence-register.md`) adds TS-72 for
`UiCheckboxBitfield64`'s click-toggle bit math and bumps the count 40 → 41. Correct per
the register rules; the widget is genuinely unreachable today. No comment on the bit
math itself — outside this lens.
---
## 5. Verdict
**REJECT.**
The additive half of OP2 (five fixtures, the three `ElementReader` property readers, the
`Merge` interaction, the `IUiChildrenAttachedListener` hook, the four `UIOption_*` class
ids, the 25 hermetic tests) is well-built and clean under this lens. But two `switch`
arms were widened onto authored data the commit believed was absent, changing the widget
class of 15 elements across 7 shipped, user-gated panels — including a documented
rendering backdrop that now draws nothing — and the harness that reported "zero
regressions" is structurally incapable of seeing any of it. The re-review gate should be
the DAT-path projection test described at the end of §2.

View file

@ -0,0 +1,580 @@
# Campaign OP slice OP2 — MECHANISM-FAITHFULNESS review
**Date:** 2026-08-11
**Commit under review:** `df9c7a35` — "feat(ui): Campaign OP slice OP2 — tab control,
template ListBox, UIOption widget mappings"
**Lens:** retail-mechanism fidelity + contract fidelity against
`docs/plans/2026-08-10-options-panel-campaign.md` §4 OP2 and
`docs/research/2026-08-10-options-panel-structure.md`.
**Mode:** READ-ONLY. No build, no test run, no launch. Every claim below is either 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: **REJECT**
Two independent MUST-FIX classes, each with hard evidence:
1. **The unconditional `Type 8` mapping (and the `Type 5` mapping) silently rewires four
already-shipped windows and nine already-shipped list boxes at runtime.** The committed
test suite cannot see it because the 27 pre-existing fixtures were deliberately NOT
regenerated, so they deserialize with `TabTable`/`TemplateList` empty. One
already-committed test (`EffectsUiControllerTests.cs:135-137`) encodes the pre-OP2
contract and would fail against real DAT data. The slice's own acceptance criterion
("no change to any existing widget's behaviour") is false as landed.
2. **The retail mechanism for both new widgets is fully present in the named decomp and
was not consulted.** `UIElement_Panel::Update/OpenTab/SetupTabPageHash` (Type 8 is
`UIElement_Panel`, **not** a class called `UIElement_TabControl` — that class does not
exist in the PDB) and `UIOption_CheckboxBitfield64::CreateChildren/ListenToElementMessage/
Refresh` are all decompiled. Reading them confirms most of the port, refutes
`UiCheckboxBitfield64`'s checked-state predicate, refutes its "the dat authors nothing"
premise, and makes TS-72's stated unknown a non-unknown.
The parts the prompt asked me to independently verify — the two structural identities, the
tab-table decode, the template-list decode, the effective-state discipline, the conformance
pins — are **all correct**. This is a REJECT on blast radius and on two mechanism defects,
not on the Options-panel data work, which is clean.
---
## 1. Property readers (0x2E / 0x64 / 0x72)
### 1.1 Tab table `0x2E` — decode CORRECT, entry filtering DIVERGES
`ElementReader.ReadTabTable` (`src/AcDream.App/UI/Layout/ElementReader.cs:508-527`) decodes
`{0x30 button, 0x31 page, 0x32 isDefault}` and preserves authored array order.
Independently decoded from the committed fixture
`tests/AcDream.App.Tests/UI/Layout/fixtures/options_2100002B.json`, element `0x10000208`
DirectState:
```
P0x58 = Enum(0x1)
P0x2E = Array[4]
Struct{0x30=Enum(0x1000020D), 0x31=Enum(0x10000212), 0x32=Bool(True)}
Struct{0x30=Enum(0x1000020E), 0x31=Enum(0x10000211), 0x32=Bool(False)}
Struct{0x30=Enum(0x1000050B), 0x31=Enum(0x1000050C), 0x32=Bool(False)}
Struct{0x30=Enum(0x1000020F), 0x31=Enum(0x10000213), 0x32=Bool(False)}
P0x57 = Enum(0x1000001A)
```
This matches research §1.3 / §10.1 exactly (Gameplay default, entry 0; Chat third at
`0x1000050B`/`0x1000050C`). The `Enum`/`DataId`/`Integer` coercion in `ReadStructMemberId`
(`:554-564`) is the right tolerance — the authored kind here is `Enum`.
Retail's own reader is `UIElement_Panel::SetupTabPageHash @0x0046C2E0`. Three filters
acdream does not implement:
| Retail | acdream | Impact |
|---|---|---|
| `BaseProperty::GetPropertyName(entry) != 0x2F`**skip entry** (`0x0046C3A6`) | any `Struct` accepted | NOTE — every authored entry is `0x2F` |
| `InqProperty(0x30)` or `InqProperty(0x31)` missing → **skip entry** (`0x0046C3E5`, `0x0046C3FC`) | entry added with `0` for the missing member | **SHOULD-FIX** — a partial entry yields `UiTabTableEntry(0, pageId, …)` that still participates in `SwitchTo` and can hide a page |
| `tab != page` required (`0x0046C46B`) | not checked | NOTE |
### 1.2 Template list `0x64` — decode EXACT
`ReadTemplateList` (`:529-545`) decodes `{0x63 layout DID, 0x62 element id}`, in that order.
Retail: `UIElement_ListBox::AddItem(BaseProperty*) @0x0046F170` reads
`InqStructElement(0x63)` then `InqStructElement(0x62)` and calls
`AddItem(did, elementId, parent)`. **Byte-for-byte the same pair, same order.**
Decoded from the committed fixtures (all three arrays, verbatim):
```
0x100001FA (Character) P0x64 = 3 × {0x2100002B, 0x10000216 / 0x10000217 / 0x10000218}
0x10000200 (Config) P0x64 = 8 × {0x2100002B, 216,217,218,21A,222,220,21D,221}
0x1000050D (Chat) P0x64 = 9 × {…same 8…, 0x10000520}
```
Identical to research §1.5's table. ✔
### 1.3 Scrollbar `0x72` — correct value, imprecise semantics, missing sibling
`ReadReferencedElementId(info, 0x72)` (`:569-579`) is correct: the authored kind is `Enum`
and retail reads it with `InqEnum`.
Exact retail semantics, from `UIElement_Scrollable::OnSetAttribute @0x00474630` (not cited
anywhere in the commit):
```
0x71 -> m_eHorizonalScrollbarID (+ UpdateScrollbarSize_(1))
0x72 -> m_eVerticalScrollbarID (+ UpdateScrollbarSize_(0))
```
resolution is `UIElement::FindRelative(this, id)` validated by `DynamicCast(0xB)` and a
mutual-relative check (`GetScrollbarPointer_ @0x00473EC0`).
- **NOTE:** `ElementInfo.ScrollbarElementId` / `UiTemplateListBox.ScrollbarElementId` should
be named *vertical*; `0x71` (horizontal) is unread. `0x71` IS authored in already-committed
fixtures (`vendor_…` `0x100000BD`/`0x100000C5`/`0x100000CE`, `combat_…` `0x100000B6`) — not
on the Options ListBoxes, so no OP2-observable gap, but the asymmetry deserves a comment.
### 1.4 The round-5 N1 lesson — CORRECTLY OBSERVED
All three readers resolve through `ElementInfo.TryGetEffectiveProperty` (`:215-235`), which
is DirectState-as-base then effective-default override — **not** a per-state first-wins scan.
`ApplyCanonicalLegacyProjection` is invoked from both entry points
(`LayoutImporter.ToInfo` and `ElementReader.Merge:410`), and the three new fields are
*recomputed* from the merged state rather than scalar-merged, which is strictly correct.
No N1 reintroduction. ✔
- **SHOULD-FIX (coverage):** the new conformance tests read `TabTable`/`TemplateList`/
`ScrollbarElementId` off the **serialized** fixture fields — `LayoutImporter.Build` does not
re-run the projection at load. So `ReadTabTable`/`ReadTemplateList` are exercised only at
fixture-generation time and a future regression in either reader would not fail a single
test. One synthetic `ElementInfo` + `States[Direct].Properties[0x2E]` unit test per reader
closes this for ~20 lines.
---
## 2. `UiTabControl` — right behaviour, wrong class, wrong lookup, invented fallback
**The core behaviour is confirmed against retail.** `UIElement_Panel::Update @0x0046BD00`:
```
if (arg2 != m_OpenPageToken && arg3 != m_OpenTabToken) {
m_OpenTabToken = arg3; m_OpenPageToken = arg2;
foreach (tabId -> pageId) in m_TabPageHash {
visible = 0; state = 0xB; // Closed
if (m_OpenTabToken == tabId && m_OpenPageToken == pageId) { visible = 1; state = 0xC; } // Open
page = GetChildRecursive(this, pageId); if (page) page->SetVisible(visible);
tab = GetChildRecursive(this, tabId); if (tab) tab->SetState(state);
}
UIElement::BroadcastElementMessage(this, 0x2C, 0, 0);
}
```
`UiTabControl.SwitchTo` (`src/AcDream.App/UI/UiTabControl.cs:88-103`) does exactly this:
exactly one page `Visible`, every tab button driven to `Open`/`Closed` via
`RetailTabBinding.SetOpen``RetailUiStateIds.Open = 12` / `Closed = 11`
(`src/AcDream.App/UI/IUiDatStateful.cs:15-16`) = retail's `0xC`/`0xB`. ✔ The authored data
corroborates independently: the four tab buttons are `Type 0xC` text elements with
`DefaultStateId = 11 (Closed)` and authored state-11 grey `ARGB(255,127,127,127)` vs state-12
`ARGB(255,204,204,204)` font colours.
**The authored default IS honoured on mount** — `OnChildrenAttached` (`:65-81`) picks the
`IsDefault` entry and `SwitchTo`s it, and the fixture proves entry 0 (Gameplay,
`0x1000020D`/`0x10000212`) is the sole default. Retail takes the last `0x32==true` entry;
acdream's loop does too. ✔
Four defects:
- **MUST-FIX (citation).** The class is documented as retail `UIElement_TabControl`
(`UiTabControl.cs:7`, `DatWidgetFactory.cs:108-109`). **No such class exists**
`grep UIElement_TabControl docs/research/named-retail/symbols.json` returns nothing. Type 8
is `UIElement_Panel` (`UIElement::RegisterElementClass(8, UIElement_Panel::Create)` at
`0x0046C6B7`). CLAUDE.md's phase checklist requires a named symbol + address; citing a
fictional class is what let items 24 below through. Correct anchors:
`UIElement_Panel::SetupTabPageHash @0x0046C2E0`, `::Update @0x0046BD00`,
`::OpenTab @0x0046BE20`, `::InqTabFromPage @0x0046BEB0`,
`::ListenToElementMessage @0x0046BF90`.
- **SHOULD-FIX.** `FindDirectChild` (`:105-111`) scans immediate children only; retail uses
`GetChildRecursive` for **both** the tab and the page. Fine for `0x2100002B` (all direct),
wrong for the Configure Keyboard tab host `0x1000049B` (research §8) this widget is
explicitly meant to also serve.
- **SHOULD-FIX.** `defaultEntry ??= _tabs[0]` (`:78`) is invented. Retail leaves
`m_OpenPageToken/m_OpenTabToken` at 0 when no entry authors `0x32`, so `Update(0,0)` hits the
guard and **no switch happens at all**. Either match retail or file a register row.
- **NOTE.** Retail's `BroadcastElementMessage(this, 0x2C, …)` after each switch, its
`SetMouseVisible(1)` per tab button during setup (`0x0046C4FB`), and its
`ListenToElementMessage` handling of `idMessage 0x18` (a page becoming visible re-opens its
tab; the panel follows its open page's visibility) are unported. Retail also drives
switching from a child element message (`0x19`/`0x29``OpenTab`) rather than a per-button
click delegate — acdream's `RetailTabBinding.SetClick` adaptation is consistent with the
existing character/vendor/spellbook tabs, but it is an adaptation and OP3 will need the
`0x2C` notification for the `OnVisibilityChanged` apply/revert semantics (research §3.6).
### 2.1 **MUST-FIX — the Type-8 arm is unconditional and rewires four shipped windows**
`DatWidgetFactory.cs:110` maps **every** Type-8 element to `UiTabControl`. Type 8 =
`UIElement_Panel`; a Panel without a `0x2E` array is just a panel. Two consequences, both
regressions relative to the previous `_ => new UiDatElement(info, resolve)` fallback:
* `UiTabControl : UiElement` has **no `OnDraw`** — the authored DirectState media is not drawn
(`UiDatElement.OnDraw`, `src/AcDream.App/UI/Layout/UiDatElement.cs:193-216`).
* `UiElement.ClickThrough` defaults **false**; `UiDatElement` sets it **true**
(`UiDatElement.cs:111`). Every converted panel now swallows clicks that miss a child.
I decoded the raw `States` bag of every committed fixture (the raw property survives even
though the projected field does not). Pre-existing Type-8 elements:
| Fixture | Element | Size | Authored `P0x2E` | Media | Consequence |
|---|---|---|---|---|---|
| `vendor_21000012_100000B7.json` | `0x1000008D` | 800×20 | **none** | **yes** (`0x06004…`, DirectState) | loses its fill; `RetailUiRuntime.cs:2287` comments that this element "provides its own fill" |
| `vendor_…` | `0x100000B8` | 800×110 | 3 entries (`B9/BC`, `BA/C4`, `BB/CD`) | no | live tab control fights `VendorUiController` |
| `character_2100002E.json` | `0x10000227` (window root) | 300×600 | 3 entries (`228/22B`, `229/22C`, `538/539`) | no | live tab control; entry 3 is the **Titles** tab `CharacterStatController.cs:432` deliberately keeps inert |
| `spellbook_21000034.json` | `0x100002A8` (window root) | 300×600 | 2 entries | no | live tab control vs `SpellbookWindowController` |
| `combat_21000073.json` | `0x100000A2` | 800×60 | **8 entries** (`A3/AA``5C2/5C3`) | no | **no controller rebinds these**`UiTabControl` takes ownership outright and hides 7 of the 8 stance pages |
`VendorUiController`/`SpellbookWindowController`/`CharacterStatController` re-bind their tabs
after import, so their click hijack is overwritten; but `UiTabControl.OnChildrenAttached`
still runs first and mutates `Visible`/state, and **combat has no re-binder at all**.
The minimum correct fix is (a) gate the arm exactly like the Type-5 arm
(`8 when info.TabTable.Count > 0`) so media-bearing plain panels such as `0x1000008D` keep
`UiDatElement`, (b) give `UiTabControl` `UiDatElement`'s media draw + `ClickThrough`
semantics, and (c) explicitly reconcile the four pre-existing hosts — either `UiTabControl`
owns their switching and the duplicated controller code goes, or Type 8 is not mapped
outside `0x2100002B` in this slice.
### 2.2 **MUST-FIX — the same class of regression on the Type-5 arm**
`DatWidgetFactory.cs:99-105` justifies the gate with "none currently reach this factory". That
is refuted by the same raw-property decode. Pre-existing Type-5 elements that **do** author
`P0x64`, and therefore flip from `UiDatElement` to `UiTemplateListBox` at runtime:
| Fixture | Element | Media | Referenced by |
|---|---|---|---|
| `effects_positive/negative_2100001B.json` | `0x10000123` | no | `EffectsUiController.ListId` |
| `examine_2100006B_100005F2.json` | `0x10000149`, `0x10000335`, `0x1000032D` | no | `AppraisalUiController.CreatureStatsListId` / `CreatureExtraListId` / `SpellFormulaListId` |
| `character_2100002E.json` | `0x1000023D` | no | `CharacterStatController.ListBoxId` |
| `character_2100002E.json` | `0x10000532` | **yes** | Titles page list |
| `spellbook_21000034.json` | `0x10000464` | no | `SpellbookWindowController.ComponentListId` |
| `mini_game_2100001E.json` | `0x10000174` | **yes** | mini-game board |
`UiTemplateListBox : UiPanel` zeroes `BackgroundColor`/`BorderColor` (`UiTemplateListBox.cs:92-93`)
and draws no authored media, is not `ClickThrough`, and inserts an anchored `UiScrollablePanel`
as child 0.
**Smoking gun:** `tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs:135-137`
already asserts
```csharp
UiElement listHost = Assert.IsType<UiDatElement>(layout.FindElement(EffectsUiController.ListId));
UiItemList list = Assert.IsType<UiItemList>(Assert.Single(listHost.Children));
```
Against real DAT data both assertions now fail (wrong type; two children). It passes only
because `effects_positive_2100001B.json` predates the `TemplateList` field.
**This is also why the commit message's fixture-drift paragraph is wrong.** It states the
regenerated drift was "an Outline/OutlineColor field pair added by an earlier commit,
bcc34ee3". The pre-existing fixtures contain **neither** `Outline`/`OutlineColor` **nor**
`TabTable`/`TemplateList`/`ScrollbarElementId` (grep count 0 for all five in
`character_2100002E`, `vendor_…`, `spellbook_…`, `combat_…`). Regeneration would have
populated the three new fields too — i.e. the decision to revert the drift is exactly what
suppressed the evidence of §2.1/§2.2. Regenerating those 27 fixtures is the acceptance gate
for any fix here.
---
## 3. `UiTemplateListBox` — mechanism faithful
`AddItemFromTemplateList(index)` (`src/AcDream.App/UI/UiTemplateListBox.cs:104-118`)
resolves `Templates[index]` through `TemplateResolver(layoutId, elementId)` and appends the
built subtree. Retail (`UIElement_ListBox::AddItemFromTemplateList @0x0046F2B0`
`AddItem(BaseProperty*) @0x0046F170``AddItem(did, elementId, parent) @0x0046EDC0`
`UIElementManager::CreateChildElementByDid(this, did, elementId)`) does the same thing: build
the named element out of the named LayoutDesc as a child of the list box, then append.
Instantiating through `LayoutImporter.ImportInfos(dats, layoutId, elementId)` + `Build` is the
correct acdream analogue of `CreateChildElementByDid`, and it is the same overload
`RetailDialogFactory` already uses. ✔ Out-of-range and no-resolver both return null, matching
retail's null returns. ✔
Scrollbar linkage is carried as data (`ScrollbarElementId`) for a page controller to bind to
`Scroll`; that mirrors `ChatWindowController`'s existing pattern. Acceptable for a
"mechanism-only" slice — see §1.3 for the naming/`0x71` notes.
- **NOTE.** Row stacking uses `_viewport.ContentHeight` as the next row's `Top`
(`:114-116`). Retail sizes the box from `CalculatePaperSize` and the ListBox owns row
placement; the acdream model is equivalent for uniform top-anchored rows and is the same
model `CharacterStatController.RebuildActiveList` already uses. Fine.
- **NOTE.** Authored dat children of a `UiTemplateListBox` land as siblings of the internal
viewport (the class does not override `AddChild`), while template rows go inside it. Benign
for the Options ListBoxes (no authored children) but relevant to §2.2's converted lists.
---
## 4. The two structural identities — **BOTH CONFIRMED** (independent check)
I decoded the fixture myself rather than taking the commit message's word.
### 4.1 `UIOption_Slider 0x10000037` ≡ horizontal `UIElement_Scrollbar` — **CONFIRMED**
`options_2100002B.json` element `0x1000021C`:
```
0x1000021C T=0x10000037 (146,4) 120x12 DirectState image 0x060012C5
P0x7B=Bool(True) P0x86=Float(0.5) P0x7C=Bool(True) P0x7E=Bool(False)
P0x82=Bool(False) P0x83=Bool(True) [+ P0x47/P0x48/P0x4B, see below]
child id=1 T=0x1 7x12 DirectState image 0x060012C6
```
Compare the real `Type 0xB` scalar slider in `combat_21000073.json`, element `0x1000004F`
(gmCombatUI desired-power):
```
0x1000004F T=0xB 707x14
P0x7B=Bool(True) P0x86=Float(0.5) P0x7C=Bool(True) P0x7E=Bool(False)
P0x82=Bool(False) P0x83=Bool(False)
child id=1 T=0x1 12x14 child id=4 T=0x3 child T=0x7 (meter)
```
**Identical property fingerprint, identical `child id = 1` thumb convention, `W > H`.**
`BuildScrollbar`'s horizontal branch (`DatWidgetFactory.cs:223-279`) reads exactly
`DefaultImage(info)` for the track and `child id 1` for the thumb. The identity holds; the
mapping is correct and the conformance test (`OptionsPanelFixture_SliderControl_BuildsAs…`)
pins `Horizontal == true` and a non-zero thumb sprite. ✔
### 4.2 `UIOption_Menu 0x10000038``UiMenu`'s shape — **CONFIRMED**
```
options_2100002B 0x10000224 T=0x10000038 120x18
P0x2 P0x6 P0x7=DID(0x21000043) P0x8 P0x9 P0xA=DID(0x21000043) P0x13 [+0x47/0x48/0x4B]
children: 0x10000355 T=0xC 100x18 (label) 0x10000356 T=0x3 17x19 (arrow cap)
vendor_… 0x100000BF T=0x6 (UIElement_Menu) 117x18
P0x2 P0x6 P0x7=DID(0x21000043) P0x8 P0x9 P0xA=DID(0x21000043) P0x13
children: 0x1000034D T=0xC 100x18 (label) 0x1000034E T=0x3 17x19 (arrow cap)
```
Same seven properties, the same popup-template DID `0x21000043`, the same 100×18 label +
17×19 arrow-cap child pair. `new UiMenu()` is right, and it matches how the existing Type-6
arm is built. ✔
- **NOTE (both identities).** The `UIOption_*` family carries an extra authored triple
`P0x47=Enum(0x10000397)`, `P0x48=DID(0x21000041)`, `P0x4B=Bool(True)` that the plain
Type-6/Type-11 counterparts do not. It is on the slider, the menu, **and** the already-mapped
`UIOption_Checkbox 0x10000219`, so it is a pre-existing uniform gap rather than something
OP2 introduced — but somebody should establish what `0x21000041/0x10000397` is (it looks like
a shared tooltip/option-chrome base pair) before OP4OP6 rely on those rows rendering
completely.
---
## 5. `UiOptionToggleSlider` / `UiCheckboxBitfield64` and TS-72
### 5.1 `UiOptionToggleSlider` — consistent with the authored templates ✔
Fixture-verified: `0x10000220` (272×20) and `0x10000221` (272×36) are both
`T=0x10000036` and both contain exactly one `0x10000219` (`T=0x10000035`, checkbox) and one
`0x1000021C` (`T=0x10000037`, slider); `0x10000221` additionally carries the two range-label
`Type 0xC` children `0x1000021E`/`0x1000021F`. Composing rather than consuming children is
right, and `OnChildrenAttached` (`src/AcDream.App/UI/UiOptionToggleSlider.cs:33-42`) picks the
first `UiButton` and first `UiScrollbar`, which is unambiguous for both templates. The
"labelled slider row `0x1000021D` is a plain `Type 3`, not `0x10000036`" test is a genuinely
good data-driven discriminator. ✔
### 5.2 `UiCheckboxBitfield64` — **MUST-FIX ×2**
**(a) The checked-state predicate is wrong.** `IsSet` (`src/AcDream.App/UI/UiCheckboxBitfield64.cs:137-138`)
requires **all** mask bits set. Retail `UIOption_CheckboxBitfield64::Refresh @0x004859C0`:
```
lowHit = m_current.low & mask.low;
highHit = m_current.high & mask.high;
allSet = (lowHit == mask.low && highHit == mask.high); // computed…
ebx = ((lowHit | highHit) != 0) ? 0 : 1; // …but the VISIBLE flag is ANY-set
row = UIElement_ListBox::GetItem(this, i);
cb = UIElement::GetChild(row, 0x10000219);
UIElement::SetAttribute_Bool(cb, 0xE, ebx == 0); // checked <=> ANY mask bit set
```
Retail checks the box when **any** bit of the mask is set; acdream only when **all** are. This
matters immediately: 8 of the 13 Chat filter masks are multi-bit
(Gameplay `0x83912021`, Combat `0x00600040`, Magic `0x00020080`, AreaSpeech `0x00001004`,
Tells `0x00000018`, Allegiance `0x00040C00`, …). The click direction is derived from the
displayed state on both sides, so a wrong predicate inverts the next click too.
**(b) The toggle math is NOT unknown — TS-72's premise is false.**
`UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0`:
```
if (idMessage == 1
&& GetAttribute_Int (pElement, 0x10000084, &index) // row index stamped at build time
&& GetAttribute_Bool(pElement, 0xE, &onOff) // the row checkbox's new state
&& index < childCount)
{
BitUtils::SetBitsOnOrOff(&m_current, m_rgChildValues[index].mask, onOff);
vtable+0x2D4 (1); // Apply(1)
}
return UIElement_ListBox::ListenToElementMessage(this, edi);
```
`SetBitsOnOrOff(value, mask, on)` **is** "on ⇒ OR mask, off ⇒ AND-NOT mask" — so
`ToggleRow` (`:140-155`) is *correct*, and TS-72's claim that "the decompiled excerpt this
campaign pulled covers `Apply`'s WRITE side, not its own click-handler's bit math" describes
what the campaign grepped, not what exists. Every method is in
`acclient_2013_pseudo_c.txt` and `symbols.json`: `CreateChildren @0x00485DF0`,
`AddChild @0x004862A0`, `Refresh @0x004859C0`, `ListenToElementMessage @0x00485AE0`,
`GetValue @0x00486150`, `Changed/SaveCurrentValue/RestoreSavedValue/RestoreDefaultValue`
`@0x004856A0…0x00485780`. **TS-72 is therefore not an accurate statement of what is and is
not verified.** It should be rewritten (or retired) to say: the OR/AND-NOT half is
byte-confirmed correct; the checked-state predicate is byte-confirmed **wrong**.
**(c) "The template authors zero children" is refuted by the committed fixture.**
`BuildCheckboxBitfield64` (`DatWidgetFactory.cs:810-815`) reads nothing from `ElementInfo`,
and the class doc (`UiCheckboxBitfield64.cs:11-14`) plus the commit message assert the
template "carries NO children and NO media". The actual authored element:
```
0x10000520 T=0x10000044 272x100
P0x64 = Array[1] -> Struct{0x63=DID(0x2100002B), 0x62=Enum(0x10000521)}
P0x10000082 = DID(0x06004D17)
P0x10000083 = DID(0x06004D19)
```
It authors **its own row-template list** — the very `0x64` property this commit taught the
reader to decode — naming `0x2100002B/0x10000521`, plus two RenderSurface DIDs (which are the
LED sprite's `Highlight`/`Ghosted` images, `0x06004D17`/`0x06004D19`). `0x10000521` is a
272×20 container holding `0x10000219` (`Type 1`, base `0x21000044/0x10000329`) with the 13×13
five-state LED child `0x10000328` — i.e. exactly the row art. Research §1.5 flagged
`0x10000521` as "UNKNOWN consumer"; **this fixture closes that unknown** (research U10 too),
and the decomp confirms it:
```
UIOption_CheckboxBitfield64::CreateChildren @0x00485DF0
for i in 0 .. m_rgChildValues.m_num-1:
row = UIElement_ListBox::AddItemFromTemplateList(this, 0, nullptr); // template idx 0
cb = GetChildRecursive(row, 0x10000219)->DynamicCast(1);
SetAttribute_Int(cb, 0x10000084, i);
UIElement_Text::SetStringInfo(cb, childValues[i].siLabel);
UIElement::SetTooltip (cb, childValues[i].siTooltip);
ResizeTo(GetWidth(), CalculatePaperSize(0, -1));
Refresh();
```
`UIOption_CheckboxBitfield64` **is a `UIElement_ListBox`** — it calls
`UIElement_ListBox::AddItemFromTemplateList`/`GetItem`/`CalculatePaperSize`/
`ListenToElementMessage` on itself. So the faithful acdream port is
`UiCheckboxBitfield64` composing the `UiTemplateListBox` mechanism **this same commit
shipped**, not a hand-rolled `UiPanel` of synthesized `UiButton`s. As written, `AddChild`
(`:108-135`) fabricates an `ElementInfo{Type=1, Height=RowHeight}` with a hand-injected
`0x0B` property and an invented `RowHeight = 14f` whose comment claims "No authored row
height exists for this block (it is unauthored in the dat)" — the dat authors 272×20 with a
260×14 checkbox inset at (6,2). Consequences as landed: no LED art, no authored font
(`0x40000002`), no authored colour (`0x1B`), no authored label insets (`0x23`/`0x25`), no
justification (`0x14=Left`), no tooltip plumbing, and no outline seeding.
`AddChild(uint64 mask, uint32 labelId, uint32 tooltipId) @0x004862A0` is also confirmed to be
pure bookkeeping (append a `ChildInfo{mask, StringInfo(label, tableEnum **0x10000006**),
StringInfo(tooltip, 0x10000006)}`) — note the table enum is `0x10000006`, not the `0x10000003`
used for headers/labels elsewhere. Worth pinning for OP5.
---
## 6. Outline seams (`aa6635ae` / AP-192) — **PRESERVED**
I scanned all five new fixtures for authored `0x21`/`0x22`. Every occurrence (32 total) is
`0x21 = Bool(True)` **in state 3 only**, on `Type 1` buttons: the three
Apply/Reset/Defaults buttons on each of Character/Chat/Config, and the seven Gameplay-tab
buttons. Those build through the untouched `BuildButton`, whose effective-default resolution
correctly yields outline-off — precisely the AP-192 deferral, unchanged. No authored `0x22`
anywhere; no authored `0x21` on any element reaching a *new* build path. ✔
- **NOTE.** `new UiMenu()` for `0x10000038` does not seed `Outline`/`OutlineColor` — but
neither does the pre-existing Type-6 arm, and `UiMenu`'s own doc records "No authored menu
element carries it today", which the Options menu `0x10000224` confirms (no `0x21`/`0x22`
on it or its label child). Unchanged, not regressed.
- **NOTE (folds into §5.2c).** `UiCheckboxBitfield64`'s synthesized rows have no outline
seeding either — but that is a symptom of bypassing the authored template, not a separate
defect. Building the rows through `AddItemFromTemplateList` gets the seam for free via
`BuildCheckbox`.
---
## 7. Conformance tests vs research §10.1 — **ACCURATE**, coverage thin in two places
I spot-checked every pinned constant against the research doc and the fixtures. All correct:
| Test | Pins | Research §10.1 / §1.3 / §1.5 | Verdict |
|---|---|---|---|
| `…TabControl_HasFourEntriesInAuthoredOrder_GameplayDefault` | `020D/0212 default`, `020E/0211`, `050B/050C`, `020F/0213`, `Assert.Single(IsDefault)` | identical | ✔ |
| `CharacterListBoxFixture_TemplateList_…` | 3 × `{0x2100002B, 216/217/218}` | identical | ✔ |
| `ConfigListBoxFixture_TemplateList_…` | 8, order `216,217,218,21A,222,220,21D,221` | identical | ✔ |
| `ChatListBoxFixture_TemplateList_…` | 9, + `0x10000520` | identical | ✔ |
| scrollbar linkage ×3 | `0x100001FB`, `0x10000201`, `0x10000201` | identical | ✔ |
| `CharacterListBox_TemplateMechanism_ReachesSixHeadersAndFortyNineToggles` | `{3,15,6,11,7,7}` = 49 under 6 headers; `6*22 + 49*20` content height | §7 counts; template heights 292×22 / 272×20 confirmed in fixture | ✔ |
| `UiTabControl_*` ×5 | default mount, exactly-one-visible, Open/Closed flip, click-through, no-op | matches `UIElement_Panel::Update` | ✔ |
Two gaps:
- **SHOULD-FIX.** No test pins the reader itself (see §1.4) — every "reader" test actually
reads a serialized field.
- **SHOULD-FIX.** `UiCheckboxBitfield64_AddChild_TracksMaskAndTogglesOnClick` deliberately
picks the single-bit `Error` mask (`0x04000000`) because it "round-trips cleanly" — which is
exactly the case that cannot distinguish acdream's all-set predicate from retail's any-set
predicate. A multi-bit mask (Combat `0x00600040`) against a partially-set current value is
the test that would have caught §5.2a.
- **NOTE.** The plan's OP2 contract cites "tab-button ↔ page-slot switching per lane A §5".
§5 is the Chat-tab row map; the tab control is §1.3 and its host is §1.4. The implementer
cited the right sections; the plan's cross-reference should be corrected so OP8 does not
chase §5.
---
## Summary of classifications
**MUST-FIX**
1. `DatWidgetFactory.cs:110` — unconditional `Type 8 → UiTabControl` converts four shipped
Type-8 hosts (vendor `0x100000B8`, character `0x10000227`, spellbook `0x100002A8`, combat
`0x100000A2` — the last with **no** controller re-binder) and strips `UiDatElement` media
drawing + `ClickThrough` from the media-bearing vendor backdrop `0x1000008D`.
2. `DatWidgetFactory.cs:104` — the Type-5 gate's "none currently reach this factory" is false;
nine shipped ListBoxes author `P0x64` (effects, examine ×3, character ×2, spellbook,
mini-game). `EffectsUiControllerTests.cs:135-137` already encodes the pre-OP2 contract and
fails against real DAT data. Fixture staleness — not correctness — is what keeps the suite
green; regenerating the 27 fixtures is the acceptance gate for the fix.
3. `UiCheckboxBitfield64.cs:137-138` — checked-state predicate is all-bits-set; retail
(`Refresh @0x004859C0`) is any-bit-set.
4. `UiCheckboxBitfield64` / `DatWidgetFactory.cs:810` — the template `0x10000520` **does**
author content (`P0x64 → 0x2100002B/0x10000521`, plus `P0x10000082`/`P0x10000083`); retail
`CreateChildren @0x00485DF0` builds every row through `AddItemFromTemplateList(0)`. The
synthesized `ElementInfo` + invented `RowHeight = 14f` must be replaced by the authored
template path, and the false "unauthored in the dat" comments removed.
5. `UiTabControl.cs:7` / `DatWidgetFactory.cs:108` — cite `UIElement_Panel`
(`SetupTabPageHash @0x0046C2E0`, `Update @0x0046BD00`, `OpenTab @0x0046BE20`); the named
class `UIElement_TabControl` does not exist in the PDB.
6. `docs/architecture/retail-divergence-register.md` TS-72 — inaccurate as written; the click
math is in the decomp, the OR/AND-NOT half is confirmed right and the predicate half is
confirmed wrong. Also missing rows for the invented bitfield row geometry and the dropped
`0x64`/`0x10000082`/`0x10000083` authored data.
**SHOULD-FIX**
7. `ReadTabTable` must skip entries missing `0x30`/`0x31` (retail `0x0046C3E5`/`0x0046C3FC`).
8. `UiTabControl.FindDirectChild` → recursive descendant lookup (retail `GetChildRecursive`).
9. `defaultEntry ??= _tabs[0]` is invented; retail performs no switch when nothing authors
`0x32`.
10. Add reader-level unit tests for `0x2E`/`0x64`/`0x72` (fixture tests only pin serialized
values).
11. Add a multi-bit-mask `UiCheckboxBitfield64` test.
**NOTE**
12. `0x2F` MasterPropertyId and `tab != page` filters unimplemented (harmless today).
13. `0x72` is specifically the **vertical** scrollbar (`UIElement_Scrollable::OnSetAttribute
@0x00474630`); `0x71` (horizontal) is unread though authored in vendor/combat layouts.
14. `BroadcastElementMessage(0x2C)`, `SetMouseVisible(1)`, and the `idMessage 0x18`
visibility-follow path are unported — OP3 needs the first for §3.6 semantics.
15. The `UIOption_*` `P0x47/P0x48/P0x4B` triple (`0x21000041/0x10000397`) is unread across the
whole family, including the pre-existing checkbox mapping.
16. `UIOption_CheckboxBitfield64::AddChild` uses string table enum `0x10000006`, not
`0x10000003` — pin for OP5.
17. Research §1.5's "`0x10000521` … UNKNOWN consumer" and §10.5 U10 are now closed by the
committed fixture; the research doc should be updated.
18. The plan's OP2 contract cites "lane A §5" for the tab host; the correct sections are §1.3
(tab table) and §1.4 (host).
19. The commit message's fixture-drift paragraph understates the drift (three new fields, not
one pair).
---
## Appendix — retail extracts used (all from `docs/research/named-retail/`)
| Symbol | Address | Used for |
|---|---|---|
| `UIElement::RegisterElementClass(8, UIElement_Panel::Create)` | `0x0046C6B7` | Type 8 identity |
| `UIElement_Panel::SetupTabPageHash` | `0x0046C2E0` | `0x2E` entry filters, default selection, `Update(page, tab)` tail |
| `UIElement_Panel::Update` | `0x0046BD00` | one-visible-page + `SetState(0xB/0xC)` + `Broadcast(0x2C)` |
| `UIElement_Panel::OpenTab` / `InqTabFromPage` / `ListenToElementMessage` | `0x0046BE20` / `0x0046BEB0` / `0x0046BF90` | switching entry points, `0x18`/`0x19`/`0x29` messages |
| `UIElement_ListBox::AddItemFromTemplateList` | `0x0046F2B0` | `0x64` array indexing |
| `UIElement_ListBox::AddItem(BaseProperty*)` | `0x0046F170` | `0x63`/`0x62` struct member order |
| `UIElement_ListBox::AddItem(did, elementId, parent)` | `0x0046EDC0` | `CreateChildElementByDid` seam |
| `UIElement_Scrollable::OnSetAttribute` | `0x00474630` | `0x71` horizontal / `0x72` vertical |
| `UIElement_Scrollable::GetScrollbarPointer_` | `0x00473EC0` | `FindRelative` + `DynamicCast(0xB)` validation |
| `UIOption_CheckboxBitfield64::CreateChildren` | `0x00485DF0` | rows built from template index 0 |
| `UIOption_CheckboxBitfield64::ListenToElementMessage` | `0x00485AE0` | `SetBitsOnOrOff` click math |
| `UIOption_CheckboxBitfield64::Refresh` | `0x004859C0` | any-bit-set checked predicate |
| `UIOption_CheckboxBitfield64::AddChild` | `0x004862A0` | `ChildInfo{mask,label,tooltip}`, table enum `0x10000006` |
| `UIElement::RegisterElementClass(0x10000037/38/36/44, …)` | `0x0048522A` / `0x0048436A` / `0x00484A3A` / `0x00485EDA` | the four UIOption class ids |
Fixture decodes were performed with a throwaway JSON reader over the committed
`tests/AcDream.App.Tests/UI/Layout/fixtures/*.json` (scratchpad only, not committed). Nothing
in the repository was modified except this document.