acdream/docs/research/2026-08-11-op2-review-mechanism.md
Erik 26b119354d 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>
2026-08-11 00:29:08 +02:00

580 lines
34 KiB
Markdown
Raw Permalink 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 — 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.