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>
376 lines
22 KiB
Markdown
376 lines
22 KiB
Markdown
# OP2 review — regression / blast-radius lens
|
||
|
||
**Commit under review:** `df9c7a35` "feat(ui): Campaign OP slice OP2 — tab control,
|
||
template ListBox, UIOption widget mappings"
|
||
**Reviewer lens:** what OP2 breaks OUTSIDE its own scope (Options panel).
|
||
**Date:** 2026-08-11
|
||
**Verdict: REJECT** — two independent MUST-FIX regressions on shipped panels, and a
|
||
test harness structurally incapable of observing either.
|
||
|
||
---
|
||
|
||
## 0. Executive summary
|
||
|
||
OP2's own acceptance criterion is written into the code it added:
|
||
|
||
> `src/AcDream.App/UI/Layout/DatWidgetFactory.cs:103` —
|
||
> "*(OP2 acceptance: no behavior change to any existing widget)*"
|
||
|
||
That criterion is **falsified on two independent axes**, and the justification for one
|
||
of them is a factual claim about the codebase that is demonstrably wrong:
|
||
|
||
> `DatWidgetFactory.cs:99-101` — "*an ORDINARY Type-5 ListBox that authors no template
|
||
> array (**none currently reach this factory** …) keeps falling to the generic
|
||
> UiDatElement fallback unchanged*"
|
||
|
||
**Ten** Type-5 elements reach that factory today, in five shipped panels, and **every
|
||
one of them authors a non-empty template array**. Separately, **five** Type-8 elements
|
||
in four shipped panels change widget class.
|
||
|
||
Total blast radius: **15 elements across 7 shipped, user-gated panels** — character
|
||
sheet, combat, spellbook/component book, vendor, examination, effects (positive +
|
||
negative), mini-game — silently change widget class in production.
|
||
|
||
The 12,770-test green suite is a **structural false negative**: the fixture path never
|
||
calls the code that populates the new fields, so every fixture-driven test takes the
|
||
pre-OP2 branch while production takes the new one.
|
||
|
||
This is the failure mode `docs/research/2026-08-05-c4-closeout-handoff.md` names as
|
||
binding: *a contract asserting a mechanism that does not exist*, plus *inferring a fact
|
||
you can observe*.
|
||
|
||
The new Options-panel work itself (fixtures, tab table read, template list read, the
|
||
four `UIOption_*` mappings, the 25 tests) is sound. The problem is entirely in the
|
||
un-gated widening of two `switch` arms onto pre-existing authored data.
|
||
|
||
---
|
||
|
||
## 1. Fixture-wide enumeration (the requested sweep)
|
||
|
||
Method: parsed all **32** committed fixtures under
|
||
`tests/AcDream.App.Tests/UI/Layout/fixtures/` (27 pre-existing + 5 new), walked every
|
||
`ElementInfo` node, and recorded (a) the node `Type`, (b) whether any state authors dat
|
||
property `0x2E` / `0x64` / `0x72`, and (c) whether the JSON carries the new serialized
|
||
fields.
|
||
|
||
### 1.1 Type-8 (`UIElement_TabControl`) — 6 occurrences, 5 of them pre-existing
|
||
|
||
| Fixture | Element | Children | Authors `0x2E`? | Tab entries | Pre-OP2 widget | Post-OP2 widget |
|
||
|---|---|---|---|---|---|---|
|
||
| `character_2100002E.json` | `0x10000227` (**panel ROOT**) | 7 | yes | **3** | `UiDatElement` | `UiTabControl` |
|
||
| `combat_21000073.json` | `0x100000A2` | 16 | yes | **8** | `UiDatElement` | `UiTabControl` |
|
||
| `spellbook_21000034.json` | `0x100002A8` (**panel ROOT**) | 5 | yes | **2** | `UiDatElement` | `UiTabControl` |
|
||
| `vendor_21000012_100000B7.json` | `0x100000B8` | 6 | yes | **3** | `UiDatElement` | `UiTabControl` |
|
||
| `vendor_21000012_100000B7.json` | `0x1000008D` | 0 | **no** | 0 | `UiDatElement` | `UiTabControl` |
|
||
| `options_2100002B.json` | `0x10000208` | 10 | yes | 4 | *(new)* | `UiTabControl` ✅ |
|
||
|
||
Authored tab tables actually present in the pre-existing panels (verbatim from the
|
||
fixtures, `{0x30 button, 0x31 page, 0x32 isDefault}`):
|
||
|
||
- character `0x10000227`: `(0x10000228→0x1000022B, default)`, `(0x10000229→0x1000022C)`,
|
||
`(0x100003F8→0x100003F9)`
|
||
- combat `0x100000A2`: 8 rows, `(0x10000063→0x1000006A, default)` … `(0x10000382→0x10000383)`
|
||
- spellbook `0x100002A8`: `(0x100002A9→0x100002AC, default)`, `(0x100002AA→0x100002AD)`
|
||
- vendor `0x100000B8`: `(0x100000B9→0x1000007C, default)`, `(0x100000BA→0x10000084)`,
|
||
`(0x100000BB→0x1000008D)`
|
||
|
||
### 1.2 Type-5 (`UIElement_ListBox`) — 16 occurrences, 10 of them pre-existing
|
||
|
||
**Every pre-existing Type-5 element authors a NON-EMPTY property `0x64`.**
|
||
|
||
| Fixture | Element | `0x64` rows | `0x72` | Controller constant | Post-OP2 (production) |
|
||
|---|---|---|---|---|---|
|
||
| `character_2100002E.json` | `0x1000023D` ×2 | **5** | `0x1000023E` | `CharacterStatController.ListBoxId` | `UiTemplateListBox` |
|
||
| `character_2100002E.json` | `0x10000532` | **1** | `0x10000533` | — | `UiTemplateListBox` |
|
||
| `effects_positive_2100001B.json` | `0x10000123` | **1** | `0x10000124` | `EffectsUiController.ListId` | `UiTemplateListBox` |
|
||
| `effects_negative_2100001B.json` | `0x10000123` | **1** | `0x10000124` | `EffectsUiController.ListId` | `UiTemplateListBox` |
|
||
| `examine_2100006B_100005F2.json` | `0x10000149` | **1** | — | `AppraisalUiController.CreatureStatsListId` | `UiTemplateListBox` |
|
||
| `examine_2100006B_100005F2.json` | `0x1000032D` | **1** | — | `AppraisalUiController.SpellFormulaListId` | `UiTemplateListBox` |
|
||
| `examine_2100006B_100005F2.json` | `0x10000335` | **1** | — | `AppraisalUiController.CreatureExtraListId` | `UiTemplateListBox` |
|
||
| `mini_game_2100001E.json` | `0x10000174` | **1** | — | — | `UiTemplateListBox` |
|
||
| `spellbook_21000034.json` | `0x10000464` | **2** | `0x10000465` | `SpellbookWindowController.ComponentListId` | `UiTemplateListBox` |
|
||
| `options_*` (3 new) | `0x100001FA` / `0x10000200` / `0x1000050D` | 3 / 8 / 9 | yes | *(new)* | `UiTemplateListBox` ✅ |
|
||
|
||
### 1.3 The four `0x100000xx` UIOption class ids — **zero** pre-existing occurrences
|
||
|
||
| Class id | Occurrences | Files |
|
||
|---|---|---|
|
||
| `0x10000036` CheckboxSlider | 2 | `options_2100002B.json` only |
|
||
| `0x10000037` Slider | 4 | `options_2100002B.json` only |
|
||
| `0x10000038` Menu | 1 | `options_2100002B.json` only |
|
||
| `0x10000044` CheckboxBitfield64 | 1 | `options_2100002B.json` only |
|
||
|
||
**Clean.** These four arms are the only genuinely additive part of the switch change.
|
||
No pre-existing layout carries any of them.
|
||
|
||
### 1.4 New-field presence per fixture (fixture-provenance check)
|
||
|
||
`System.Text.Json` with `IncludeFields = true` serializes *every* public field, so a
|
||
fixture generated by the current generator necessarily contains `Outline`,
|
||
`OutlineColor`, `TabTable`, `TemplateList`, and `ScrollbarElementId` on every node.
|
||
|
||
| Group | Files | `Outline`/`OutlineColor` | `TabTable`/`TemplateList`/`ScrollbarElementId` |
|
||
|---|---|---|---|
|
||
| The 5 new Options fixtures | `options_2100002B`, `options_gameplay_2100002A`, `options_character_21000028`, `options_chat_2100005C`, `options_config_21000029` | **present** | **present** |
|
||
| `chat_2100006f.json` | regenerated at CH6a | present | absent |
|
||
| The other 26 pre-existing | all remaining | **absent** | **absent** |
|
||
|
||
---
|
||
|
||
## 2. MUST-FIX findings
|
||
|
||
### MUST-FIX 1 — Type-8 arm silently re-classes 5 elements in 4 shipped panels
|
||
|
||
**Site:** `src/AcDream.App/UI/Layout/DatWidgetFactory.cs:110`
|
||
```csharp
|
||
8 => new UiTabControl(info.TabTable),
|
||
```
|
||
Unlike the Type-5 arm, this one carries **no guard at all** — every Type-8 element in
|
||
every layout now becomes a `UiTabControl`.
|
||
|
||
`UiTabControl` (`src/AcDream.App/UI/UiTabControl.cs:42`) derives from bare `UiElement`.
|
||
`UiDatElement` (`src/AcDream.App/UI/Layout/UiDatElement.cs:31`) is
|
||
`UiElement, IUiDatStateful` and its constructor sets `ClickThrough = true`
|
||
(`UiDatElement.cs:111`). Four independent consequences:
|
||
|
||
**(a) Lost background art — a straight rendering regression.**
|
||
Vendor `0x1000008D` is 800×20 and authors a DirectState sprite:
|
||
`StateMedia[""] = (100687632, DrawMode 1)`. `RetailUiRuntime.cs:2287` documents it by
|
||
name: *"backdrop element `0x1000008D` provides its own fill"*. It has **zero children**
|
||
and **no `0x2E` property** — it is pure chrome that happens to carry retail Type 8.
|
||
`UiTabControl` has no media plumbing whatsoever, so **the vendor panel's backdrop fill
|
||
stops drawing.** This is the clearest single defect in the commit.
|
||
|
||
**(b) `ClickThrough` inverts `true → false` on all five.**
|
||
`UiElement.ClickThrough` is an auto-property defaulting to `false`
|
||
(`UiElement.cs:159`); `UiElement.HitTest` gates on it at `UiElement.cs:582-583`
|
||
(`if (ClickThrough) return null; return OnHitTest(...) ? this : null;`), and
|
||
`OnHitTest` defaults to a plain bounds test (`UiElement.cs:421-422`). Two of the five
|
||
are **panel roots** (character `0x10000227`, spellbook `0x100002A8`), so the entire
|
||
character sheet and the entire spellbook now claim every pointer hit inside their
|
||
bounds instead of passing unclaimed clicks through to the window shell behind them.
|
||
|
||
**(c) Lost `IUiDatStateful` state propagation.**
|
||
`LayoutImporter.cs:168-169` runs `stateful.TrySetRetailState(...)` only for
|
||
`IUiDatStateful` widgets; `UiDatElement.TrySetRetailState` recurses into children
|
||
(`UiDatElement.cs:98-99`). `UiTabControl` does not implement the interface, so the
|
||
authored default-state propagation into the 7 / 16 / 5 / 6 children of these four tab
|
||
hosts silently stops. This is the exact mechanism the comment at
|
||
`LayoutImporter.cs:164-167` says the hook exists to preserve.
|
||
|
||
**(d) Import-time visibility + click rebinding on controller-owned tabs.**
|
||
`UiTabControl.OnChildrenAttached` (`UiTabControl.cs:65-81`) runs *during*
|
||
`LayoutImporter.Build`, before any controller wiring, and calls
|
||
`RetailTabBinding.SetClick` (which also sets `ClickThrough = false` on each tab button,
|
||
`RetailTabBinding.cs:16`) plus `SwitchTo(default)` → `page.Visible = active` and
|
||
`RetailTabBinding.SetOpen`. All four panels already own these tabs
|
||
(`CharacterStatController.cs:428-432`, `SpellbookWindowController.cs:138-139`,
|
||
`VendorUiController.cs:587-589`).
|
||
|
||
Mitigating detail, and it is *luck*, not design: on 3 of the 4 panels the tab table's
|
||
ids do **not** match the resolved child ids, so `FindDirectChild` misses and `SwitchTo`
|
||
is inert — vendor's table names pages `0x1000007C/0x10000084/0x1000008D` while the
|
||
controller and the actual children use `0x100000BC/0x100000C4/0x100000CD`; combat's
|
||
table names `0x1000006A..0x10000383` while the children are `0x100000AA..0x100005C3`.
|
||
Only spellbook and character resolve, and both controllers happen to re-assert
|
||
visibility afterwards. **Nothing in the commit establishes or tests this ordering.**
|
||
|
||
Worth flagging on its own: `CharacterStatController.cs:439-449` carries an explicit
|
||
comment — *"WHY this cannot be done in the importer … The controller is the correct and
|
||
only place for initial tab-page selection"* — that OP2 now contradicts for four panels
|
||
without amending it.
|
||
|
||
### MUST-FIX 2 — Type-5 guard rests on a false premise; 10 elements flip in production
|
||
|
||
**Site:** `src/AcDream.App/UI/Layout/DatWidgetFactory.cs:104-105`
|
||
```csharp
|
||
5 when info.TemplateList.Count > 0 => new UiTemplateListBox(
|
||
info.TemplateList, info.ScrollbarElementId),
|
||
```
|
||
The guard is correct *in the fixture path* and useless *in production*, because
|
||
`info.TemplateList` is populated by different code on the two paths:
|
||
|
||
- **Fixture path:** `FixtureLoader.LoadInfos` (`FixtureLoader.cs:253-265`) deserializes
|
||
straight into `ElementInfo` and hands it to `LayoutImporter.Build`.
|
||
`ApplyCanonicalLegacyProjection` is **never called**. Stale fixtures carry no
|
||
`TemplateList` key → the field initializer's empty list survives → guard is `false`
|
||
→ old `UiDatElement` behavior. Tests are green.
|
||
- **Production path:** `LayoutImporter.ToInfo` calls
|
||
`ElementReader.ApplyCanonicalLegacyProjection(info)` (`LayoutImporter.cs:465`), which
|
||
runs `info.TemplateList = ReadTemplateList(info)` from property `0x64`. Guard is
|
||
`true` for all ten → `UiTemplateListBox`.
|
||
|
||
Section 1.2 shows all ten pre-existing Type-5 elements author 1–5 valid
|
||
`{0x63 layoutDid, 0x62 elementId}` rows. Six of them are named controller constants
|
||
(`AppraisalUiController.CreatureStatsListId` / `CreatureExtraListId` /
|
||
`SpellFormulaListId`, `CharacterStatController.ListBoxId`, `EffectsUiController.ListId`,
|
||
`SpellbookWindowController.ComponentListId`).
|
||
|
||
Consequences: same `IUiDatStateful` loss and same media loss as MUST-FIX 1, plus a
|
||
**spurious child injection** — `UiTemplateListBox`'s constructor unconditionally does
|
||
`base.AddChild(_viewport)` (`UiTemplateListBox.cs:94`), so every one of these ten list
|
||
boxes gains a full-size anchored `UiScrollablePanel` as its first child. Since
|
||
`UiPanel` defaults `ClickThrough = false` (confirmed by the comment at
|
||
`UiPanel.cs:157-159`), that transparent viewport participates in hit-testing across the
|
||
whole list-box rect, competing with the `UiItemList` each controller adds afterwards on
|
||
an untested Z-order tie-break.
|
||
|
||
Mitigating: the consumers all use `FindElement(...)` returning `UiElement?` with an
|
||
`is UiItemList` test plus an else-branch (`EffectsUiController.cs:90-94`,
|
||
`SpellbookWindowController.cs:196-199`, `AppraisalUiController.cs:169`,
|
||
`:241-243`, `:313-315`), so no controller *fails to mount*. The damage is confined to
|
||
rendering, hit-testing, and state propagation — but it is unreviewed and unmeasured.
|
||
|
||
### MUST-FIX 3 — the harness cannot observe either change (the root enabler)
|
||
|
||
`ApplyCanonicalLegacyProjection` has exactly two call sites, both DAT-only:
|
||
`LayoutImporter.cs:465` (`ToInfo`) and `ElementReader.cs:410` (`Merge`). The fixture
|
||
path reaches neither. Therefore:
|
||
|
||
- Every fixture-driven test exercises the **pre-OP2** branch for all 15 affected
|
||
elements while production exercises the **post-OP2** branch. The "12,770 passed /
|
||
0 failed, zero regressions" claim in the commit message is true and meaningless for
|
||
this change.
|
||
- The one existing test that touches an affected element,
|
||
`RetailFixtureConformanceTests.cs:100`
|
||
(`{ FixtureLoader.LoadCharacterInfos, 0x10000227u, 8u, 300f, 600f, 60 }`), asserts the
|
||
**`ElementInfo.Type`**, not the built widget class — it already knew `0x10000227` is
|
||
Type 8 and still could not catch this.
|
||
- `RetailLayoutFixtureGenerator.cs` now regenerates **all 32** layouts. The moment the
|
||
coordinator runs `ACDREAM_REGENERATE_UI_FIXTURES=1`, the 27 stale fixtures gain
|
||
populated `TabTable`/`TemplateList`, and the suite flips to production shapes for all
|
||
15 elements in one commit, with no test written to explain the diff.
|
||
|
||
Answering the task's forward-looking question directly: **no committed test depends on
|
||
fields present only in new-shape fixtures.** The exposure is the inverse and worse —
|
||
tests currently depend on those fields being *absent*.
|
||
|
||
**Minimum fix shape (recommendation, not a mandate):** narrow both arms so they cannot
|
||
reach a pre-existing element — e.g. gate on the Options LayoutDesc ids, or on the
|
||
authored-tab-table ids actually resolving to direct children, or (cleanest) require
|
||
`info.TabTable.Count > 0 && every ButtonElementId/PageElementId resolves` before
|
||
choosing `UiTabControl`, and make `UiTabControl`/`UiTemplateListBox` carry the same
|
||
media + `IUiDatStateful` behavior `UiDatElement` provides. Whatever the shape, land a
|
||
test that builds the four affected pre-existing layouts **through the DAT-path
|
||
projection** (call `ApplyCanonicalLegacyProjection` over the fixture tree before
|
||
`Build`) and asserts the resolved widget class for all 15 ids.
|
||
|
||
---
|
||
|
||
## 3. SHOULD-FIX
|
||
|
||
### SHOULD-FIX 1 — `UiTemplateListBox` injects a hit-testable viewport unconditionally
|
||
|
||
`UiTemplateListBox.cs:94` (`base.AddChild(_viewport)`) runs in the constructor, before
|
||
`LayoutImporter.BuildWidget` appends the element's own dat children. The widget does not
|
||
set `ConsumesDatChildren`, so authored children become **siblings of the viewport rather
|
||
than rows inside it** — silently correct today only because all three Options ListBoxes
|
||
author zero children, and a latent trap for OP3+ the moment one doesn't. Combined with
|
||
`UiPanel`'s `ClickThrough = false` default this is the mechanism behind MUST-FIX 2's
|
||
hit-testing exposure.
|
||
|
||
### SHOULD-FIX 2 — the tab table's ids are base-layout ids on inherited layouts
|
||
|
||
`UiTabControl.FindDirectChild` (`UiTabControl.cs:105-111`) matches
|
||
`child.DatElementId` against the raw `0x2E` values. On three of the four pre-existing
|
||
panels those values do not correspond to any child in the *incorporated* tree (§2,
|
||
MUST-FIX 1(d)) — strongly suggesting property `0x2E` carries **base-layout** ids that
|
||
`LayoutImporter.IncorporateChildren` remaps. The Options panel happens to author its
|
||
tab table with post-incorporation ids, so OP2's own tests pass. This will bite OP3+
|
||
directly, because the Options pages mount cross-layout via `BaseLayoutId`/`BaseElement`
|
||
— exactly the case where the remap applies. Worth resolving now rather than
|
||
rediscovering it as an OP4 mystery.
|
||
|
||
### SHOULD-FIX 3 — silent no-resolve is indistinguishable from correct behavior
|
||
|
||
`OnChildrenAttached` and `SwitchTo` both treat a `FindDirectChild` miss as a no-op
|
||
(`UiTabControl.cs:70-72`, `:95-99`). A tab control whose entire table fails to resolve
|
||
(combat, today) is behaviorally identical to one with no table at all. Given
|
||
SHOULD-FIX 2, a diagnostic line on unresolved entries would have surfaced this during
|
||
implementation.
|
||
|
||
---
|
||
|
||
## 4. NOTEs (verified clean, or informational)
|
||
|
||
**NOTE 1 — `ElementReader.Merge` handles the three new fields correctly.** Verified.
|
||
`Merge` (`ElementReader.cs:344-412`) does *not* add scalar merge rules for `TabTable`,
|
||
`TemplateList`, or `ScrollbarElementId`, and does not need to: it merges the **state
|
||
property tables** first (`:400-408`, `UiStateInfo.Merge` giving derived-wins per
|
||
property) and then calls `ApplyCanonicalLegacyProjection(m)` at `:410`, which recomputes
|
||
all three from the merged effective state. Base-only `0x64` survives; derived `0x64`
|
||
replaces base wholesale (correct — arrays are whole-value properties, not concatenated);
|
||
neither is doubled. **No existing `Merge` behavior changed** — the new field reads are
|
||
appended after the existing `Outline`/`OutlineColor` block in
|
||
`ApplyCanonicalLegacyProjection` and touch no prior line. This addresses focus point 2
|
||
in full: nothing here can drop or double a template list for the OP3+ cross-layout
|
||
mounts.
|
||
|
||
**NOTE 2 — fixture-drift handling is exactly as described.** `git show --stat df9c7a35`
|
||
lists 5 fixture files, all `A` (added), zero `M`. The 27 pre-existing fixtures are
|
||
byte-untouched by this commit. And per §1.4 the 5 new fixtures **do** carry
|
||
`Outline`/`OutlineColor` (plus the three new fields), proving they came from the current
|
||
generator rather than being hand-built or stale-sourced. This focus-3 MUST-FIX condition
|
||
is **satisfied**.
|
||
|
||
**NOTE 3 — the 25 new tests are hermetic.**
|
||
`OptionsPanelLayoutConformanceTests.cs` contains exactly 25 `[Fact]`s (matching the
|
||
commit message) and zero `IDatReaderWriter` / `DatCollection` references; every test
|
||
loads a committed fixture through `FixtureLoader`. The generator is env-gated
|
||
(`RetailLayoutFixtureGenerator.cs:42-50`, early-returns unless
|
||
`ACDREAM_REGENERATE_UI_FIXTURES == "1"`), so it is inert on a machine without DATs. The
|
||
csproj copies fixtures by wildcard (`AcDream.App.Tests.csproj:28`,
|
||
`UI\Layout\fixtures\*.json`), so the five new files need no registration. **The suite
|
||
remains green without the machine DAT.** Focus point 5: clean.
|
||
|
||
**NOTE 4 — `IUiChildrenAttachedListener` cannot double-fire.** The hook is invoked
|
||
exactly once per widget instance, at `LayoutImporter.cs:174-175`, immediately after that
|
||
widget's own child loop and before `BuildWidget` returns. Depth-first ordering means a
|
||
nested listener fires before its ancestor, which is the correct order for
|
||
cross-reference resolution. Nested `LayoutImporter.Build` calls from
|
||
`UiTemplateListBox.TemplateResolver` create fresh widget instances, so no re-entry
|
||
hazard. Focus point 4's "fire twice / wrong subtree" concern: **not present.**
|
||
|
||
**NOTE 5 — wheel / drag / clipping forwarding is structurally fine.** Because
|
||
`_viewport` is anchored `Left|Top|Right|Bottom` and `UiElement.HitTest` walks children
|
||
before gating on the parent (`UiElement.cs:571-583`), pointer and wheel events land on
|
||
the `UiScrollablePanel` directly — no forwarding shim is needed, and the scroll model,
|
||
per-row clipping, and `LineHeight` quantum are the same `UiScrollablePanel` code the
|
||
character sheet and CH6 already exercise. The anchor-capture behavior for a viewport
|
||
constructed at 0×0 inside a sized host is *not* covered by any of the 25 tests; low risk,
|
||
but unmeasured.
|
||
|
||
**NOTE 6 — `UiOptionToggleSlider` first-match binding.**
|
||
`UiOptionToggleSlider.OnChildrenAttached` (`UiOptionToggleSlider.cs:33-42`) binds the
|
||
first `UiButton` and first `UiScrollbar` child. The `else if` is harmless (a child cannot
|
||
be both types). Zero pre-existing layouts carry class `0x10000036`, so no blast radius —
|
||
noted only because the pattern silently picks the first match rather than the authored
|
||
`0x10000219` / `0x1000021C` ids the class doc cites.
|
||
|
||
**NOTE 7 — per-element allocation in the hot projection path.**
|
||
`ApplyCanonicalLegacyProjection` now allocates two fresh `List<>` instances
|
||
(`ReadTabTable`, `ReadTemplateList`, `ElementReader.cs:490-497`) for **every element on
|
||
every call**, including the ~99% that author neither property, and `Merge` calls it once
|
||
per inheritance level. Import is startup-only and not on a frame path, so this is
|
||
cosmetic — but an early `TryGetEffectiveProperty` bail before allocating would be free.
|
||
|
||
**NOTE 8 — TS-72 is correctly filed.** The register hunk
|
||
(`docs/architecture/retail-divergence-register.md`) adds TS-72 for
|
||
`UiCheckboxBitfield64`'s click-toggle bit math and bumps the count 40 → 41. Correct per
|
||
the register rules; the widget is genuinely unreachable today. No comment on the bit
|
||
math itself — outside this lens.
|
||
|
||
---
|
||
|
||
## 5. Verdict
|
||
|
||
**REJECT.**
|
||
|
||
The additive half of OP2 (five fixtures, the three `ElementReader` property readers, the
|
||
`Merge` interaction, the `IUiChildrenAttachedListener` hook, the four `UIOption_*` class
|
||
ids, the 25 hermetic tests) is well-built and clean under this lens. But two `switch`
|
||
arms were widened onto authored data the commit believed was absent, changing the widget
|
||
class of 15 elements across 7 shipped, user-gated panels — including a documented
|
||
rendering backdrop that now draws nothing — and the harness that reported "zero
|
||
regressions" is structurally incapable of seeing any of it. The re-review gate should be
|
||
the DAT-path projection test described at the end of §2.
|