acdream/docs/research/2026-08-12-fa3-review-blast.md
Erik e9eb756480 docs: FA3 blast-radius review -- APPROVE-WITH-FIXES (1 MUST-FIX, 6 SHOULD-FIX)
MUST-FIX 1: the Friends/Squelch lists have NO scroll driver. Both
controllers set TemplateResolver but never wire the authored scrollbar's
Model (0x10000518 / 0x10000543, both direct siblings of their ListBox
under the page root per FA3's own fixture), unlike all four existing
UiTemplateListBox consumers. There is no wheel fallback -- wheel scroll
lives only on UiText, not UiScrollablePanel -- so Scroll has no driver at
all. Visible at authored size: the 400/430-tall ListBoxes sit at y=40 in
a 362-tall panel, so rows past ~322px are off-panel AND unreachable.
AD-79 covers the inert BUTTONS, not a dead scrollbar.

SHOULD-FIX: the revision-driven rebuild does N live DAT imports under the
shared DatLock while the panel is CLOSED (first repeating consumer of a
resolver every other caller invokes once at Bind); Refresh() consumes the
revision before building so one resolver miss latches an empty list;
per-frame closure allocation in the hidden allegiance page; Flush()'s doc
omits its scroll reset and diverges from the sibling UiItemList.Flush()
it shares a name with; #383's "days ago" is really ~18-21h per git;
the SS10 addendum's trailing ** is orphaned.

Verified clean: catalog/window-name consumers all degrade safely
(SetPanelOpen(12) is a no-op, opacity controller correctly scoped by
#379); persistence omission is the documented cohort behavior; F3/F4
plumbing predates FA3 entirely (UI.Abstractions untouched) and the
apparent bare-F3 duplicate is in the non-production AcdreamCurrentDefaults;
mount order respects the DialogFactory constraint; SocialRuntimeBindings
is required-positional with one construction site; the generator is
env-gated and only the new fixture landed; +18 reconciles exactly
(23 targeted + 289 blast-radius regression tests pass on the FA3 binary);
AD-79 well-formed with the count bumped 58->59; and the corrected tab
table was independently re-read from the fixture's own TabTable
(0x1000028C -> 0x10000291, IsDefault=true -- Allegiance IS the default).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 03:14:42 +02:00

338 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Campaign FA slice FA3 — BLAST-RADIUS review
**Reviewer lens:** the axes the implementer may not have traversed — every
consumer of the tables/widgets/seams FA3 touched, not the correctness of the
social panel itself (that is the mechanism reviewer's lane).
**Target:** `0a9ca2f1`, `74c3d85d`, `b6a25110`, `d7e1cffd` on
`claude/latest-commits-cb0c8f`.
**Verdict: APPROVE-WITH-FIXES** — 1 MUST-FIX, 6 SHOULD-FIX, 1 NIT.
The slice is structurally clean: the catalog/window-name/composition/mount-order
seams were all traversed correctly and every table consumer degrades safely.
One authored control is dead, and one new per-frame path does live DAT I/O
while the panel is closed.
---
## MUST-FIX
### MF-1 — The Friends and Squelch lists have no scroll driver at all: the authored scrollbars are never wired
`SocialFriendsPageController.Bind` and `SocialSquelchPageController.Bind` set
`listBox.TemplateResolver` and stop:
- `src/AcDream.App/UI/Layout/SocialFriendsPageController.cs:65`
- `src/AcDream.App/UI/Layout/SocialSquelchPageController.cs:61`
Both ListBoxes author a scrollbar, and FA3's own committed fixture proves it —
each scrollbar is a **direct sibling of its ListBox under the page root**, so a
`FindDescendant(pageRoot, id)` would resolve it trivially:
| Page | ListBox | rect | `ScrollbarElementId` | scrollbar rect |
|---|---|---|---|---|
| Friends `0x10000513` | `0x10000517` | (8,40) 270x400 | **`0x10000518`** | (280,40) 16x400, Type 11 |
| Squelch `0x1000054A` | `0x1000053E` | (8,40) 270x430 | **`0x10000543`** | (280,40) 16x430, Type 11 |
Every other `UiTemplateListBox` consumer in the tree wires the model:
- `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs:322`
- `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs:291`
- `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs:378`
- `src/AcDream.App/UI/Layout/KeyboardConfigController.cs:277`
**There is no wheel fallback.** Wheel scrolling exists only on `UiText`
(`src/AcDream.App/UI/UiText.cs:225`, `:714-718``WheelScrollEnabled`);
`UiScrollablePanel` has no wheel handler. With `Model` unbound, the viewport's
`Scroll` has **no driver whatsoever** — the list is not merely awkward to
scroll, it is completely unscrollable.
**Impact is user-visible at the authored size, not just at large rosters.** The
panel root `0x1000018F` is 300x362. The Friends ListBox starts at y=40 and is
400 tall, so only ~322 px of it is inside the panel to begin with. Every row
past ~322 px is off-panel *and* unreachable. Squelch is worse (430-tall box in
the same 362-tall panel).
This is also an undocumented divergence: **AD-79 covers the seven inert
Friends/Squelch action *buttons*, not a dead scrollbar.** The lists themselves
are advertised as live and read-only, and a list you cannot scroll is not
fully live.
**Fix:** two lines per controller, mirroring the four existing sites — resolve
the ListBox's own `ScrollbarElementId` scoped to the page root and assign
`scrollbar.Model = listBox.Scroll`. No register row needed once wired.
---
## SHOULD-FIX
### SF-2 — The revision-driven rebuild does N live DAT imports under the shared DAT lock, while the panel is closed
`SocialPanelController.Tick()` is called unconditionally from
`src/AcDream.App/UI/RetailUiRuntime.cs:559`, with no visibility gate. Each
`AddItemFromTemplateList(0)` invokes `MountSocialPanel`'s `TemplateResolver`
(`RetailUiRuntime.cs:2626-2642`), which takes `_bindings.Assets.DatLock` and
runs a full `ImportInfos` + `Build` **per row**.
FA3 is the first consumer to call that resolver on a *repeating* schedule, and
the first to call it while its panel is hidden. Every pre-existing consumer
builds rows exactly once at `Bind`:
`KeyboardConfigController` builds all six pages once (`:255-295`), and the three
Options page controllers likewise.
So one friend logging in or out (`FriendsUpdateType.OnlineStatus`
`Interlocked.Increment(ref _revision)`,
`src/AcDream.Core/Social/FriendsState.cs:61`) rebuilds the entire roster: N lock
acquisitions and N DAT reads on the UI thread, with the panel closed. The DAT
lock is shared with world-mesh streaming.
Suggest gating `Tick` on window visibility (refresh on `OnShown` — the
`IRetainedPanelController` hook already exists and is currently unused by this
controller) and/or caching the built row template.
### SF-3 — `Refresh()` consumes the revision before building, so one transient resolver miss latches an empty list
`SocialFriendsPageController.cs:79-80` and `SocialSquelchPageController.cs:75-76`
set `_lastRevision = <state>.Revision` **first**, then `Flush()`, then add rows.
`AddItemFromTemplateList` returns null on a resolver miss and both controllers
`continue`/`return` silently. Because the revision is already consumed and the
list has already been flushed, a single failed resolve leaves the roster
**empty until the next revision bump**. The Flush-first ordering makes this
strictly worse than the one-shot consumers, which at least fail with their
authored content intact. Advance `_lastRevision` only after a successful
rebuild.
### SF-4 — Per-frame closure allocation in a panel that ticks while hidden
`SocialAllegiancePageController.Tick()`
(`src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs:107-109`):
```csharp
IReadOnlyList<UiText.Line> lines = hasProfile ? NoLines : BlankLine;
if (_monarchName is not null) _monarchName.LinesProvider = () => lines;
if (_patronName is not null) _patronName.LinesProvider = () => lines;
```
`() => lines` captures a local, so this allocates a display class plus two
delegates **every frame**, unconditionally, including while the panel is hidden
(~180 allocations/second). `NoLines` and `BlankLine` are already static
readonly; hoist two static readonly `Func<>` providers and pick between them.
Cheap to fix, and Slice I's "0 B/resolve" discipline makes new per-frame
garbage in a hidden panel worth catching now.
### SF-5 — `UiTemplateListBox.Flush()`'s doc omits that it also resets scroll position, and silently diverges from the sibling `Flush()` it shares a name with
`src/AcDream.App/UI/UiTemplateListBox.cs:239-252` documents only "resetting
`ContentHeight` to 0". `UiScrollablePanel.ClearContent()`
(`src/AcDream.App/UI/UiScrollablePanel.cs:54-61`) **also** calls
`Scroll.SetScrollY(0)`.
For a poll-and-rebuild list that is user-visible behavior: once MF-1 is fixed
and the list can scroll, a user scrolled into a long Friends roster gets yanked
to the top every time any friend's online status changes.
Worth noting explicitly because `UiItemList.Flush()`
(`src/AcDream.App/UI/UiItemList.cs:363-367`) — the established sibling this
method borrows its name from — does **not** touch scroll. Same name, different
semantics, no doc distinguishing them.
### SF-6 — #383's drift window is overstated; git narrows it to hours, not days
`docs/ISSUES.md` #383 says the two drifted fixtures were committed "(days ago,
same machine)". Git says otherwise:
- `keyboard_config_21000009.json``b4edee97`, 2026-08-11 09:19
- `options_2100002B.json``e71e5a96`, 2026-08-11 06:25
The FA3 regeneration run was 2026-08-12 ~02:58 — i.e. **~18 h and ~21 h**
earlier, the previous day. That materially tightens the investigation window
the issue asks for ("determine WHAT modified the installed DATs and when"): a
same-day change is far easier to correlate against tooling activity than a
multi-day one. Correct the parenthetical.
Everything else in #383 checks out against the evidence — see VERIFIED CLEAN 6.
### SF-7 — The §10 addendum's bold markers are unbalanced
`docs/research/2026-08-11-fa-panel-structure.md:900-916` contains five `**`
markers:
1. `**[FA3 addendum, … is WRONG.**` — opens *and closes* on the first sentence
2. `**Allegiance**` — balanced inline pair
3. `the full citation.]**` (line 916) — **orphan**, opens an emphasis run that
never closes and bleeds into the following section
The FA2 convention the commit message cites
(`docs/research/2026-08-11-fa-acdream-seams.md:286-304`) uses exactly two
markers wrapping the whole block. Note the FA3 block also contains a fenced
code block, which a markdown bold span cannot cross anyway — so the
whole-block-bold intent needs a different treatment here (e.g. bold only the
lead sentence and drop the trailing `**`).
---
## NIT
### N-8 — The gate script does not exercise the two things most likely to be wrong
`docs/research/2026-08-12-campaign-fa-test-script.md` has good coverage of the
open paths, exclusivity, tab switching, empty states, and the closed-panel live
update (§"Live update while the panel is closed" — nice catch, that is exactly
the SF-2 path). It has **no** step for:
- scrolling a Friends/Squelch list longer than the visible area — precisely
where MF-1 bites, and the user will not hit it with a short test roster;
- close the panel → log out → log back in, to confirm the restore-open
behavior is wanted (see VERIFIED CLEAN 2).
Add both before the gate, otherwise MF-1 ships silently.
---
## VERIFIED CLEAN
Stated plainly, because "no finding" here is itself the review product.
1. **Catalog / window-name consumers fully enumerated, all degrade safely.**
`OnWindowVisibilityChanged` (`RetailUiRuntime.cs:817`) now resolves panel id
12 and calls `ToolbarController.SetPanelOpen(12, …)`. That is a safe no-op:
`ToolbarController.cs:394-406` iterates `_panelButtons` and simply falls
through when no button carries the id. `SyncToolbarWindowButtons` (`:810`)
iterates `ToolbarPanels` only, where the social panel is correctly absent.
`RetailWindowOpacityController` is scoped to the five chat windows by #379
(`:152-158`, `:194-199`) so it correctly ignores the new window. There is no
UI-Studio consumer of these tables in the tree. `TryGetPanelId` /
`TryGetWindowName` are linear scans over `Mounted` with no count assumptions
anywhere.
2. **Persistence is a deliberate, documented choice, not an oversight.**
`WindowNames.SocialPanel` is intentionally NOT in
`stateManagedVisibilityWindows` (`RetailUiRuntime.cs:440-446`, whose members
are Combat/JumpPowerbar/ExternalContainer/Vendor). The seam doc states the
rule explicitly (`2026-08-11-fa-acdream-seams.md`: *"if the panel should keep
its own visibility across sessions, it must NOT be listed"*), and the
behavior matches its whole cohort — Options, Spellbook, Character, Inventory
and Vitae all restore their own open state the same way. So the
Configure-Keyboard restore-open surprise generalizes here, but as **expected
behavior consistent with every sibling**, not a new defect. Flagged for the
gate under N-8 only.
3. **F3/F4 wiring introduces no conflict and no keybinds.json impact.** The
`InputAction` members, the `RetailActionIdentityTable` mappings
(`src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs:193-194`)
and the `KeyBindings.RetailDefaults` F3/F4 chords (`KeyBindings.cs:209-210`)
**all predate FA3**`git diff b560f415 d7e1cffd -- src/AcDream.UI.Abstractions/`
is empty. FA3 adds only the handler, so the Configure Keyboard screen's
conflict universe and display are unchanged; those two actions were already
listed and rebindable, they just did nothing when pressed.
I chased the apparent duplicate bare-F3 binding at `KeyBindings.cs:119`
(→ `AcdreamDumpNearby`): it lives in `AcdreamCurrentDefaults()` (`:88-146`),
the WASD regression anchor that is **not loaded in production**, while
`RetailDefaults()` (`:147-401`) deliberately relocates that action to
**Ctrl+F3** (`:356`). No conflict in the production table; F4 is unique
there too. Nothing else consumed either action — `ToolbarInputController.Handle`
(`ToolbarInputController.cs:23-38`) handles only quick-slot and
`CreateShortcut`, so the new early-returns displace no prior behavior.
4. **Mount order respects the documented constraint.** `MountSocialPanel` is at
`RetailUiRuntime.cs:422`, immediately after `MountDialogFactory` at `:421`
satisfying the seam doc's rule (`2026-08-11-fa-acdream-seams.md:548-552`:
*"Any FA panel that raises a confirmation must be mounted after
MountDialogFactory"*). No existing panel's position in the ordered list
moved, and `RegisterMainPanel`'s duplicate-id/duplicate-name guard
(`RetailPanelUiController.cs:88-91`) would have thrown had id 12 or
`"social-panel"` collided.
5. **Composition cannot silently degrade a host.** `SocialRuntimeBindings`
(`RetailUiRuntime.cs:229-233`) is a **required positional member** of
`RetailUiRuntimeBindings` (`:337`), placed before the only optional member
(`Keyboard = null`). There is exactly one construction site in the tree —
`InteractionRetainedUiComposition.cs:635` — and no test harness constructs
the record. Any future host omitting it fails to compile rather than
silently passing null.
6. **The fixture generator is properly env-gated and the drift claim holds.**
`RegenerateAllRetailFixtures_WhenExplicitlyRequested`
(`RetailLayoutFixtureGenerator.cs:131-138`) early-returns unless
`ACDREAM_REGENERATE_UI_FIXTURES=1`, so a normal test run cannot rewrite any
fixture. I diffed all four FA3 commits against `b560f415`: the only fixture
touched is `social_panel_2100006E_1000018F.json`. The two drifted fixtures
are genuinely excluded, exactly as the commit message and #383 claim.
7. **Suite accounting reconciles exactly.** 13 `SocialPanelControllerTests` +
1 `SocialPanelLiveMountProbeTests` + 4 `RetailPanelCatalogTests` = **18**;
13,215 + 18 = **13,233**. Confirmed by `[Fact]` counts per file and by
running the targeted filter on the FA3-era Release binary (built 02:59, after
both code commits):
- `~SocialPanel|~RetailPanelCatalog`**23 passed, 0 failed** (18 new + 5
pre-existing catalog tests).
- Blast-radius regression filter
`~TemplateListBox|~OptionsPage|~KeyboardConfig|~OptionsPanel|~WindowLayoutPersistence|~Toolbar`
**289 passed, 0 failed**. No regression on any shared surface FA3 touched.
8. **`UiTemplateListBox.Flush()` is purely additive to existing consumers.** No
pre-existing consumer calls it; the four Options/Keyboard controllers are
untouched. Dormancy is correctly preserved (`_viewport?.ClearContent()` never
allocates the viewport). `ClearContent` is pre-existing and complete (clears
children, `_baseTops`, `ContentHeight`, scroll). No interaction with #371
that fix lives in `LayoutScrollableChildren`'s intersection test
(`UiScrollablePanel.cs:77+`), which `Flush` does not touch. `Flush` is also
an established name across sibling list widgets (`UiItemList`,
`CreatureAppraisalRows`, `EffectsUiController`, `VendorUiController`,
`SpellbookWindowController`, `InventoryController`,
`ExternalContainerController`), so the misuse surface is low — the only
semantic gap is SF-5's scroll-reset divergence.
9. **Register row AD-79 is well-formed.** Six populated columns (row / what /
files / why / risk-if-assumption-breaks / anchor), correctly scoped as ONE
row covering all seven controls rather than one per button, and honest about
covering both "not wired" and "not yet researched". The section header count
is correctly incremented **58 → 59 active rows** with AD-79 prepended to the
chronological summary.
10. **The §10 addendum is a clean, correctly-dated in-place correction.** Pure
insertion (+18 / 0) — it rewrites neither the original text nor any
earlier addendum, and is dated 2026-08-12. Only its bold markup is off
(SF-7).
11. **I independently verified the headline correction from the committed
fixture.** The panel root's own authored `TabTable` reads:
| ButtonElementId | PageElementId | IsDefault |
|---|---|---|
| `0x1000028C` | `0x10000291` | **true** |
| `0x1000028E` | `0x10000292` | false |
| `0x10000512` | `0x10000513` | false |
| `0x1000053B` | `0x1000054A` | false |
That matches `SocialPanelController`'s class doc and the §10 addendum
exactly. **Allegiance really is the authored default tab** — lane A's
x-order guess was wrong and the correction is sound.
12. **No stale roster across reconnect — the FA2 MUST-FIX class does not
recur.** `RuntimeCommunicationState.ResetFriends`/`ResetSquelch`
(`src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs:168-169`, and
the combined reset at `:250-251`) call `Clear()`, which increments
`Revision` — so the panel's revision poll flushes the list on reset without
any FA3-side reset plumbing.
13. **The `0x10000492`-authored-twice hazard is genuinely avoided.**
`SocialAllegiancePageController.Bind` scopes both name lookups to their own
block container (`:84-85`, `FindDescendant(monarchField, …)` /
`FindDescendant(patronField, …)`) rather than searching the page root — the
campaign-OP `0x10000211`-in-two-layouts lesson applied correctly.
---
## Gate observation (not a finding)
Registering the social panel via `RegisterMainPanel` puts it in the shared
main-panel geometry group, whose rectangle is cross-applied to every sibling
(`RetailPanelUiController.SynchronizeMainPanelSiblings``ApplyWindowGeometry`).
The social panel is authored 300x362; Options is 310x400. Height is therefore
shared across differently-authored panels — but that is **pre-existing,
deliberate cohort behavior** (the OP3 blast review's SHOULD-FIX 2 explicitly
moved Options *into* this policy), not something FA3 introduced. Worth an eye
at the gate: open Options first, then F3, and confirm the social panel is not
visibly clipped or over-tall.