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>
34 KiB
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:
- The unconditional
Type 8mapping (and theType 5mapping) 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 withTabTable/TemplateListempty. 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. - 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 isUIElement_Panel, not a class calledUIElement_TabControl— that class does not exist in the PDB) andUIOption_CheckboxBitfield64::CreateChildren/ListenToElementMessage/ Refreshare all decompiled. Reading them confirms most of the port, refutesUiCheckboxBitfield64'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.ScrollbarElementIdshould be named vertical;0x71(horizontal) is unread.0x71IS 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/ScrollbarElementIdoff the serialized fixture fields —LayoutImporter.Builddoes not re-run the projection at load. SoReadTabTable/ReadTemplateListare exercised only at fixture-generation time and a future regression in either reader would not fail a single test. One syntheticElementInfo+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 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 exists —grep UIElement_TabControl docs/research/named-retail/symbols.jsonreturns nothing. Type 8 isUIElement_Panel(UIElement::RegisterElementClass(8, UIElement_Panel::Create)at0x0046C6B7). CLAUDE.md's phase checklist requires a named symbol + address; citing a fictional class is what let items 2–4 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 usesGetChildRecursivefor both the tab and the page. Fine for0x2100002B(all direct), wrong for the Configure Keyboard tab host0x1000049B(research §8) this widget is explicitly meant to also serve. - SHOULD-FIX.
defaultEntry ??= _tabs[0](:78) is invented. Retail leavesm_OpenPageToken/m_OpenTabTokenat 0 when no entry authors0x32, soUpdate(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, itsSetMouseVisible(1)per tab button during setup (0x0046C4FB), and itsListenToElementMessagehandling ofidMessage 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'sRetailTabBinding.SetClickadaptation is consistent with the existing character/vendor/spellbook tabs, but it is an adaptation and OP3 will need the0x2Cnotification for theOnVisibilityChangedapply/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 : UiElementhas noOnDraw— the authored DirectState media is not drawn (UiDatElement.OnDraw,src/AcDream.App/UI/Layout/UiDatElement.cs:193-216).UiElement.ClickThroughdefaults false;UiDatElementsets 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
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.ContentHeightas the next row'sTop(:114-116). Retail sizes the box fromCalculatePaperSizeand the ListBox owns row placement; the acdream model is equivalent for uniform top-anchored rows and is the same modelCharacterStatController.RebuildActiveListalready uses. Fine. - NOTE. Authored dat children of a
UiTemplateListBoxland as siblings of the internal viewport (the class does not overrideAddChild), 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 tripleP0x47=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-mappedUIOption_Checkbox 0x10000219, so it is a pre-existing uniform gap rather than something OP2 introduced — but somebody should establish what0x21000041/0x10000397is (it looks like a shared tooltip/option-chrome base pair) before OP4–OP6 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 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()for0x10000038does not seedOutline/OutlineColor— but neither does the pre-existing Type-6 arm, andUiMenu's own doc records "No authored menu element carries it today", which the Options menu0x10000224confirms (no0x21/0x22on 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 throughAddItemFromTemplateListgets the seam for free viaBuildCheckbox.
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_TracksMaskAndTogglesOnClickdeliberately picks the single-bitErrormask (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 (Combat0x00600040) 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
DatWidgetFactory.cs:110— unconditionalType 8 → UiTabControlconverts four shipped Type-8 hosts (vendor0x100000B8, character0x10000227, spellbook0x100002A8, combat0x100000A2— the last with no controller re-binder) and stripsUiDatElementmedia drawing +ClickThroughfrom the media-bearing vendor backdrop0x1000008D.DatWidgetFactory.cs:104— the Type-5 gate's "none currently reach this factory" is false; nine shipped ListBoxes authorP0x64(effects, examine ×3, character ×2, spellbook, mini-game).EffectsUiControllerTests.cs:135-137already 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.UiCheckboxBitfield64.cs:137-138— checked-state predicate is all-bits-set; retail (Refresh @0x004859C0) is any-bit-set.UiCheckboxBitfield64/DatWidgetFactory.cs:810— the template0x10000520does author content (P0x64 → 0x2100002B/0x10000521, plusP0x10000082/P0x10000083); retailCreateChildren @0x00485DF0builds every row throughAddItemFromTemplateList(0). The synthesizedElementInfo+ inventedRowHeight = 14fmust be replaced by the authored template path, and the false "unauthored in the dat" comments removed.UiTabControl.cs:7/DatWidgetFactory.cs:108— citeUIElement_Panel(SetupTabPageHash @0x0046C2E0,Update @0x0046BD00,OpenTab @0x0046BE20); the named classUIElement_TabControldoes not exist in the PDB.docs/architecture/retail-divergence-register.mdTS-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 dropped0x64/0x10000082/0x10000083authored 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.