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>
580 lines
34 KiB
Markdown
580 lines
34 KiB
Markdown
# 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 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 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 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 `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.
|