Closes the mechanism lens's REOPEN (one MUST-FIX) and both lenses' small
residuals on the OP2 rework (b236a442); the blast lens re-review was
CLOSED outright. Fable-direct per the two-failure escalation rule.
- AP-195 filed: UIOption_CheckboxBitfield64 ports HALF of Refresh
@0x004859C0 — the ANY-set checkbox predicate is exact, but the ALL-set
LED media swap (P0x10000082=0x06004D17 / P0x10000083=0x06004D19) and
the ListBox self-sizing tail (ResizeTo/CalculatePaperSize — the block
IS a UIElement_ListBox in retail) are unported, and the block's row
stacking is a second divergent implementation beside UiTemplateListBox.
All due at OP5 before the Chat tab's connected gate; the IsSet doc
comment now names both halves instead of quoting only the ported one.
- Row tooltips: UiButton gains settable TooltipText surfaced through the
shared GetTooltipText hover pipeline (UiCatalogSlot's pattern);
UiCheckboxBitfield64.AddChild applies the row tooltip retail stamps in
CreateChildren @0x00485DF0, and documents that the 0x10000084 row-index
attribute stamp is deliberately replaced by the typed mask closure.
- AD-73 addendum: UiTemplateListBox.ConsumesDatChildren=true is inert
only while no authored Type-5 element carries children — that premise
is now conformance-PINNED across all 32 fixtures (a future DAT
regeneration surfacing an authored child fails the build instead of
silently dropping it).
- Plan doc: OP2's contract names UiTabPanel.cs (retail UIElement_Panel),
not the fictional-class-named UiTabControl.cs; ledger records OP1 and
OP2 both CLOSED.
Full Release suite: 12,871 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
376 lines
24 KiB
Markdown
376 lines
24 KiB
Markdown
# Campaign OP slice OP2 rework — MECHANISM-lens FOCUSED RE-REVIEW
|
||
|
||
**Date:** 2026-08-11
|
||
**Rework under review:** `b236a442` — "fix(ui): OP2 rework — dormant UiDatElement
|
||
subclasses, fixed Panel/CheckboxBitfield64 mechanism"
|
||
**Round-1 findings being closed:** `docs/research/2026-08-11-op2-review-mechanism.md`
|
||
(verdict REJECT on `df9c7a35`)
|
||
**Mode:** READ-ONLY. No build, no test run, no launch. Every claim is 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: **REOPEN — one MUST-FIX, narrowly scoped**
|
||
|
||
All four *behavioural* MUST-FIXes and all five SHOULD-FIXes from round 1 are
|
||
genuinely closed, with regression tests that would actually catch a relapse. This is
|
||
**not** a redo — it is a one-item fix round.
|
||
|
||
The one thing that fails is the tail of round-1 MUST-FIX 6, and it is the **same
|
||
failure mode that caused the round-1 REJECT, on the same element**: the commit
|
||
asserts a completeness claim about `0x10000520`'s authored data and about retail's
|
||
`Refresh` that the data and the decomp both refute.
|
||
|
||
- `UiCheckboxBitfield64.cs:194-201`'s doc comment quotes
|
||
`UIOption_CheckboxBitfield64::Refresh @0x004859C0` as if the ANY-bit predicate were
|
||
the whole function. It is not. `Refresh` computes **both** an all-set and an any-set
|
||
result and uses them for **two different things** (§2.2 below). The all-set half
|
||
drives a per-row LED media swap between the element's two authored RenderSurface
|
||
DIDs — `P0x10000082 = 0x06004D17` and `P0x10000083 = 0x06004D19`, both still present
|
||
in the regenerated fixture, both still unread anywhere in `src/AcDream.App`
|
||
(verified by grep: zero hits outside one prose mention).
|
||
- That unported half has **no register row**. Round-1 MUST-FIX 6 asked for exactly
|
||
this ("Also missing rows for … the dropped `0x64`/`0x10000082`/`0x10000083` authored
|
||
data"). `0x64` is now consumed; the two DIDs are not, and the register gained only
|
||
AD-73 (tab dormancy) while TS-72 was retired.
|
||
|
||
Fix = one AP row naming the unported `Refresh` media branch + the unported
|
||
`ResizeTo(GetWidth(), CalculatePaperSize(0,-1))` tail, and a corrected doc comment
|
||
that stops presenting a half-quoted `Refresh` as complete. The port itself belongs to
|
||
OP5 with the consumer; nothing is user-reachable today.
|
||
|
||
---
|
||
|
||
## 1. Finding-by-finding closure check
|
||
|
||
### 1.1 `UiTabPanel` — **CLOSED** (all five sub-checks)
|
||
|
||
| Round-1 item | Rework | Evidence |
|
||
|---|---|---|
|
||
| MUST-FIX 5 — cite `UIElement_Panel`, not the fictional `UIElement_TabControl` | ✔ | `src/AcDream.App/UI/UiTabPanel.cs:8-21` names `UIElement_Panel::Create @0x0046C6B7`, `SetupTabPageHash @0x0046C2E0`, `Update @0x0046BD00`, `OpenTab @0x0046BE20`, `InqTabFromPage @0x0046BEB0`, `ListenToElementMessage @0x0046BF90`, and explicitly records the naming correction. `DatWidgetFactory.cs:110-119` carries the same correction at the factory arm. Class renamed `UiTabControl` → `UiTabPanel`; the old file is deleted. |
|
||
| Switching semantics preserved | ✔ | `UiTabPanel.cs:184-199` is byte-equivalent to the round-1 reading of `UIElement_Panel::Update @0x0046BD00`: exactly one page `Visible`, every tab button driven `Open`/`Closed` via `RetailTabBinding.SetOpen` → `RetailUiStateIds.Open`/`Closed` = retail `0xC`/`0xB`. Nothing in this commit touched `RetailTabBinding.cs` or `RetailUiStateIds`. |
|
||
| SHOULD-FIX 8 — recursive descendant resolution | ✔ | `FindDescendant` (`UiTabPanel.cs:208-219`) is a depth-first descendant walk used for **both** the button (`:149`) and the page (`:150`, `:191`, `:194`), matching retail's `GetChildRecursive` at `0x0046C4EE` / `0x0046BD00`. `FindDirectChild` is gone. |
|
||
| SHOULD-FIX 9 — no invented `_tabs[0]` fallback | ✔ | `UiTabPanel.cs:172-173` — `if (defaultEntry is { } def) SwitchTo(def.PageElementId);` with no `??=`. **Independently re-verified against the decomp this round:** `SetupTabPageHash` zeroes `m_OpenTabToken`/`m_OpenPageToken` at `0x0046C312`/`0x0046C318`, seeds `ebp_1 = 0` at `0x0046C31E`, only writes `ebp_1`/`var_3c` inside the `0x32`-true branch at `0x0046C4DF`/`0x0046C4E3`, and tail-calls `Update(this, var_3c, ebp_1)` at `0x0046C5EB`. With nothing authoring `0x32`, that is `Update(0,0)` against zeroed tokens, and `Update`'s guard `arg2 != m_OpenPageToken && arg3 != m_OpenTabToken` is false → **no switch**. Confirmed. |
|
||
| SHOULD-FIX 7 — reader skips malformed entries | ✔ | `ElementReader.cs:526` — `if (buttonId == 0u \|\| pageId == 0u) continue;` with the retail filter cited inline at `:520-525` (`InqProperty(0x30) @0x0046C3E5`, `InqProperty(0x31) @0x0046C3FC`). I re-read both sites: a missing `0x30` jumps `label_46c510` and a missing `0x31` jumps the same, both landing on `label_46c594` (next entry) without touching either hash. Match. |
|
||
|
||
Round-1 SHOULD-FIX 3's "a silently-empty table and a wholly-unresolvable table are
|
||
indistinguishable" is also addressed: `UnresolvedEntries` (`:118`) plus a `[D.2b]`
|
||
diagnostic line (`:154-157`).
|
||
|
||
Two retail behaviours in `SetupTabPageHash` remain unported and are now *newly*
|
||
visible because the rework reads that function properly — both inert on today's data,
|
||
both NOTE (§3).
|
||
|
||
### 1.2 `UiCheckboxBitfield64` — **CLOSED on all three named sub-checks**
|
||
|
||
**(a) Rows built from the authored template via `AddItemFromTemplateList(0)`.** ✔
|
||
`UiCheckboxBitfield64.AddChild` (`:140-180`) resolves `Templates[0]` through
|
||
`TemplateResolver`, finds `0x10000219` by recursive descendant search
|
||
(`FindCheckboxRecursive`, `:182-192`), and stamps label/checked/click onto that
|
||
widget. The synthesized `ElementInfo{Type=1}` and the invented `RowHeight = 14f` are
|
||
**deleted**; `BuildCheckboxBitfield64` is deleted from the factory and the arm is now
|
||
`0x10000044u => new UiCheckboxBitfield64(info.TemplateList)`
|
||
(`DatWidgetFactory.cs:162`). Row geometry now comes from `row.Height`
|
||
(`:173-176`) — the authored 272×20.
|
||
|
||
This matches retail `CreateChildren @0x00485DF0` as I re-read it this round:
|
||
`AddItemFromTemplateList(&this->vtable, 0, nullptr)` per row → `GetChildRecursive(row,
|
||
0x10000219)` → `DynamicCast(1)` → `SetAttribute_Int(cb, 0x10000084, i)` →
|
||
`SetStringInfo` → `SetTooltip`.
|
||
|
||
**Fixture-verified independently** (I decoded `options_2100002B.json` myself, not the
|
||
commit message): `0x10000520` is `T=0x10000044`, 272×100, **zero** children, empty
|
||
`StateMedia`, and authors exactly
|
||
`TemplateList = [{0x2100002B, 0x10000521}]`; `0x10000521` is `T=3`, 272×20, one child
|
||
`0x10000219`. The conformance test pins that exact pair
|
||
(`OptionsPanelLayoutConformanceTests` `…Bitfield64Template_BuildsAsEmptyUiCheckboxBitfield64`).
|
||
|
||
**(b) `IsSet` is retail's ANY-bit predicate.** ✔ `:200-201` —
|
||
`(CurrentLow & lowMask) != 0 || (CurrentHigh & highMask) != 0`. Matches
|
||
`Refresh @0x00485A11`: `ebx = ((eax_2 | ecx_2) != 0) ? 0 : 1` →
|
||
`SetAttribute_Bool(cb, 0xE, ebx == 0)`.
|
||
|
||
**(c) A multi-bit test that actually discriminates.** ✔
|
||
`UiCheckboxBitfield64_MultiBitMask_IsSet_UsesAnyBitNotAllBits` seeds current
|
||
`0x00000040` against Combat's real two-bit mask `0x00600040` and asserts
|
||
`row.Selected == true`. Under the old all-bits predicate `IsSet` returns false and
|
||
`Assert.True(row!.Selected)` fails — the test genuinely discriminates, unlike the
|
||
single-bit `Error` case it replaced (which is retained and now also pins that the row
|
||
resolves to `TemplateCheckboxElementId`). It further pins that clicking clears
|
||
**both** mask bits, i.e. the AND-NOT half.
|
||
|
||
`ToggleRow` (`:209-224`) is unchanged and remains correct: `turnOn = !IsSet(...)`
|
||
derives from the bitfield, not from `Selected`, so a `ToggleBehavior` self-toggle on
|
||
the resolved `UiButton` cannot double-flip it.
|
||
|
||
### 1.3 TS-72 retirement — **PARTIALLY CLOSED (the MUST-FIX)**
|
||
|
||
The retirement itself is legitimate. TS-72's stated unknown was the click-toggle bit
|
||
math; that is now decomp-confirmed
|
||
(`ListenToElementMessage @0x00485AE0` → `BitUtils::SetBitsOnOrOff @0x004857D0`:
|
||
`arg3 != 0` → OR-in both halves, else AND-NOT both halves). The retirement note at
|
||
`docs/architecture/retail-divergence-register.md:350` states plainly that the toggle
|
||
half was always right and the checked-state half was wrong and is now fixed. Accurate,
|
||
and written per register convention (struck row retained with its original text; the
|
||
section header's active count moved 41 → 40).
|
||
|
||
**What was swept:** the note ends "nothing about its own click/checked mechanism
|
||
remains genuinely unverified". Verified is not the same as ported. Reading `Refresh`
|
||
in full (§2.2) shows a decomp-confirmed, unported presentation half of the checked
|
||
mechanism, on the same element whose "authors nothing" claim caused round 1's REJECT,
|
||
with the two authored DIDs it consumes still sitting unread in the fixture. That gap
|
||
has no row anywhere in the register.
|
||
|
||
AD-73 is correct as far as it goes but is scoped to `UiTabPanel` only (§1.4).
|
||
|
||
### 1.4 The dormancy shape — **CLOSED**, with one undocumented shape change
|
||
|
||
**Overrides cannot leak pre-activation.** I enumerated every member both subclasses
|
||
add or override:
|
||
|
||
| | `UiTabPanel` | `UiTemplateListBox` |
|
||
|---|---|---|
|
||
| base | `UiDatElement` (`:79`) | `UiDatElement` (`:81`) |
|
||
| `OnDraw` | not overridden → `UiDatElement`'s authored-media draw | not overridden |
|
||
| `ClickThrough` | base ctor sets `true` (`UiDatElement.cs:138`) | same |
|
||
| `IUiDatStateful` | inherited unchanged | inherited unchanged |
|
||
| `ConsumesDatChildren` | **not** overridden → `false`, children recurse | **overridden `true`** (`:145`) ← the one change |
|
||
| `AddChild` | not overridden | not overridden |
|
||
| import-time hook | `OnChildrenAttached` is an **empty body** (`:126-128`) | none |
|
||
| side-effecting members | `ActivateTabBehavior()` / `SwitchTo()` — explicit calls only | `AddItemFromTemplateList()`; `Scroll` getter materialises the viewport (`:107`) |
|
||
|
||
`UiDatElement` itself changed only `sealed` → `public class` (`:43`) and `_info` →
|
||
`protected readonly ElementInfo Info` (`:57`) plus mechanical renames. No behavioural
|
||
edit in the base. ✔
|
||
|
||
**The one shape change:** `UiTemplateListBox.ConsumesDatChildren => true` is *not*
|
||
identical to the pre-OP2 fallback, which recursed into dat children. I verified the
|
||
class doc's justifying claim myself by walking every committed fixture: **every**
|
||
Type-5 element in the corpus has zero authored children —
|
||
`character 0x1000023D` / `0x10000532`, `effects_positive`/`effects_negative
|
||
0x10000123`, `examine 0x10000149` / `0x10000335` / `0x1000032D`,
|
||
`mini_game 0x10000174`, `spellbook 0x10000464`, and the three Options ListBoxes
|
||
`0x100001FA` / `0x10000200` / `0x1000050D`. So the change is inert today and the claim
|
||
holds. It is a latent divergence (a future Type-5 that authors children loses them
|
||
silently), and it is not mentioned in AD-73 or anywhere in the register — NOTE, §3.
|
||
|
||
**Tab-table entry counts survived the new `0x30`/`0x31` skip** — character 3, combat
|
||
8, spellbook 2, vendor 3, options 4, i.e. the same counts round 1 recorded. The filter
|
||
is inert on real data, exactly as retail's is.
|
||
|
||
**AD-73 states the adaptation truthfully.** Register `:108`. I checked each claim:
|
||
- "Retail … unconditionally activate the authored default page for ANY instance that
|
||
carries a tab table" — correct, and I confirmed the trigger this round:
|
||
`SetupTabPageHash` has exactly one caller, `UIElement_Panel::OnSetAttribute
|
||
@0x0046C6DC`, fired when property `0x2E` is applied. Per-instance, unconditional.
|
||
- Oracle citations `SetupTabPageHash @0x0046C2E0`, `Update @0x0046BD00`,
|
||
`OpenTab @0x0046BE20` — all three verified present at those addresses in
|
||
`acclient_2013_pseudo_c.txt`.
|
||
- The four named hosts and their controller ownership match the fixture decode
|
||
(character `0x10000227` tab=3, spellbook `0x100002A8` tab=2, vendor `0x100000B8`
|
||
tab=3, combat `0x100000A2` tab=8, plus the tab-table-less vendor backdrop
|
||
`0x1000008D`).
|
||
- The Risk column names the real failure mode (a forgotten `ActivateTabBehavior()`
|
||
leaves every page slot visible at once, silently). Accurate.
|
||
|
||
AD-73 covers only `UiTabPanel`. `UiTemplateListBox`'s dormancy (lazy viewport +
|
||
`ConsumesDatChildren`) rides on the same adaptation and is unregistered — NOTE, §3.
|
||
|
||
### 1.5 Reader-level tests — **CLOSED**
|
||
|
||
`tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs:354-481` adds five tests that
|
||
build a synthetic `ElementInfo`, stuff a raw `UiPropertyValue` into
|
||
`States[DirectStateId].Properties.Values[0x2E/0x64/0x72]`, and call
|
||
`ElementReader.ApplyCanonicalLegacyProjection(info)` directly.
|
||
|
||
Would they catch what the fixture tests miss? **Yes, decisively.** The fixture tests
|
||
deserialize `ElementInfo.TabTable`/`TemplateList`/`ScrollbarElementId` as already-
|
||
projected JSON fields; `LayoutImporter.Build` never re-runs the projection at load, so
|
||
deleting the body of `ReadTabTable` would not fail one fixture test. These five drive
|
||
the reader functions themselves. Coverage:
|
||
|
||
- `ReadTabTable_DecodesButtonPageDefaultInAuthoredOrder` — struct-member ids, authored
|
||
order, `0x32` present/absent.
|
||
- `ReadTabTable_SkipsEntriesMissingButtonOrPage` — the new retail filter, with a
|
||
well-formed entry *after* two malformed ones, so an early-`break` regression fails too.
|
||
- `ReadTemplateList_DecodesLayoutDidAndElementIdInAuthoredOrder` — `0x63`/`0x62` pair,
|
||
order.
|
||
- `…ScrollbarLinkage_DecodesEnumProperty` / `…AbsentPropertyStaysZero` — the `0x72`
|
||
value and its absent case.
|
||
|
||
Gap (NOTE, not a miss of the round-1 finding): none of the five exercises
|
||
`ReadStructMemberId`'s `Enum`/`DataId`/`Integer` kind coercion — every test uses
|
||
`Enum`. A regression narrowing that coercion would still slip through.
|
||
|
||
The blast-radius pins (`OP2ReworkBlastRadiusConformanceTests.cs`) are the other half
|
||
of the answer: six built-widget tests across the five pre-existing Type-8 elements and
|
||
one representative Type-5, asserting `BehaviorActive == false`,
|
||
`ActivePageElementId == 0`, `ClickThrough == true`, non-zero `ActiveMedia()` on the
|
||
vendor backdrop, 16 children still present and all `Visible` on combat, and
|
||
`Empty(Children)` + `ContentHeight == 0` on the effects list. Those are exactly the
|
||
properties the dormancy model rests on.
|
||
|
||
---
|
||
|
||
## 2. New mechanism infidelity introduced or newly exposed by the rework
|
||
|
||
### 2.1 **MUST-FIX** — `Refresh`'s all-set branch is unported, its two authored DIDs are unread, and neither the doc comment nor the register says so
|
||
|
||
`UIOption_CheckboxBitfield64::Refresh @0x004859C0`, in full, per row:
|
||
|
||
```
|
||
lowHit = m_current.low & mask.low; // eax_2 @0x004859F9
|
||
highHit = m_current.high & mask.high; // ecx_2 @0x004859FB
|
||
allSet = (lowHit == mask.low && highHit == mask.high); // var_d_1 @0x004859FF..0x00485A0A
|
||
ebx = ((lowHit | highHit) != 0) ? 0 : 1; // anySet @0x00485A11
|
||
|
||
row = UIElement_ListBox::GetItem(this, i); // @0x00485A21
|
||
cb = UIElement::GetChild(row, 0x10000219); // @0x00485A35
|
||
UIElement::SetAttribute_Bool(cb, 0xE, ebx == 0); // checked <=> ANY set ← ported ✔
|
||
|
||
if (ebx == 0) { // @0x00485A51 — only when ANY set
|
||
led = UIElement::GetFirstChildElement(cb); // @0x00485A5A (0x10000328)
|
||
if (led) {
|
||
if (allSet && GetAttribute_DataID(this, 0x10000082, &v)) // @0x00485A69/0x00485A7C
|
||
UIElement::SetMediaImageForState(led, v, 1, 6); // @0x00485AA7
|
||
else if (GetAttribute_DataID(this, 0x10000083, &v)) // @0x00485A9A
|
||
UIElement::SetMediaImageForState(led, v, 1, 6);
|
||
}
|
||
}
|
||
```
|
||
|
||
Retail therefore renders **three** row states, not two: unchecked, checked-partial
|
||
(`0x10000083`), checked-complete (`0x10000082`). Eight of the thirteen Chat filter
|
||
masks are multi-bit (Gameplay `0x83912021`, Combat `0x00600040`, Magic `0x00020080`,
|
||
AreaSpeech `0x00001004`, Tells `0x00000018`, Allegiance `0x00040C00`, …), so the
|
||
partial state is reachable in ordinary play, not a corner case.
|
||
|
||
Evidence of the gap:
|
||
- `src/AcDream.App/UI/UiCheckboxBitfield64.cs:194-201` quotes `Refresh` as
|
||
`checked = (lowHit | highHit) != 0` and stops there — the `allSet` computation and
|
||
the entire media branch are absent from both the code and the citation.
|
||
- `RefreshRowVisuals` (`:226-230`) writes only `row.Toggle.Selected`.
|
||
- `grep -i '0x10000082\|0x10000083\|10000328\|0x10000084' src/AcDream.App/**/*.cs`
|
||
returns exactly **one** hit, a prose mention at `UiCheckboxBitfield64.cs:28`. The two
|
||
DIDs are read by no code path; `ElementReader` does not project them onto
|
||
`ElementInfo` either.
|
||
- They are unambiguously authored: decoded from `options_2100002B.json`, element
|
||
`0x10000520` DirectState carries `P0x10000082 = DataID(0x06004D17)` and
|
||
`P0x10000083 = DataID(0x06004D19)` alongside `P0x64`.
|
||
- `docs/architecture/retail-divergence-register.md` gained AD-73 only. No AP/TS row
|
||
names this.
|
||
|
||
Adjacent and in the same fix: retail's `CreateChildren` tail is
|
||
`ResizeTo(GetWidth(), CalculatePaperSize(0, -1))` at `0x00485E87`/`0x00485E97` — the
|
||
block sizes itself to its rows because it is a non-scrolling `UIElement_ListBox`
|
||
nested inside the Chat page's scroller. `UiCheckboxBitfield64` tracks `_contentHeight`
|
||
(`:53`, `:174-175`) but never applies it to `Height`, so the block stays at its
|
||
authored 272×100 while thirteen 20 px rows need 260 px. Also unported, also unrowed.
|
||
|
||
**Required:** one AP row covering both (unported `Refresh` media branch + unported
|
||
`ResizeTo`/`CalculatePaperSize`), naming `0x10000082`/`0x10000083`/`0x10000328` and
|
||
pointing at OP5 as the consumer gate; and a corrected `IsSet` doc comment that quotes
|
||
`Refresh` completely instead of half. The port itself can land at OP5 with the
|
||
consumer — nothing user-reachable observes it today.
|
||
|
||
### 2.2 SHOULD-FIX — `AddChild` drops retail's tooltip and per-row index stamp
|
||
|
||
`CreateChildren @0x00485E50`/`@0x00485E65` calls `SetStringInfo(cb, siLabel)` **and**
|
||
`SetTooltip(cb, siTooltip)`; `@0x00485E3E` stamps `SetAttribute_Int(cb, 0x10000084,
|
||
i)`. acdream sets `checkbox.Label` (`:169`) and stores `tooltip` in the `Row` record
|
||
(`:178`) but never applies it; there is no settable tooltip on `UiElement` (only a
|
||
virtual `GetTooltipText()` returning null, `UiElement.cs:454`), so this needs a
|
||
one-line override or a settable property. The `0x10000084` index stamp is *correctly*
|
||
replaced by a mask closure (`:171`) — equivalent and better typed; call that out in
|
||
the doc rather than leaving it silently absent.
|
||
|
||
### 2.3 SHOULD-FIX — the ListBox mechanism is now implemented twice, divergently
|
||
|
||
`UIOption_CheckboxBitfield64` **is** a `UIElement_ListBox` in retail (it calls
|
||
`AddItemFromTemplateList`/`GetItem`/`CalculatePaperSize`/`ListenToElementMessage` on
|
||
itself; `PostInit` tail-calls `UIElement_Scrollable::PostInit`). The rework's class
|
||
doc argues against reusing `UiTemplateListBox` because "retail's block does not itself
|
||
scroll" — an assertion with no citation, and one the `CalculatePaperSize`/`ResizeTo`
|
||
tail partly contradicts (the block *does* do ListBox self-sizing). The result is two
|
||
independent row-stacking implementations that already differ: `UiTemplateListBox`
|
||
stacks into a lazily-created `UiScrollablePanel`
|
||
(`UiTemplateListBox.cs:172-187`), `UiCheckboxBitfield64` stacks directly onto itself
|
||
(`:173-176`). Not a defect today; it is the seam where §2.1's `ResizeTo` gap and any
|
||
future ListBox fix will diverge. Either reuse the class or record why in the register
|
||
alongside §2.1's row.
|
||
|
||
---
|
||
|
||
## 3. NOTES
|
||
|
||
1. **`UiTemplateListBox.ConsumesDatChildren => true`** (`:145`) is the one place the
|
||
dormant subclass is not byte-identical to the `UiDatElement` fallback. Inert today
|
||
(all ten pre-existing Type-5 elements have zero authored children — verified across
|
||
every committed fixture), unregistered, and silent if a future Type-5 authors
|
||
children. One sentence in AD-73 or its own row.
|
||
2. **`UiTemplateListBox.Scroll`** (`:107`) is a side-effecting getter — reading it
|
||
materialises and attaches the viewport, breaking dormancy without any explicit
|
||
activation call. `ContentHeight` (`:111`) and `LineHeight` (`:116-124`) are both
|
||
null-safe; `Scroll` is the odd one out. Nothing reads it today.
|
||
3. **AD-73 scopes dormancy to `UiTabPanel` only.** `UiTemplateListBox`'s lazy viewport
|
||
is the same adaptation against retail's always-scrollable ListBox and rides
|
||
uncovered.
|
||
4. **`SetupTabPageHash`'s early return is unported.** At `0x0046C4EE`/`0x0046C4F5`
|
||
retail does `GetChildRecursive(this, tabId)` and, on a miss, **returns outright** —
|
||
abandoning the remaining entries and never calling `Update`. `UiTabPanel` logs the
|
||
miss, keeps iterating, and still performs the default switch (`:149-173`). Inert on
|
||
today's data (every options entry resolves) and arguably the better diagnostic, but
|
||
it is a divergence the round-1 SHOULD-FIX 3 response introduced.
|
||
5. **`tab != page` and `MasterPropertyId == 0x2F` filters still unimplemented**
|
||
(`0x0046C3A6`, `0x0046C46B`). Retail adds the hash entries either way and only
|
||
gates the default-flag read and `SetMouseVisible(1)` on `tab != page`. Harmless
|
||
today. Carried from round-1 NOTE 12.
|
||
6. **`SetMouseVisible(1)` per tab button** (`0x0046C4FB`) and
|
||
**`BroadcastElementMessage(this, 0x2C, …)`** after each `Update` remain unported.
|
||
OP3 needs the second for research §3.6's apply/revert semantics. Carried from
|
||
round-1 NOTE 14.
|
||
7. **`0x71` (horizontal scrollbar) still unread**; `ScrollbarElementId` is specifically
|
||
the *vertical* link (`UIElement_Scrollable::OnSetAttribute @0x00474630`) and is
|
||
still not named as such. Carried from round-1 NOTE 13.
|
||
8. **`SaveCurrentValue`/`RestoreSavedValue`/`Changed`** (`@0x004856A0`–`@0x00485700`)
|
||
are unported on `UiCheckboxBitfield64` — the Apply/Cancel triad OP5 needs. Not
|
||
introduced here; `SetDefaultValue`/`RestoreDefaultValue` shipped in OP2 as-is.
|
||
9. **`SpriteResolve` and `LabelFont` are now dead** on `UiCheckboxBitfield64` — the
|
||
factory no longer sets `SpriteResolve` (the row subtree carries its own resolver)
|
||
and `LabelFont`'s own doc says it is unused (`:80-82`). Harmless; delete at OP5.
|
||
10. **The campaign plan is stale.**
|
||
`docs/plans/2026-08-10-options-panel-campaign.md:181` still says "Create
|
||
`src/AcDream.App/UI/UiTabControl.cs`" — the exact fictional-class name this round
|
||
corrected — and still cites "lane A §5" for the switching contract when the tab
|
||
table is §1.3 and its host §1.4 (round-1 NOTE 18, unaddressed). OP3/OP8 will read
|
||
that line.
|
||
11. **Reader tests use only `Enum`-kind struct members**, so `ReadStructMemberId`'s
|
||
`Enum`/`DataId`/`Integer` coercion tolerance is unpinned.
|
||
12. **`IUiChildrenAttachedListener` now has exactly one implementer with an empty
|
||
body** (`UiTabPanel.cs:126-128`). Still the right hook for OP3's activation
|
||
plumbing; just no longer load-bearing in this commit.
|
||
13. Research §1.5's "`0x10000521` … UNKNOWN consumer" / §10.5 U10 are now closed by
|
||
the fixture + `CreateChildren`, and the research doc still says UNKNOWN. Carried
|
||
from round-1 NOTE 17.
|
||
|
||
---
|
||
|
||
## 4. What I re-derived independently this round
|
||
|
||
Everything below was read from primary source, not taken from the commit message:
|
||
|
||
| Claim | Source |
|
||
|---|---|
|
||
| `SetupTabPageHash` has exactly one caller, `OnSetAttribute` on property `0x2E` | `acclient_2013_pseudo_c.txt` `0x0046C6C0`–`0x0046C6DC` (grep: 2 hits total for the symbol) |
|
||
| Zeroed tokens + `Update(0,0)` ⇒ no default switch | `0x0046C312`, `0x0046C318`, `0x0046C31E`, `0x0046C4DF`, `0x0046C5EB`; `Update` guard `0x0046BD00` |
|
||
| Missing `0x30`/`0x31` skips the entry | `0x0046C3E5`, `0x0046C3FC` → `label_46c510` → `label_46c594` |
|
||
| `Refresh` computes all-set AND any-set, and uses all-set for the LED media swap | `0x004859F9`–`0x00485AA7` (full function read) |
|
||
| `SetBitsOnOrOff` = OR-on / AND-NOT-off | `0x004857D0`–`0x0048580B` |
|
||
| `CreateChildren` = per-row `AddItemFromTemplateList(0)` + `GetChildRecursive(0x10000219)` + index/label/tooltip + `ResizeTo(CalculatePaperSize)` | `0x00485DF0`–`0x00485E97` |
|
||
| `0x10000520` authors `P0x64 = [{0x2100002B, 0x10000521}]`, `P0x10000082 = 0x06004D17`, `P0x10000083 = 0x06004D19`, zero children, empty StateMedia | decode of committed `options_2100002B.json` |
|
||
| `0x10000521` is `T=3`, 272×20, one child `0x10000219` | same |
|
||
| Every Type-5 element in every committed fixture has zero authored children | walk of all 32 committed fixtures |
|
||
| Tab-table entry counts unchanged post-regeneration (character 3 / combat 8 / spellbook 2 / vendor 3 / options 4) | same walk |
|
||
| Vendor `0x1000008D` is `T=8` with no tab table (the backdrop) | same walk |
|
||
|
||
Fixture decodes used a throwaway reader in the scratchpad. Nothing in the repository
|
||
was modified except this document.
|