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

34 KiB
Raw Blame History

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) != 0x2Fskip 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.SetOpenRetailUiStateIds.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 mountOnChildrenAttached (:65-81) picks the IsDefault entry and SwitchTos 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 existsgrep 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/0x29OpenTab) 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/AA5C2/5C3) no no controller rebinds theseUiTabControl 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

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 @0x0046F2B0AddItem(BaseProperty*) @0x0046F170AddItem(did, elementId, parent) @0x0046EDC0UIElementManager::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_ScrollbarCONFIRMED

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 0x10000038UiMenu'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 UiCheckboxBitfield64MUST-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 UiButtons. 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.