acdream/docs/research/2026-08-11-op2-review-blast.md
Erik 26b119354d docs: OP2 dual-lens review findings — double REJECT (re-classing blast radius)
Blast lens: the unguarded Type-8 arm re-classes five pre-existing
elements in four shipped panels (vendor backdrop sprite stops drawing;
character sheet + spellbook swallow clicks; IUiDatStateful propagation
severed), the Type-5 guard's premise is factually wrong (ten shipped
elements author non-empty 0x64), and the 27 stale fixtures make the
harness structurally blind to both — the green suite was true but
meaningless. Merge semantics, fixture provenance, hermeticity, and the
children-attached hook all verified clean.

Mechanism lens: readers decode correctly through the canonical
effective-state path; Type 8's real retail class is UIElement_Panel
(Update @0x0046BD00 — switching behavior confirmed faithful); both
claimed structural identities (UIOption_Slider = horizontal scrollbar,
UIOption_Menu = UiMenu shape) CONFIRMED from fixture fingerprints;
U10 closed (0x10000521 is the bitfield row template, consumed by
CreateChildren @0x00485DF0 via AddItemFromTemplateList(0)); TS-72 is
backwards — the toggle math is decomp-confirmed right, IsSet's all-bits
predicate is confirmed wrong (retail Refresh checks ANY mask bit).
Missing: GetChildRecursive resolution, no-0x32 means NO default switch,
SetupTabPageHash's malformed-entry filters.

Rework round 1 follows: UiTabControl/UiTemplateListBox become
UiDatElement subclasses with dormant behavior (controller-activated),
bitfield rows build from the authored template, IsSet goes any-bit,
all 32 fixtures regenerate as the acceptance gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 00:29:08 +02:00

22 KiB
Raw Blame History

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

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

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 15 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 injectionUiTemplateListBox'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.