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

28 KiB
Raw Blame History

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 (0x1000028C0x10000291) 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:

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), 0x100002CE0x100002D0 (×16, button 3-slice art), 0x10000328 (×6, checkbox art), 0x00000001/0x10000071/0x10000072/ 0x100003640x10000366 (×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 0x1000027B0x10000280.)

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 — OnWindowVisibilityChangedToolbarController.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.