All nine blast axes verified clean at the code level. Single fix: the AD-78 register row still reads "35 of 50 rows dimmed" after FA4's D7 flipped four rows to Live (now 31 of 50) -- the class doc and conformance test were updated, the binding register row was not. Plus one minor non-blocking observation on GetMembers' per-vitals-tick allocation profile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
18 KiB
Campaign FA slice FA4 — BLAST-RADIUS review
Reviewer lens: the axes the implementer may not have traversed — enum leaks, existing-consumer regressions, interface-implementer coverage, generation-seam consistency, confirmation-ordering, dimming bookkeeping, suite accounting, register/ledger/gate-script/digest consistency, and the FA2/FA3 live paths' survival.
Target commits (branch claude/latest-commits-cb0c8f):
357d2032feat(runtime): fellowship roster enumeration +Socialselection source5bdd0528feat(ui): fellowship page fully live38f08314docs(fa4): register rows AD-80/AD-81, AD-78 addendum, gate script, ledger
Verdict: APPROVE-WITH-FIXES (1 SHOULD-FIX, 1 minor observation). Every code-level blast axis verified clean. The single fix is a stale count in a binding doc (the divergence register). No MUST-FIX, no REJECT.
VERDICT DETAIL
SHOULD-FIX 1 — the AD-78 register row still says "35 of 50 rows dimmed" (stale post-D7)
docs/architecture/retail-divergence-register.md, the AD-78 row's
file-path column, still reads verbatim:
CharacterOptionsPageController.cs(35 of 50 rows dimmed —RowSpec.StoreOnly, …)
FA4 D7 flipped four rows (IgnoreFellowshipRequests,
FellowshipAutoAcceptRequests, FellowshipShareXP,
FellowshipShareLoot) from StoreOnly to Live, so the true count is
now 31 of 50 dimmed / 19 live. The code
(CharacterOptionsPageController.Groups), the class-doc derivation table
(CharacterOptionsPageController.cs doc comment, updated in 5bdd0528),
and the conformance test (CharacterOptionsPageControllerTests, now
asserts 31/19) were all updated. The register row was not — it
carries no D7/FA4 addendum and no updated count (confirmed: the only
of 50 token in the AD-78 row is 35 of 50).
Why this matters and isn't a nit: the register is CLAUDE.md's "single
auditable list of every KNOWN place acdream's runtime behavior can
deviate from retail," and its discipline requires the commit that
changes a deviation to update its row in the same commit. AD-78's own
"Risk" column explicitly warns that dim/live classification drift "in the
OPPOSITE direction it was built to prevent" is "a real defect, not a
rendering nit." A wrong count inside the register row that governs the
convention is exactly that drift, internal to the register itself. The
38f08314 commit message asserts "AD-78's derivation table gains its D7
addendum," but that addendum landed in the class doc (5bdd0528), not in
the register row.
Fix: update the AD-78 register row's CharacterOptionsPageController.cs
parenthetical to "31 of 50 rows dimmed" (and, optionally, a one-clause D7
note naming the four un-dimmed ids), mirroring the class-doc change.
Observation A (minor, non-blocking) — GetMembers is the first allocating member on IRuntimeFellowshipView, and the controller re-materializes it per vitals tick
RuntimeFellowshipState.FellowshipView.GetMembers() materializes a fresh
RuntimeFellowMemberSnapshot[] under the gate on every call — correct and
race-safe, and the interface doc is honest that it is "a materialized
array (not a lazy iterator)." But it is the first allocating member on
that view: Snapshot is a value-returned record struct and
TryGetMember is an out-param, both 0-alloc. On the consumer side,
SocialFellowshipPageController.RefreshRoster additionally wraps the
result in new List<>(_bindings.Members()) (SocialFellowshipPageController.cs:549)
and runs on every revision bump — and the controller's own doc
(lines 19-36) states the revision bumps on every 0x02C0 vitals refresh.
So while the fellowship panel is open with N members, each incoming
vitals tick costs two bounded (N ≤ 9) allocations on the UI thread.
This is off the physics/render hot path, panel-gated, and bounded — it
does not threaten the Slice-I/J frame budgets, so it does not block
approval. Noting it because (a) lens 3 asked for the allocation profile
explicitly, and (b) the per-tick List copy is avoidable: the diff at
RefreshRoster only needs a count + guid-membership comparison, which
can walk the IEnumerable directly without materializing a List (the
array from GetMembers is already a fresh snapshot). A future tidy, not
a fix this slice owes.
VERIFIED CLEAN, per lens
Lens 1 — SelectionChangeSource.Social enum leak: CLEAN
- No exhaustive
switch/pattern-match onSelectionChangeSourceexists anywhere insrc/(grep forcase SelectionChangeSource./switch … SelectionChangeSource/is SelectionChangeSource.→ zero matches). Adding a value cannot silently mis-route. - Every
SelectionTransitionconsumer either discards the transition (RuntimeActionState,ExternalContainerController,PaperdollController,InventoryController,SpellbookWindowController,ToolbarController,SpellcastingUiControllertakeSelectionTransition _;SelectedObjectControllerandVendorUiControllerdo_ = transition;) or reads onlySelectedObjectId/Reason, neverSource:RuntimeCombatTargetState.OnSelectionChangedreadsSelectedObjectId/Reasononly — aSocial-sourced selection cannot mis-target combat.AppraisalUiController.HandleSelectionChangedreacts to any non-nullSelectedObjectId(window-visible-gated) — a fellow-row click behaves like any other world selection, which is the intended "clicking a fellow selects them in the world" mechanism (SelectFellow→Select(guid, Social)).
- Plugin-facing
SelectionChangedEventcarries noSource, soSocialis invisible to plugins. No mis-targeting surface.
Lens 2 — UiTemplateListBox.FlushPreservingScroll vs Flush + existing consumers: CLEAN
- Additive.
Flush()'s body is unchanged (_viewport?.ClearContent()); only its doc comment expanded.FlushPreservingScrollis new. - Only one caller.
FlushPreservingScrollis called solely bySocialFellowshipPageController.RebuildRoster. The Options/Keyboard/Chat controllers never callFlushorFlushPreservingScrollat all — their ListBoxes are built once (AddItemFromTemplateList/AddPrebuiltRow) and never torn down — so the new method has zero effect on them. Only Friends/Squelch/Fellowship touch these methods. - Shrink-below-offset math is correct.
FlushPreservingScrollsavesScroll.ScrollY, callsClearContent()(which zeroes the panel'sContentHeightandScroll.SetScrollY(0)but leavesScroll.ContentHeight/ViewHeightat their stale values — those are only refreshed inLayoutScrollableChildren), then re-SetScrollY(saved)— clamped against the still-stale extents, sosaved(already ≤ oldMaxScroll) is preserved exactly. On the nextOnDraw,LayoutScrollableChildrenruns beforebase.OnDrawand setsScroll.ContentHeight = ContentHeight(the fresh, possibly-smaller height) thenSetScrollY(ScrollY), which re-clamps to[0, max(0, freshHeight - view)]. So a roster that shrinks below the old offset is clamped to the valid range before any row is painted — the "clamp now, correct at next layout" contract the doc claims, verified againstUiScrollable.SetScrollY/MaxScrollandUiScrollablePanel.LayoutScrollableChildren. - #371 clip/cull untouched. The straddling-row intersection cull lives
in
LayoutScrollableChildren;FlushPreservingScrollchanges only the offset, not the cull.
Lens 3 — IRuntimeFellowshipView.GetMembers() implementer/double coverage: CLEAN (see Observation A for allocation)
- Single concrete implementer:
RuntimeFellowshipState's nestedFellowshipView(: IRuntimeFellowshipView, the only:-implements in the tree). It returns a materialized array — nevernull, an empty array when not in a fellowship (testGetMembers_EmptyWhenNotInAFellowshippins this). Build-green proves no other implementer went stale. - Test doubles are property-return null, not interface impls: the two
doubles (
GameplayInputCommandControllerTests,InteractionUiRuntimeSourcesTests) exposeIRuntimeFellowshipView Fellowshipas=> throw/=> null!on the host interface — they do not implementIRuntimeFellowshipView, so the new member cannot break them, and the suite passing shows neither is exercised throughGetMembers. - Production wiring never yields null:
InteractionRetainedUiCompositionbinds() => d.Runtime.Fellowship.GetMembers(), andGameRuntime.Fellowship→FellowshipOwner.Viewis the concrete impl. No headless/bot view returns null here.
Lens 4 — DeferredGameRuntimeStateCommands.Fellowship* seam + adapter consistency: CLEAN
- All seven wrappers route through
Invoke((commands, generation) => commands.Fellowship.X(generation, …)).Invokecaptures_commandsand_view.Generationtogether under_gateand returnsRuntimeCommandStatus.Inactivewhen deactivated or unbound — the identical generation-race-safe shape asAddShortcut/Advance. A displaced session (reconnect mid-click) cannot receive a stale-generation fellowship action. - No auto-fire. Commands fire only from explicit button
OnClickhandlers andSetPageVisibletransitions.SetPanelOpen(0x00A6) is caller-idempotent (only on an actual_pageVisibleflip) and a no-op while disconnected (returnsInactive). - Adapter consistency: the underlying
IRuntimeFellowshipCommands(all seven methods, incl.SetOpen/SetPanelOpen) predates FA4 — it was created at FA2 (369729f0) and is unchanged in these commits. Both host adapters (DirectGameRuntimeCommandAdapter,CurrentGameRuntimeCommandAdapter) already implement it (build-green; they call the interface directly, needing no graphical wrapper). FA4 adds only the App-sideDeferredGameRuntimeStateCommandsfaçade.
Lens 5 — TryAutoRespondToFellowshipInvite interception ordering: CLEAN
- The interception guard is
request.Type == (uint)ConfirmationType.Fellowship(4) && TryAutoRespondToFellowshipInvite(request). The&&short-circuits on type, so any non-fellowship confirmation never enters the interceptor and falls through to_gameplayConfirmationController?.HandleRequest. ConfirmationTypeenum:SwearAllegiance = 1,AlterSkill = 2,AlterAttribute = 3,Fellowship = 4,CraftInteraction = 5,Augmentation = 6,YesNo = 7. Allegiance is type 1, distinct from 4 — it passes through untouched. FA5's allegiance confirmations reach the genericGameplayConfirmationControllerunchanged (that controller already retains the type-1/type-4 response tuple per its own doc).- Within a type-4 request: if neither
IgnoreFellowshipRequestsnorFellowshipAutoAcceptRequestsis set,TryAutoRespondToFellowshipInvitereturnsfalse→ falls through to the generic dialog exactly as before. No swallowing. - Accept semantics correct:
SendResponse(type, contextId, autoAccept); the thirdboolisaccepted(verified atGameplayConfirmationController.cs:97,_sendResponse(_serverType, _serverContext, accepted)whereaccepted = GetBoolean(ConfirmationResult)). SoautoAccept=true→ accept, ignore-only → decline. The Ignore↔AutoAccept mutual exclusion (RuntimeCharacterState.TrySetOption) guarantees at most one is set.
Lens 6 — the four un-dimmed rows + AD-78 conformance count: CLEAN (code); see SHOULD-FIX 1 (register)
- Exactly four
RowSpecentries flippedStoreOnly→Live(IgnoreFellowshipRequests,FellowshipAutoAcceptRequests,FellowshipShareXP,FellowshipShareLoot); the other two Grouping-section rows (IgnoreAllegianceRequests,DisplayAllegianceLogonNotifications) stay dimmed. Test updated to31store-only /19live (50 total), internally consistent. - No other dimming test perturbed.
ConfigOptionsPageController,KeyboardConfigController,ChatOptionsPageControllerand their tests are untouched by FA4 (the only match was a doc-comment reference to ConfigOptions' OP6 precedent). Their dimming tests pass on the post-FA4 Release binaries (91 passed).
Lens 7 — suite accounting +34: RECONCILES EXACTLY
| Test file | Δ | how counted |
|---|---|---|
RuntimeFellowshipStateTests.cs |
+4 | 4 GetMembers_* [Fact] (commit 357d2032) |
RowTemplateResolverTests.cs |
+3 | 3 [Fact] (new file) |
UiTemplateListBoxFlushPreservingScrollTests.cs |
+4 | 4 [Fact] (new file) |
SocialFellowshipPageControllerTests.cs |
+23 | 20 [Fact] + 1 [Theory]×3 [InlineData] = 23 discovered tests (new file) |
CharacterOptionsPageControllerTests.cs |
0 | no [Fact]/[Theory] added/removed; one existing test's expected counts changed |
SocialPanelControllerTests.cs |
0 | no [Fact]/[Theory] added/removed; in-place Callbacks-shape updates |
SocialPanelLiveMountProbeTests.cs |
0 | assertions added inside the existing single env-gated [Fact] |
4+3+4+23 = +34, matching 13,238 → 13,272. The ledger's per-file arithmetic is accurate (the "+23" resolves as 20 Facts + 3 Theory cases — a naïve attribute count of 21 would look off by 2; it is not).
Lens 8 — register/ledger/gate-script/digest bookkeeping
- Register AD-80/AD-81 well-formed: both are new rows filed at FA4
(AD-79 was the prior max, from FA3), correctly incrementing the AD
section header 59 → 61 active rows (+2). AD-80 correctly frames the
XP-share display gap as ACE-vs-retail (acdream renders the byte-verified
retail table faithfully), AD-81 the two unported text-composition
primitives. Both cite
SocialFellowshipPageController.csat the right members. Well-formed. - AD-78 addendum: landed in the class doc, NOT in the register row → SHOULD-FIX 1 above.
- Gate script §FA4 honest: the
[SOLO]/[TWO-CLIENT]markings are accurate (create/caption-swap/button-enable/D5 are solo; roster/recruit/ dismiss/leader-handoff/invite-dialog need a second character and are marked[TWO-CLIENT]with an explicit FA6-bot-gate deferral that says not to treat an unrun two-client step as a failure). The FA3 now-stale "INERT" claims in steps 11-12 are corrected in place with[FA4 correction]pointers, not left wrong. Spot-checked step expectations against code: step 3 (leader-of-solo → Quit/Disband/Open enabled, Leader/Dismiss disabled) matchesRefreshButtonStates; step 15 (closed shows "Open", flips to "Close") matchesRefreshOpenCaption; steps 21-22 (100% solo even-split, 0% share-off) matchFormatStatsText/EvenSplitPercent. - Plan ledger FA4 row accurate: CODE-COMPLETE with both SHAs, the reconciled +34, the live-DAT summary, and the four scoped deferrals. Consistent with the diffs.
project_settings_options_digest.mdreferences AD-78 dimming but states no numeric dimmed count, so lens 8's conditional ("updated if it states a dimmed count") does not apply — no update owed there.
Lens 9 — @allegiance info + FA2/FA3 live paths on post-FA4 binaries: GREEN
Targeted dotnet test --no-build -c Release against the current Release
build (verified newer than every source .cs, tree clean):
- App
SocialFellowshipPageController/RowTemplateResolver/UiTemplateListBoxFlushPreservingScroll/CharacterOptionsPageController— 75 passed - App
SocialPanelController/SelectionInteractionController/SelectedObjectController— 92 passed - App
ClientCommandController(the live@allegiance infosurface) — 26 passed - Runtime
RuntimeFellowshipState/RuntimeAllegianceState/allegiance-info — 37 passed - Runtime
GameEventWiring/LiveSessionEventRouter/Allegiance— 35 passed - Core.Net
Allegiance/GameEventWiring— 115 passed - App
ConfigOptions/KeyboardConfig/ChatOptions(unrelated dimming) — 91 passed
Zero failures across all targeted runs.
Additional blast surfaces checked, all clean
SocialRuntimeBindingsgrew 4 → 14 params: only one construction site (InteractionRetainedUiComposition.cs:886), updated. No test helper constructs it. Build-green.SocialPanelController.CallbacksFellowshipSnapshot:→Fellowship:: only production site isRetailUiRuntime.MountSocialPanel(updated); test sites updated. Build-green.using AcDream.Runtime;added toRetailUiRuntime.cs: no ambiguous type surfaced (build-green); the file already fully-qualified the Runtime types it used.RowTemplateResolverextraction (carry-forward 2): the cache semantics are byte-identical to the oldMountSocialPanelinline closure — caches the importedElementInfo(including anullmiss, so a permanently-unresolvable template is not retried), rebuilds a freshUiElementon every call. Sharing one instance across Friends/Squelch/Fellowship is safe: distinct template-id keys, no collision. The revision-latch carry-forward 4 holds —_lastRosterRevisionadvances inTickbeforeRefreshRoster, so a partial resolver failure waits for the next real membership change (a count mismatch), not a per-frame retry.- Friends/Squelch scrollbar carry-forward 3: both controllers now
resolve the scrollbar via
listBox.ScrollbarElementId(DAT0x72) scoped topageRoot, replacing a hardcoded literal — the same fix Fellowship's ownBinduses. Covered by the FA3 fixture tests (Friends_/Squelch_ScrollbarModel_IsWiredToListBoxScroll,*_LongRoster_IsReachableViaScrollbar), green in the SocialPanel run above. (The installed-DAT-vs-fixture drift risk for0x72is the pre-existing #383, not FA4-introduced.) - Cross-surface option writes: the fellowship checkboxes write through
the same
SetSingleCharacterOptionRuntimeCmdcommand bus the Character tab uses, andRefreshCheckboxSelectionsre-seeds every tick — so the Ignore↔AutoAccept mutual exclusion (TrySetOption) is enforced once, at the runtime layer, for both surfaces. No double-write divergence.