acdream/docs/research/2026-08-12-fa3-review-mechanism.md
Erik 9e622f565e docs: FA3 mechanism review -- APPROVE-WITH-FIXES (2 MUST-FIX, 9 SHOULD-FIX)
Campaign FA slice FA3 mechanism-faithfulness review of 0a9ca2f1 /
74c3d85d / b6a25110 / d7e1cffd.

The load-bearing tab-table correction VERIFIED CORRECT three independent
ways: the authored 0x2E array re-parsed straight out of the committed
fixture (Allegiance 0x1000028C -> 0x10000291 is the sole IsDefault entry),
each button's caption resolved from the installed DATs via the live-mount
probe, and each page's own P0x57 + RegisterElementClass type re-read in
the pseudo-C. A fourth corroboration the addendum missed: under the
corrected pairing the default tab is also the LEFT-MOST tab (x=0).

MUST-FIX (both in the user's connected-gate script, neither code):
1. The script still carries the REFUTED x-order -- step 9 states the strip
   as "Friends, Allegiance, Fellowship, Squelch" and step 1 primes the user
   to expect Friends left-most. Real geometry: Allegiance x=0, Fellowship
   x=72, Friends x=144, Squelch x=206.
2. Step 14 tells the user `@allegiance info` should reveal the
   monarch/patron blocks and to "report if they do not" -- FA2's own fix
   round deliberately stopped 0x027C from seeding RuntimeAllegianceState,
   ApplyUpdate (0x0020) is the only writer of _hasProfile, and FA3 sends
   no 0x001F. The script steers the user into a false defect report.

SHOULD-FIX: unsupported "retail's Toggle-action semantics" claim on F3/F4
(no P0x57 read site, no OnAction handler, folded gmPanelUI global-message
stub -- the rule is acdream's OpenSpellbook precedent, not retail);
per-frame closure allocation in SocialAllegiancePageController.Tick; two
probe findings printed but never asserted (0x10000492 count, page
exclusivity); "three checkboxes" is four; AD-79 enumerates seven controls
but its cited test pins six; the two page controllers do not name AD-79;
the allegiance empty state is gated on HasProfile rather than retail's
per-relationship rule (TryGetMonarch/TryGetPatron already exist);
Tick() ignores _disposed; FindDeepest's doc overstates its guarantee.

Verified clean: every §6 Campaign-OP lesson (string resolver on both
Builds, scoped lookups -- I enumerated ALL duplicate ids and found three
previously-uncalled-out cross-page repeats, tab activation, no 0x0-extent
lazy children, cross-layout templates so the same-layout skip cannot
apply, no hand-rolled viewport); U6 genuinely closed (page 0x10000292 has
exactly two children); Flush/ClearContent resets ContentHeight; the J4.1
owners are borrowed by reference and clear in place; catalog id 12
byte-verified and the toolbar seam tolerates it via the same path four
existing non-toolbar panels take; window-frame policy byte-identical to
Options. data_794358 BYTE-VERIFIED in the PDB-paired binary as UTF-16LE
" " (one space, not empty) -- lane A's L" " reading and FA3's BlankLine
both correct. Live-mount probe passes against the installed DATs with no
fixture drift; 22 FA3 tests and 4,871 App tests / 3 skips / 0 failures.

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

514 lines
28 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 — MECHANISM-FAITHFULNESS review
**Target:** `0a9ca2f1` (feature), `74c3d85d` (fixture + tests), `b6a25110`
(gate script + ledger), `d7e1cffd` (research addendum), reviewed against
`docs/plans/2026-08-11-fellowship-allegiance-campaign.md` slice FA3 + D1 + §6
and `docs/research/2026-08-11-fa-panel-structure.md` (incl. §10 + the FA3
correction addendum).
**Verdict: APPROVE-WITH-FIXES** — 2 MUST-FIX (both in the user's connected-gate
script, which is the artifact the user actually reads while gating), 9
SHOULD-FIX. The load-bearing tab-table correction is **CONFIRMED CORRECT** by
re-derivation from the authored bytes AND independently from the installed
DATs. No code defect found in the mount path.
Method note: every claim below was re-derived, not taken from the commit
messages. The fixture was parsed directly; the env-gated live-mount probe was
run against the installed DATs; the retail anchors were re-read in
`acclient_2013_pseudo_c.txt`; and one BN string operand was byte-verified in
the PDB-paired binary (`check_exe_pdb.py``=== MATCH ===`, GUID
`9e847e2f-777c-4bd9-886c-22256bb87f32`).
---
## 1. The tab-table correction — VERIFIED CORRECT (item 1)
This was the load-bearing fact. It holds, three independent ways.
**(a) The authored `0x2E` array, read straight out of the committed fixture**
(`tests/AcDream.App.Tests/UI/Layout/fixtures/social_panel_2100006E_1000018F.json`,
root `0x1000018F`, property `46` = `0x2E`, four `Kind 8` struct entries with
sub-properties `48`=`0x30` button, `49`=`0x31` page, `50`=`0x32` IsDefault):
| entry | P0x30 (button) | P0x31 (page) | P0x32 |
|---|---|---|---|
| 0 | 268436108 = `0x1000028C` | 268436113 = `0x10000291` | **true** |
| 1 | 268436110 = `0x1000028E` | 268436114 = `0x10000292` | absent |
| 2 | 268436754 = `0x10000512` | 268436755 = `0x10000513` | absent |
| 3 | 268436795 = `0x1000053B` | 268436810 = `0x1000054A` | absent |
This is **exactly** the pairing recorded in `SocialPanelController.cs:34-37`,
and Allegiance (`0x1000028C``0x10000291`) is the sole `IsDefault` entry.
**(b) Each button's own resolved caption** — from the live-mount probe run
against the installed DATs during this review:
```
[socialprobe] tab button 0x1000028C (UiText) caption='Allegiance'
[socialprobe] tab button 0x1000028E (UiText) caption='Fellowship'
[socialprobe] tab button 0x10000512 (UiText) caption='Friends'
[socialprobe] tab button 0x1000053B (UiText) caption='Squelch'
```
**(c) Each page's own `P0x57` + its `RegisterElementClass` type**, both from the
fixture and re-confirmed by the probe:
| page | authored `Type` | retail class (verified in pseudo-C) | `P0x57` |
|---|---|---|---|
| `0x10000291` | `0x1000002C` | `RegisterElementClass(0x1000002c, gmAllegianceUI::Create)` @`004911fa` | `0x1000000E` = `ToggleAllegiancePanel` (F3) |
| `0x10000292` | `0x1000002D` | `RegisterElementClass(0x1000002d, gmFellowshipUI::Create)` @`0048e6aa` | `0x1000000F` = `ToggleFellowshipPanel` (F4) |
| `0x10000513` | `0x10000045` | `RegisterElementClass(0x10000045, gmFriendsUI::Create)` @`0048e21a` | `0x10000118` |
| `0x1000054A` | `0x10000047` | `RegisterElementClass(0x10000047, gmSquelchUI::Create)` @`0048d04a` | `0x10000124` |
The class doc's further claim that `0x10000118`/`0x10000124` carry **no**
retail-default keybind is also correct: neither literal appears in
`keymap-default.txt` or `retail-default.keymap.txt`.
**A fourth corroboration the addendum did not state, and should have.** With
the corrected pairing, the authored x-order becomes Allegiance (x=0),
Fellowship (x=72), Friends (x=144), Squelch (x=206) — i.e. **the authored
default tab is also the left-most tab**, which is what one would expect and
which the old (refuted) guess did not produce. Button widths track caption
lengths under the corrected mapping too (`Friends`, the shortest caption, is
the narrowest button at 62px). This same x-order is the basis of MUST-FIX 1
below.
Also verified: root `P0x10000029 = 12` (the `RetailPanelCatalog.SocialPanel`
byte-verification claim), root `P0x57 = 0x1000000D`, close button `0x10000290`
`P0x12 = 0x1000000D`, and `0x1000000D` really is an InputMap **context** id
(`ID_InputMap_ToggleChatEntry`, `src/AcDream.Core/Input/RetailActionMap.cs:161`;
it appears as `Context 0x1000000D` in `keymap-default.txt`) — so the class
doc's "authored but UNCONSUMED" reading is accurate, not a hand-wave.
---
## MUST-FIX
### MF-1 — the gate script carries the REFUTED x-order into the user's contract
`docs/research/2026-08-12-campaign-fa-test-script.md:59-64` (step 9):
> "left-to-right authored order is Friends, Allegiance, Fellowship, Squelch"
and `:20-25` (step 1):
> "the authored default tab — NOT Friends, despite Friends being drawn
> left-most on the tab strip in some sort orders"
Both are wrong. The authored geometry (fixture, corroborated by the probe's
`root-child` dump) is:
```
0x1000028C "Allegiance" x=0 72x25
0x1000028E "Fellowship" x=72 72x25
0x10000512 "Friends" x=144 62x25
0x1000053B "Squelch" x=206 70x25
0x10000290 (close X) x=276 24x25
```
Friends is drawn **third**, not left-most. This is the same x-order guess
`d7e1cffd` corrects in the research doc — it just never got corrected in the
script. Step 10's caption list (`:65-68`) repeats the wrong ordering.
Why this is MUST-FIX and not a nit: the script is the user's contract for the
connected gate, and step 1 explicitly primes the user to expect Friends at the
left edge next to a tab that opens by default. The user will see Allegiance at
x=0 and has been told that is the wrong-looking case. Fix step 9, step 1's
parenthetical, and step 10 to the real order — and take the opportunity to say
"the default tab is also the left-most tab", which is the most reassuring form
of the correction.
### MF-2 — the gate script's step 14 sends the user to a trigger that CANNOT fire, and tells them to report it if it doesn't
`docs/research/2026-08-12-campaign-fa-test-script.md:100-107` (step 14):
> "If some other client action causes an allegiance push to land server-side
> during this session (e.g. a `@allegiance info` chat command, which already
> works independently of this panel), the monarch/patron blocks should then
> become VISIBLE — **report if they do not**."
and the matching "What to report" bullet at `:153-155`:
> "The Allegiance page's monarch/patron blocks staying hidden after a real
> allegiance push has visibly landed (e.g. after `@allegiance info` shows
> non-empty data in chat)."
`RuntimeAllegianceState._hasProfile` is set in exactly one place —
`ApplyUpdate` (`src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs:126-142`),
whose own doc comment reads *"`0x0020 AllegianceUpdate` — … the **ONLY**
inbound writer of this owner's profile (see the class doc's MUST-FIX 2
correction)"*. FA2's own fix round deliberately stopped `0x027C` (the
`@allegiance info` response family) from seeding this owner
(`4272ad0e`, "0x027C stops seeding"; `GameEventWiring.cs:231` documents it),
and the plan's FA5 row states the `0x001F` subscription that provokes `0x0020`
is **FA5 scope**. FA3 sends no `0x001F`.
So the script names a trigger the campaign deliberately disconnected, and then
instructs the user to file a defect when it fails to work. Either drop the
trigger entirely and state plainly that the blocks are expected to stay hidden
for the whole of FA3 (the honest shell-slice expectation), or replace it with
the only real path (a server-side allegiance change that makes ACE push
`0x0020` unsolicited) and say so. The "What to report" bullet must go with it.
---
## SHOULD-FIX
### SF-1 — the F3/F4 "retail Toggle-action semantics" claim has no retail anchor (item 2)
`src/AcDream.App/UI/Layout/SocialPanelController.cs:200-204`:
> "lets `RetailUiRuntime.HandleInputAction` implement **retail's** Toggle-action
> close-on-second-press semantics (same shape as `OpenSpellbook`'s own
> page-aware toggle)"
I went looking for the retail handler and could not find one. Re-derived:
- **No `P0x57` consumer exists in the binary.** The only two `0x57` reads
anywhere in `acclient_2013_pseudo_c.txt` are
`AppraisalProfile::InqInt(arg2, 0x57, …)` @`004b2954` and
`AppraisalProfile::InqFloat` @`004b3981` — unrelated. No
`GetAttribute_Enum(this, 0x57, …)` site exists. This matches lane A §6.1's
UNVERIFIED flag; the FA3 class doc correctly restates it for the *root's*
`P0x57`, then quietly relies on a different retail claim for the toggle.
- **`ClientUISystem::OnAction @0x00564B90` does not handle these actions.** For
`m_InputAction > 0x7c` it handles exactly `0x1000001E`, `0x10000025` and
`0x1000002B` and returns `0` for everything else — `0x1000000E`/`0x1000000F`
fall through.
- **`gmPanelUI` never sees the action.** `gmPanelUI::PostInit @0x004BD010` does
`RegisterForGlobalMessage(this, 1)`, but `gmPanelUI::ListenToGlobalMessage`
is COMDAT-folded onto `NoticeHandler::RecvNotice_DisplayWeenieError
@0x004F5860`, whose body is `return;`.
- No other `OnAction` implementation's range covers `0x1000000E`/`0x1000000F`
(`ChatInterface` `0x10000020..0x10000119`, magic `0x10000060..0x10000105`,
combat `0x1000005B..`, `CPlayerSystem` `0x1000002A..`).
So retail's actual F3/F4 behaviour — and in particular what a repeat press does
when the panel is already open on the *other* tab — is **not established from
this decomp**. The shipped rule ("close only when already on the target tab")
is acdream's own `OpenSpellbook` precedent, which is a perfectly defensible
choice; it just must not be labelled as retail's. Re-word the comment to name
it as precedent-following, and add the unknown to the research doc's §8 list
(next to U7, which is the same gap) so FA7's closeout can decide whether it
wants a register row. The gate script's "same toggle-closes-on-repeat-press
shape as every other `Toggle*Panel` action" (`:26-29`) is fine as-is — it
claims acdream consistency, not retail fidelity.
### SF-2 — per-frame closure allocation in the allegiance page tick
`src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs:108-110`:
```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` is captured, so this allocates a display class **plus** a delegate,
twice, on **every frame**, whether or not the panel is visible
(`RetailUiRuntime.cs:559` ticks it unconditionally). At the profile's measured
~520 FPS that is ~2,000 allocations/second of pure garbage for a value that
changes at most once per session. The Modern Runtime slices spent whole commits
driving per-frame paths to 0 B; this is a new, avoidable regression of that
discipline in the graphical hot loop.
Fix: hoist two `static readonly Func<IReadOnlyList<UiText.Line>>` fields (one
returning `BlankLine`, one returning `NoLines`) and assign the cached delegate,
and/or only write `LinesProvider` when the gate actually flips. The fellowship
page's tick is already allocation-free — this is the only offender.
### SF-3 — the live-mount probe prints two of its headline findings without asserting them (item 7)
`tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs:117-118`
computes `passupCount` and prints it, but never asserts it. The commit message
nevertheless claims *"`0x10000492` **is confirmed** authored twice under the
allegiance page"*. A print is not a gate: this cannot fail if a future importer
change collapses or drops one of the two instances — which is exactly the
regression the scoped-lookup rule exists to survive. (For the record I did
confirm the fact independently: the fixture authors `0x10000492` twice, once
under `0x10000255/0x10000490` and once under `0x1000025A/0x10000490`, and the
probe printed `= 2`.) Add `Assert.Equal(2, passupCount)`.
Likewise, the probe activates the tab behavior (`:67`) and asserts
`UnresolvedEntries` is empty, but never asserts **page exclusivity** — that
after `ActivateTabBehavior()` exactly one of the four page elements is
`Visible`, and that it is the Allegiance page. That is the #372 class
("the panel mounts but the pages are wrong/blank") stated as an assertion
rather than a hope, and it is one line.
Everything else the probe asserts is real and does cover the classes it claims:
non-empty tab captions (the #375 resolver class — I re-ran it and it passes),
the four pages resolving, the fellowship frame pair, and the six allegiance
signature elements. All pass against the installed DATs.
### SF-4 — "all three visible option checkboxes" is four
`src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs:19`. Frame
`0x1000026B` authors **four** `Type 0x10000035` children —
`0x10000270` (IgnoreFellowshipRequests), `0x10000271`
(FellowshipAutoAcceptRequests), `0x10000272` (FellowshipShareXP),
`0x10000273` (FellowshipShareLoot) — per lane A §3.1 and confirmed in the
fixture. The gate script step 11 (`:73-82`) hedges with "a fourth, Share Loot,
may also be present" when both the decomp and the committed fixture already
settle it. Fix the count in both places; a gate script that is unsure about
what the user will see invites noise.
### SF-5 — AD-79's pinning test does not cover the control AD-79 itself enumerates
The register row names seven controls, including the Friends page's "Appear
Offline"-shaped checkbox `0x1000052C`, and cites
`SocialPanelControllerTests.FriendsAndSquelchActionButtons_AreClickable_ButHaveNoHandler`
as what pins the INERT contract. That test
(`SocialPanelControllerTests.cs:272-291`) covers only the six **buttons**.
`0x1000052C` builds through `DatWidgetFactory.BuildCheckbox` as a `UiButton`
(`DatWidgetFactory.cs:785-816`), so it can be added to the existing loop
verbatim. Seven enumerated, six pinned is the kind of drift AD-78's own
"Risk" column warns about.
### SF-6 — the two page controllers do not cite AD-79 by id
`SocialFriendsPageController.cs:23-25` and `SocialSquelchPageController.cs:21-22`
both say "See this commit's single register row…". AD-79's own *Where* column
asserts "both classes' own doc comments cite this row". Literally true,
practically not: a future reader grepping `AD-79` will not find the code. Name
the row.
### SF-7 — the allegiance empty state is coarser than the contract's own source (item 4)
FA3's contract line is "all four pages' **empty states**", and lane A §4.5 is
what defines the allegiance one. I re-read and byte-verified that section's
claims because it is what the slice implements against:
- `gmAllegianceUI::UpdateMonarchData @0x00491B40` branches on
`(monarchId == player_id || monarchId == 0)` — retail hides the monarch block
when there is no monarch **or the monarch is you**, then sets **both**
`m_pMonarchName` *and* `m_pMonarchFollowers` to the literal at
`data_794358`.
- **`data_794358` byte-verified** in the PDB-paired binary
(`C:\Users\erikn\Downloads\acclient.exe`, image base `0x400000`, RVA
`0x394358` → file offset `0x394358`): bytes `20 00 00 00` = UTF-16LE `" "`,
one character. So lane A's `L" "` reading is correct and FA3's
`BlankLine = [new UiText.Line(" ", …)]`
(`SocialAllegiancePageController.cs:40-41`) matches it exactly — **a single
space, not an empty string**, as asserted at
`SocialPanelControllerTests.cs:177-178`. This half is clean.
What is coarser: FA3 gates **both** blocks on `Snapshot.HasProfile` rather than
per-relationship. The consequence is not hypothetical — the moment any
`0x0020 AllegianceUpdate` lands, a monarch character sees an **empty, visible**
monarch block that retail hides, and anyone whose patron is the monarch sees an
empty patron block instead of retail's relabelled-monarch presentation. FA2
already ships the data needed to do it properly:
`IRuntimeAllegianceView.TryGetMonarch` / `TryGetPatron`
(`GameRuntimeGameplayViews.cs:200-204`) plus `Snapshot.MonarchGuid`; the FA3
`Callbacks` record deliberately narrows the input to
`Func<RuntimeAllegianceSnapshot>` (`SocialPanelController.cs:90-96`), which is
what forecloses it.
I am **not** calling this a MUST-FIX: FA3 is a shell slice, the class doc is
honest about the simplification, and MF-2's finding means `HasProfile` is
effectively always `false` for the whole FA3 gate, so nothing wrong is visible
*during this gate*. But it is a contract gap that will be invisible by the time
FA5 lands unless it is written down. Either widen the callback to the view and
implement the real per-relationship gate now, or add an explicit FA5 acceptance
line ("the monarch block hides when the monarch is you; the patron block hides
when the patron is the monarch") to the plan's FA5 row so it cannot be lost.
Related, and worth a line in the class doc: `Tick()` reassigns
`LinesProvider` **unconditionally every frame**, so any FA5 code that sets a
real monarch/patron name on `0x10000257`/`0x1000025C` will be overwritten on
the next frame unless this method is changed at the same time.
### SF-8 — `Tick()` ignores `_disposed`
`SocialPanelController.Dispose()` (`:224-228`) sets `_disposed` and nothing
reads it. The handle passes `Controller = controller` to
`RetailWindowFrame.Mount`, and `RetailUiRuntime.SocialPanelController` is never
nulled, so `RetailUiRuntime.Tick`'s `SocialPanelController?.Tick()`
(`RetailUiRuntime.cs:559`) will keep ticking the four page controllers after
disposal. Nothing throws today (the Runtime owners outlive the panel and the
J-owner snapshot getters are disposed-guarded), so this is low severity — but
"tick a disposed controller" is precisely the class the J-slices spent commits
eliminating. One `if (_disposed) return;` closes it.
### SF-9 — `SocialPanelRowText.FindDeepest` documents a stronger guarantee than it implements
`SocialPanelRowText.cs:19-31` promises "the deepest `UiText` descendant
(pre-order, last match wins)". The implementation returns the **last match in
traversal order**, which equals the deepest only when the subtree is a single
chain. Both real templates *are* single chains — I dumped them from the
installed DATs:
```
FriendsRow 0x10000519 Type 3 (270x24) -> 0x1000051A Type 0xC -> 0x1000051F Type 0xC
SquelchRow 0x10000541 Type 3 (270x24) -> 0x10000542 Type 0xC -> 0x1000054D Type 0xC
```
so the behaviour is correct today and the class doc's honesty about
`gmFriendsUI`/`gmSquelchUI` being outside the campaign's decompiled scope is
exactly the right posture. Just make the doc describe what the code does, or
make the code track depth.
---
## 2. What verified clean
Stated plainly, because most of this slice is right.
**The tab-table correction (item 1).** Confirmed three independent ways
(§1 above). The addendum in `d7e1cffd` is accurate, and correcting in place
while leaving the original text visible follows the FA1/FA2 convention.
**§6 Campaign-OP lessons compliance (item 3).** Each one re-derived:
- *String resolver on every `Build`.* `MountSocialPanel` passes
`resolver.Resolve` to the main `LayoutImporter.Build`
(`RetailUiRuntime.cs:2611-2617`) **and** its nested `TemplateResolver`
builds each row template with its own `strings.Resolve`
(`:2626-2645`). Both covered — the #375 class. The probe's non-empty-caption
assertion is the live check, and it passes.
- *Scoped `FindDescendant` for every repeating id.* I enumerated **all**
duplicate element ids in the panel subtree rather than trusting the stated
one. Duplicates exist at `0x10000439`/`0x100000E9`/`0x10000215` (×4, tab
button art), `0x100002CE``0x100002D0` (×16, button 3-slice art),
`0x10000328` (×6, checkbox art), `0x00000001`/`0x10000071`/`0x10000072`/
`0x10000364``0x10000366` (×4, scrollbar parts), `0x10000490`/`0x10000491`/
`0x10000492` (×2, the two allegiance blocks), and — not previously called
out anywhere — **`0x10000254`, `0x1000025D` and `0x1000051E` repeat ACROSS
pages** (`0x10000254` under both the Friends page and the allegiance monarch
block; `0x1000025D` under the Friends page and the patron block;
`0x1000051E` under both Friends and Squelch). Every FA3 lookup is safe:
the four page ids, the two fellowship frames, both allegiance field
containers, both name texts and both ListBox ids are each **unique**, the
per-page controllers scope to their page root anyway, and the single flat
`layout.FindElement(0x10000290)` for the close button targets a unique id.
`0x10000492` is not looked up at all in FA3, so the intra-panel duplication
cannot bite yet — but the three cross-page duplicates above are worth
carrying into FA5's notes, since FA5 will be resolving allegiance-page ids.
- *Tab activation performed.* `controller.ActivateTabs()` at
`RetailUiRuntime.cs:2662`, after `Bind` — the OP3 ordering constraint.
Verified live: `UnresolvedEntries` is empty and `IsShowingAllegiance` is true
after activation.
- *No lazily-created fill-anchored 0×0 children (the #372 class).* The new
controllers create no elements at all. The only lazy child is
`UiTemplateListBox`'s viewport, which already seeds `Width`/`Height` from the
ListBox's authored extent (`UiTemplateListBox.cs:152-182`, the #372 fix), and
both social ListBoxes carry real authored extents (Friends `270x400`, Squelch
`270x430`) at `Bind` time.
- *Same-layout template-prototype skip.* Checked for this layout specifically:
all four `P0x64` arrays point at **other** LayoutDescs — Friends
`0x2100005D/0x10000519`, Squelch `0x21000060/0x10000541`, fellows
`0x21000030/0x10000281`, vassals `0x2100002F/0x10000266`. The importer's skip
is same-LAYOUT only (`LayoutImporter.cs:233-249`), and in any case
`MountSocialPanel` uses the single-root `ImportInfos(dats, layoutId,
rootElementId)` overload, which never enumerates top-level siblings. No
parked prototypes are possible here.
- *No hand-rolled scroll viewport.* Both lists ride the existing
`UiScrollablePanel` through `UiTemplateListBox`, so the #371 straddling-row
clip comes for free. Popup/pointer-priority rules are not applicable.
**`UiTemplateListBox.Flush()`.** The doc claim "resetting `ContentHeight` to 0"
is accurate: `UiScrollablePanel.ClearContent()` (`:54-61`) removes every child,
clears `_baseTops`, sets `ContentHeight = 0` and resets the scroll offset. The
dormancy guard (`_viewport?.`) is genuine — a never-populated box still never
allocates its viewport. The shrink case the Friends/Squelch rebuild needs
therefore works.
**Fellowship empty-state frame swap (item 4).** Retail anchor re-read at
`gmFellowshipUI::Update @0x0048F440`: when
`ClientFellowshipSystem::GetFellowshipSystem()->m_pFellowship == 0` it calls
`m_pInAFellowshipFrame->SetVisible(0)` then
`m_pNotInAFellowshipFrame->SetVisible(1)` — exactly the two writes
`SocialFellowshipPageController.Tick` makes, driven off
`RuntimeFellowshipSnapshot.IsInFellowship`. Lane-A unknown **U6 is genuinely
closed**: page `0x10000292` has **exactly two children**, the two frames, so a
single `Visible` toggle per frame really is the whole swap. (Frame contents:
`0x1000026B` = name box `0x1000026F`, Create `0x10000274`, four checkboxes;
`0x10000275` = roster ListBox `0x10000279`, scrollbar, six buttons
`0x1000027B``0x10000280`.)
**Friends/Squelch read-only binding (item 5).** Bound to the J4.1 owners by
reference — `RuntimeCommunicationState.Friends`/`.Squelch` are get-only
properties constructed once (`RuntimeCommunicationState.cs:67-68,132-133`) and
**cleared in place** at reset (`:250-251`), so holding the instance across a
generation reset is correct, and `FriendsState.Clear()` bumps `Revision`
(`FriendsState.cs:67-74`) so the panel rebuilds to empty on its own. No new
state copies; `Snapshot()`'s array copy only runs on a revision change. The
per-frame cost when nothing changed is one `Interlocked.Read` per list. The
D1 INERT contract holds: checkboxes build as `UiButton` with a null `OnClick`
(no latching visual state without a handler), and the six buttons are pinned
null by test.
**Panel exclusivity + catalog (item 6).** `SocialPanel = 12` is byte-verified
(root `P0x10000029 = 12`). Registered in `Mounted` only. The toolbar seam
genuinely tolerates it: `SyncToolbarWindowButtons` iterates `ToolbarPanels`
only (`RetailUiRuntime.cs:809-814`), so no phantom button; `BindPanelButtons`
binds by authored toolbar element, and the toolbar authors no `0x10000029 = 12`
button; and the one path that *does* reach the toolbar with a non-toolbar id —
`OnWindowVisibilityChanged``ToolbarController.SetPanelOpen(12, …)`
(`:831-832`) — walks `_panelButtons`, finds nothing, and returns
(`ToolbarController.cs:394-406`). This is the same path `PositiveEffects` (4),
`LinkStatus` (8), `MiniGame` (9) and `Vitae` (15) already take. No crash, no
greyed phantom. Both open paths keep `RetailPanelUiController` state coherent:
`ToggleWindow` and `CloseWindow` both route through `TryGetPanelId`
`_panelUi` (`:708-711`, `:801-807`).
**Window-frame policy.** `RetailWindowFrame.Options` for the social panel is
byte-for-byte the Options panel's post-review policy (ResizeX=false,
bottom-edge-only, both constrain flags, full ContentAnchors,
`DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo)`), differing only in
`Left`/`Top`. No invented divergence in the shared main-panel cohort. The
root/page height mismatch (root `300x362`, pages `300x575`) is **identical** to
the already-gated Options panel's own authored shape, so it is not an FA3
property.
**The live-mount probe run (item 7).** Executed with
`ACDREAM_PROBE_LIVE_MOUNT=1` against the installed DATs: **1 passed, 0
failed**. Every assertion holds. Given #383 (installed-DAT vs committed-fixture
drift), I also cross-checked the committed social-panel fixture against the
probe's live dump on root extent, child count and order, the tab table, all
four page `P0x57` values, and both allegiance blocks' geometry (including the
two *differently sized* `0x10000490` instances, monarch `(210,0 90x36)` vs
patron `(220,0 80x36)`). **No drift** — this fixture is faithful to the
installed DAT.
**Tests.** `SocialPanelControllerTests` + `RetailPanelCatalogTests`: 22 passed.
Full `AcDream.App.Tests` on the committed Release binaries: **4,871 passed / 3
skipped / 0 failed** — no collateral regressions. The ledger's `+18` arithmetic
checks out exactly (13 `SocialPanelControllerTests` + 4 `RetailPanelCatalogTests`
+ 1 probe). I did not re-run the full 13,233-test solution suite (no builds
permitted in this review), so that figure is unverified but arithmetically
consistent.
**Register bookkeeping.** AD-79's header count is right — the table contains
exactly 59 `AD-` rows, matching "59 active rows", up one from 58. The row's
element enumeration is accurate against the fixture (Friends `0x10000514`/
`0x10000515`/`0x10000516` + `0x1000052C`; Squelch `0x10000547`/`0x1000054B`/
`0x1000054C`), and its honesty about `gmFriendsUI`/`gmSquelchUI` being outside
the campaign's researched scope is the right framing. Only SF-5/SF-6 above.
**Gate-script spot checks (item 8).** Steps 13 (F3 opens on Allegiance; repeat
F3 closes; F4 opens directly on Fellowship), step 4 (F3 while open on
Fellowship switches rather than closes) and step 5 (the authored X at
`0x10000290`, a direct root child at `(276,0) 24x25`, always visible because it
is not inside any page, wired to `ToggleWindow`) all match
`RetailUiRuntime.OpenSocialPanel` (`:603-620`) and
`SocialPanelController.Bind`'s close wiring (`:145-150`) exactly. Steps 78's
exclusivity claims match `RetailPanelUiController.SetPanelVisibility`'s
one-active-panel logic. Step 6's geometry claims match the frame options. The
two claims that do **not** hold are MF-1 and MF-2. One wording nit for whoever
edits the script: step 5 calls the X "top-right of the window chrome" when it
is an authored panel element, not chrome — harmless, but a user hunting the
chrome's own close affordance may be briefly confused.
---
## 3. Disposition
APPROVE-WITH-FIXES. MF-1 and MF-2 must land before the user runs the gate —
both are in `docs/research/2026-08-12-campaign-fa-test-script.md` and neither
touches code. SF-1 through SF-9 are code/doc/test corrections that can ride the
same fix round. Nothing here warrants a re-review of the mount path itself: the
tab table, the frame swap, the string resolver, the scoped lookups, the tab
activation, the catalog registration and the toolbar seam are all correct, and
the live-mount probe passes against the installed DATs.