A second mechanism-lens review landed on the same path at6849b457while this one was in progress and was overwritten by913e35cd. Its text is recoverable from git and is now cited from a new appendix, its two unique findings are carried forward, and the two places the reviews disagree are adjudicated from primary source. Carried forward: - SF-9: AD-78's register row still says "35 of 50 rows dimmed" (the D7 addendum landed in the class doc, not the row's Where column). - N-0: the Open/Close caption does not optimistically pre-toggle; lane B feature 11 records that retail's handler pre-toggles _open_fellow locally before sending 0x0291. Adjudicated: - _ftol2 vs MathF.Round:6849b457filed it a NIT ("round and truncation agree on every table value"). That holds for the DECIMAL literals, not the stored floats -- 0x007C91D4 = 0.44999998807907104 and 0x007E72BC = 0.3499999940395355, so retail truncates 44.999998/34.999999 to 44/34 while acdream rounds to 45/35. Stays MUST-FIX 1. - D6 invite auto-response:6849b457passed it as verified-clean after confirming the code matches the plan. The binary says retail has no such client-side read on any confirmation path. Stays MUST-FIX 2. The reviews agree on the reconnect D4 hole, the missing panel-level D4 conjunction test, the leader-tint register omission, and the live-DAT probe result. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
549 lines
31 KiB
Markdown
549 lines
31 KiB
Markdown
# Campaign FA slice FA4 — MECHANISM-FAITHFULNESS review
|
||
|
||
**Reviewer lens:** mechanism faithfulness (does the shipped code do what
|
||
retail does, for the reasons retail does it). Sibling lens: regression /
|
||
blast radius (separate doc).
|
||
|
||
**Target:** `357d2032` (Runtime), `5bdd0528` (App + tests), `38f08314`
|
||
(docs) against `docs/plans/2026-08-11-fellowship-allegiance-campaign.md`
|
||
slice FA4 + D4/D5/D6/D7 + the four FA3 re-review carry-forwards, with
|
||
`docs/research/2026-08-11-fa-fellowship-wire.md` (lane B) and
|
||
`-fa-panel-structure.md` (lane A) as ground truth.
|
||
|
||
**Verdict: APPROVE-WITH-FIXES — 5 MUST-FIX, 9 SHOULD-FIX, 4 NIT.**
|
||
|
||
The slice is real work and most of it is faithful: the button-enable
|
||
table is a verbatim port, the even-split *table* is byte-exact, the
|
||
roster diff genuinely never rebuilds on a vitals tick, the create-flow
|
||
gating matches retail's "the button IS the refusal mechanism", and the
|
||
live-DAT probe (re-run by this review, below) substantiates every
|
||
element-resolution claim in the ledger. The MUST-FIXes are not polish:
|
||
two of them are behaviors retail's binary demonstrably does *not* have
|
||
(D6's invite intercept, D5's rounding), one is a mechanism retail *does*
|
||
have and this slice did not port (world→panel selection sync), one is a
|
||
lifecycle hole that silently disables the very stream D4 exists to turn
|
||
on, and one is the register rule.
|
||
|
||
---
|
||
|
||
## 0. What this review re-derived (not trusted)
|
||
|
||
| Claim | How it was checked | Result |
|
||
|---|---|---|
|
||
| Live-DAT mount/element/string claims in the FA4 ledger row | Re-ran `ACDREAM_PROBE_LIVE_MOUNT=1 dotnet test --no-build -c Release --filter SocialPanelLiveMountProbeTests` against the installed DATs | **CONFIRMED.** `0x1000026F` builds as `UiField`; all 11 buttons/checkboxes as `UiButton`; ListBox `0x10000279` templates=1 `0x21000030/0x10000281`, scrollbar `0x1000027A`; all 5 row fields at the right widget types; labels resolve ("Ignore Fellowship Requests", "Automatically Accept Fellowship Requests", "Share Fellowship Experience and Luminance", "Share Fellowship Loot"); captions resolve `'Open'`/`'Close'`; production `Bind()` console output **empty** |
|
||
| The frame-containment "structural finding" | Same probe dump | **CONFIRMED and EXTENDED** — `0x1000026B` children are `0x10000272,0x10000273,0x10000274,0x1000026C..0x10000271`: the Create button, the name field **and all four checkboxes** live in the NOT-in-fellowship frame. See SHOULD-FIX 8 for the consequence the ledger missed |
|
||
| `GetEvenSplitXPPctg` table | Byte-read `.rdata` of the PDB-paired `acclient.exe` at the ten VAs lane B §7.2 names | **CONFIRMED verbatim**, including `0x007E72B8 = 0.31111109256744385` and default `0.0` |
|
||
| How retail converts that float to the displayed integer | Byte-decoded `0x0048ECC9..0x0048ECD8` + the callee at `0x005DE394` | **REFUTES the port** — see MUST-FIX 1 |
|
||
| Whether retail's client reads the two fellowship option bits on the invite path | Read `gmFellowshipUI::RecvNotice_FellowshipRequest @0x00490880`, `MakeFellowRequestDialog @0x00490620`, `ClientUISystem::Handle_Character__ConfirmationRequest @0x005640A0` in full + swept every `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests` occurrence in the 2013 pseudo-C | **REFUTES D6's invite half** — see MUST-FIX 2 |
|
||
| The 93 tests in the five touched test classes | `dotnet test --no-build -c Release --filter` | 93 passed / 0 failed |
|
||
|
||
---
|
||
|
||
## MUST-FIX 1 — D5's percentage conversion rounds where retail truncates: 6- and 8-fellow rows display the wrong number
|
||
|
||
`SocialFellowshipPageController.cs:650`
|
||
|
||
```csharp
|
||
return $"{member.Level} {(int)MathF.Round(pct * 100f)}%";
|
||
```
|
||
|
||
The class doc and the commit message both claim "The percentage itself IS
|
||
retail's exact number". The *table* is exact (byte-verified this review).
|
||
The **conversion is not**.
|
||
|
||
Retail, byte-decoded from the PDB-paired binary at
|
||
`gmFellowshipUI::UpdateFellowStats`:
|
||
|
||
```
|
||
0048ECC9 d9 44 24 10 fld dword [esp+0x10] ; pct (float)
|
||
0048ECCD d8 0d 70 51 7a 00 fmul dword [0x007A5170] ; = 100.0f (byte-read)
|
||
0048ECD3 e8 bc f6 14 00 call 0x005DE394 ; _ftol2
|
||
```
|
||
|
||
`0x005DE394` is MSVC's `_ftol2` (`55 8b ec 83 ec 20 83 e4 f0 d9 c0 d9 54
|
||
24 18 df 7c 24 10 df 6c 24 10 …` — the fld/fst/fistp/fild + correction
|
||
dance whose whole purpose is to turn the FPU's round-to-nearest `fistp`
|
||
into **C truncation toward zero**). The product is formed on the x87
|
||
stack from two floats, so it is exact; `_ftol2` then chops it.
|
||
|
||
Per-entry comparison (float bits read from the binary; acdream column is
|
||
the shipped expression):
|
||
|
||
| n | retail float | retail `trunc(pct×100)` | acdream `(int)MathF.Round(pct*100f)` |
|
||
|--:|---|--:|--:|
|
||
| 1 | 1.0 | 100 | 100 |
|
||
| 2 | 0.75 | 75 | 75 |
|
||
| 3 | 0.6000000238418579 | 60 | 60 |
|
||
| 4 | 0.550000011920929 | 55 | 55 |
|
||
| 5 | 0.5 | 50 | 50 |
|
||
| **6** | **0.44999998807907104** | **44** | **45** ❌ |
|
||
| 7 | 0.4000000059604645 | 40 | 40 |
|
||
| **8** | **0.3499999940395355** | **34** | **35** ❌ |
|
||
| 9 | 0.31111109256744385 | 31 | 31 |
|
||
| 10 | 0.2800000011920929 | 28 | 28 |
|
||
|
||
Two of the ten roster sizes are wrong. Note the failure is not fixed by
|
||
swapping `MathF.Round` for a cast: `0.45f * 100f` **already rounds up to
|
||
exactly `45.0f`** in single precision, so `(int)(pct * 100f)` still
|
||
yields 45. The product must be formed in a wider type, exactly as
|
||
retail's x87 does:
|
||
|
||
```csharp
|
||
return $"{member.Level} {(int)((double)pct * 100.0)}%";
|
||
```
|
||
|
||
(`(double)0.44999998807907104 * 100.0 = 44.999998807907104` → 44 ✓;
|
||
`0.3499999940395355 * 100.0 = 34.99999940395355` → 34 ✓; every other row
|
||
unchanged.)
|
||
|
||
**Also fix in the same commit:**
|
||
- `SocialFellowshipPageControllerTests.FormatStatsText_MatchesD5Rules`
|
||
exercises only n=1/n=9/proportional — neither failing size is pinned.
|
||
Add `[InlineData(true, true, 6, "12 44%")]` and
|
||
`[InlineData(true, true, 8, "12 34%")]`.
|
||
- The gate script's §FA4 step 21 currently presents the 9-member ACE gap
|
||
(AD-80) as the *only* percentage divergence a tester should tolerate,
|
||
which would prime them to accept the wrong 45% at six fellows as
|
||
correct. Same class as FA3's own mechanism MF-1/MF-2 script
|
||
corrections.
|
||
- AD-80's row text says acdream "renders retail's own byte-decoded XP-share
|
||
table verbatim" — true of the table, false of the rendered integer until
|
||
this lands.
|
||
|
||
---
|
||
|
||
## MUST-FIX 2 — D6's invite intercept is a client-side mechanism retail does not have, and it can silently swallow real invites
|
||
|
||
`RetailUiRuntime.cs:665-693`
|
||
|
||
```csharp
|
||
if (request.Type == (uint)GameEvents.ConfirmationType.Fellowship
|
||
&& TryAutoRespondToFellowshipInvite(request))
|
||
return true;
|
||
```
|
||
|
||
**(a) No retail anchor exists.** Read in full this review:
|
||
|
||
- `ClientUISystem::Handle_Character__ConfirmationRequest @0x005640A0` —
|
||
a bare 7-way jump table; `case 4` is one call to
|
||
`CM_Fellowship::SendNotice_FellowshipRequest`. No option read.
|
||
- `gmFellowshipUI::RecvNotice_FellowshipRequest @0x00490880` — copies the
|
||
string, tail-calls `MakeFellowRequestDialog`. No option read.
|
||
- `gmFellowshipUI::MakeFellowRequestDialog @0x00490620` — its **only**
|
||
guard is `if (this->m_fellowRequestContext == 0)` (`@0x00490635`), the
|
||
one-dialog-at-a-time rule lane B §2.4 already documents. No option read.
|
||
- A whole-file sweep of `IgnoreFellowshipRequests` /
|
||
`FellowshipAutoAcceptRequests` in `acclient_2013_pseudo_c.txt` returns
|
||
**only**: the two `PlayerOptionPage` label registrations
|
||
(`@0x004a0b56`/`@0x004a0d16`), an input-action toggle pair
|
||
(`@0x00561fbf`/`@0x005621ef`), `CPlayerModule::OnChanged`'s mutual
|
||
exclusion (`@0x0059a971`/`@0x0059a987`), and the `PlayerModule`
|
||
get/set switch arms (`@0x005d3aca`/`@0x005d3f11`/`@0x005d3c06`/
|
||
`@0x005d4031`). **Zero reads on any confirmation path.**
|
||
|
||
Retail's client shows the dialog unconditionally. This is the same shape
|
||
as FA1's D9 and FA2's D2: the plan asserted a mechanism, primary source
|
||
refutes it.
|
||
|
||
**(b) The code comment cites ACE server source as a retail anchor.**
|
||
`RetailUiRuntime.cs:669` reads "retail's `Fellowship.cs:121`-equivalent
|
||
client-side mirror". `Fellowship.cs` is `ACE.Server`. Lane B's own rows
|
||
for these two options say the opposite of what the comment implies —
|
||
feature 5: "**Store only.** ACE is the consumer; acdream has nothing to
|
||
do beyond sending the bit (already correct)"; feature 6: "**Store
|
||
only**".
|
||
|
||
**(c) Against a correct ACE it is dead code; against a drifting one it is
|
||
harmful.** Per lane B, ACE refuses the recruit outright when the target
|
||
has `IgnoreFellowshipRequests` (`Player_Fellowship.cs:98-102` →
|
||
`FellowshipIgnoringRequests 0x0417` to the *recruiter*) and auto-joins
|
||
without a confirmation when the target has `FellowshipAutoAcceptRequests`
|
||
(`Fellowship.cs:121` → `AddConfirmedMember(…, true)`). In both cases the
|
||
target's client never receives a type-4 request, so the intercept never
|
||
fires. It only fires when the client's copy of the bit disagrees with the
|
||
server's — and then it does the wrong thing.
|
||
|
||
**(d) That disagreement is the default state, not an edge case.**
|
||
`CharacterOptionTable.cs:113` gives `IgnoreFellowshipRequests` a client
|
||
default of **`true`**. A player who has never touched the option, on any
|
||
server whose own default differs or that does not implement the
|
||
server-side filter, will have every fellowship invite auto-declined with
|
||
**no dialog and no chat line** — indistinguishable from a broken client.
|
||
The FA3 gate script's §FA4 steps 18/19 cannot detect this: they pass
|
||
identically whether the intercept exists or not, because ACE filters
|
||
first.
|
||
|
||
**Recommended disposition (matching the FA1-D9 / FA2-D2 precedent):**
|
||
delete `TryAutoRespondToFellowshipInvite` and its call site, revert the
|
||
`IgnoreFellowshipRequests` / `FellowshipAutoAcceptRequests` rows in
|
||
`CharacterOptionsPageController.Groups` to `StoreOnly` (they return to
|
||
OP1's classification, which was right), and record the finding as a D6
|
||
addendum in the plan: *the invite-receive half of D6 is refuted; both
|
||
bits are server-consumed, acdream's job is to send them, and retail's own
|
||
client shows the dialog unconditionally.* If instead the user wants the
|
||
auto-respond behavior kept as a deliberate quality-of-life divergence,
|
||
it needs (i) an explicit register row, (ii) the ACE-as-retail citation
|
||
corrected, and (iii) an answer for the default-on `Ignore` bit — but that
|
||
is a user decision, not an implementer one.
|
||
|
||
---
|
||
|
||
## MUST-FIX 3 — D4 never re-declares `0x00A6` after a generation reset, so a reconnect leaves the vitals stream dark for the whole new session
|
||
|
||
`SocialFellowshipPageController.cs:453-458` +
|
||
`SocialPanelController.cs:250-268`
|
||
|
||
`SetPageVisible` is edge-triggered on `_pageVisible`, and
|
||
`FellowshipSetPanelOpen` is a no-op while disconnected (returns
|
||
`Inactive` without sending). Enumerating every transition:
|
||
|
||
| Transition | Behavior | Verdict |
|
||
|---|---|---|
|
||
| Panel opens with Fellowship active | `OnShown` → `_visible=true` → `SetPageVisible(true)` → sends `1` | ✓ |
|
||
| Panel opens on another tab | conjunction false, `_pageVisible` already false, no send | ✓ (retail's widget isn't visible either) |
|
||
| Switch **to** Fellowship while open | `ActivePageChanged` → sends `1` | ✓ |
|
||
| Switch **away** | sends `0` | ✓ |
|
||
| Close window while on Fellowship | `OnHidden` → sends `0` | ✓ |
|
||
| `ActivateTabs()` at mount (default = Allegiance, `Visible=false`) | conjunction false, no spurious send; ordering is safe because `ActivateTabs` runs before `RetailWindowFrame.Mount` (`RetailUiRuntime.cs:2775`) | ✓ |
|
||
| Logout | nothing sent; ACE's `LogOut_Inner` quits the fellowship anyway (lane B feature 21) and the flag is per-session server-side | ✓ — no close is owed |
|
||
| **Disconnect mid-open, then reconnect** | `_pageVisible` stays `true` across the generation reset; no visibility edge occurs on the new generation; **`0x00A6` is never sent again** | ❌ |
|
||
|
||
`RetailUiRuntime` is process-lifetime (`GameWindow.cs:1020`, published
|
||
once behind a "already owns interaction/UI state" guard), and
|
||
`ResetSessionTransientUi` (`RetailUiRuntime.cs:731-736`) only resets the
|
||
dialog controller, the appraisal controller and the Examination window —
|
||
it does not touch the social panel. `RetailWindowLayoutPersistence`'s
|
||
`RestoreAll()` on the new `EnteredWorld` will not produce an edge either,
|
||
because `RetailWindowHandle.NotifyVisibility` early-returns when
|
||
`_notifiedVisible == visible`.
|
||
|
||
Observable result: after any in-process reconnect with the panel left
|
||
open on Fellowship, every fellow's health/stamina/mana freezes for the
|
||
rest of the session (lane B §4.5: `Fellowship.OnVitalUpdate` sends
|
||
`0x02C0` only to fellows whose `FellowshipPanelOpen` is true, and that
|
||
flag is set only by `0x00A6`).
|
||
|
||
**Fix:** clear the declaration latch at the existing session-reset seam
|
||
so the next `Tick`/visibility evaluation re-declares — e.g. a
|
||
`SocialPanelController.ResetSessionDeclaration()` that resets
|
||
`_fellowship`'s `_pageVisible` to `false` and then re-runs
|
||
`UpdateFellowshipPageVisibility()`, called from
|
||
`ResetSessionTransientUi`. That is a one-line addition at a seam that
|
||
already runs on every generation reset (`LiveSessionRuntimeFactory.cs:182`
|
||
→ `LiveSessionResetBindings.SessionDialogs`) — not a timer, not a poll.
|
||
|
||
**Honesty note on the retail comparison:** `gmFellowshipUI` has no
|
||
`OnEndCharacterSession` (verified — its method list is
|
||
Create/PostInit/Update/UpdateButtons/UpdateFellow*/RecvNotice_*/
|
||
ListenToElementMessage/OnVisibilityChanged/MakeFellowRequestDialog and
|
||
nothing else), and its `PostInit` does **not** send `Event_UpdateRequest`
|
||
(lane A §6.3 — only the allegiance panel does). Retail's re-declaration
|
||
therefore comes from `OnVisibilityChanged` firing when the in-game UI
|
||
state is torn down/restored around character select; that inference is
|
||
not byte-established here. The *defect* stands regardless of how retail
|
||
gets it right: acdream reaches a state where the stream it just turned on
|
||
is off and nothing will ever turn it back on.
|
||
|
||
**Also:** the gate script §FA4 has no reconnect step, so the owed
|
||
connected gate cannot catch this. Add one after step 8.
|
||
|
||
---
|
||
|
||
## MUST-FIX 4 — `gmFellowshipUI::UpdateFellowSelection` is not ported: selecting a fellow in the WORLD leaves Dismiss/Leader disabled, and no row ever shows as selected
|
||
|
||
The slice ports one direction of retail's two-directional selection
|
||
coupling. `SelectFellow` (`SocialFellowshipPageController.cs:676-680`)
|
||
correctly does panel → world (`SelectionChangeSource.Social`, the FA4
|
||
Runtime commit). The reverse arm is missing:
|
||
|
||
`gmFellowshipUI::UpdateFellowSelection @0x0048F0F0`, reached from
|
||
`RecvNotice_SelectionChanged @0x0048F1C0` (and from `Update`
|
||
`@0x0048F6E9`), walks the list box on every world-selection change:
|
||
|
||
```
|
||
selectedID = ACCWeenieObject::selectedID
|
||
for each row:
|
||
if row.GetAttribute_InstanceID(0x1000000D) == selectedID:
|
||
m_iidSelectedFellow = selectedID
|
||
SetSelectedItem(listBox, row, 0) // and return
|
||
else if that id == m_iidSelectedFellow:
|
||
remember row as the fallback
|
||
SetSelectedItem(listBox, fallbackOrNull, 0)
|
||
UpdateButtons(this)
|
||
```
|
||
|
||
So in retail, clicking a fellow **in the 3D world** (or reaching them via
|
||
any other selection origin) selects their panel row and enables
|
||
Dismiss/Assign-Leader. In acdream `_selectedFellowGuid` is written only
|
||
by the row name-text `OnClick`
|
||
(`SocialFellowshipPageController.cs:599,676`), so the world→panel arm
|
||
does nothing and those two buttons stay greyed.
|
||
|
||
Two further consequences of the same gap:
|
||
|
||
- **No visual selection at all.** There is no `SetSelectedItem`
|
||
equivalent — nothing in the roster ever indicates which fellow is
|
||
selected. The user clicks a name and gets no feedback until they notice
|
||
two buttons un-greying.
|
||
- **The plan's FA4 contract line is not met.** §3's FA4 row reads
|
||
"roster rows (adds `UiTemplateListBox` Flush/selection/row-instance-id
|
||
— lane A sized this)". `Flush` pre-dated FA4 (FA3); this slice added
|
||
`FlushPreservingScroll` only. **No selection model and no per-row
|
||
instance-id were added** — retail's row identity key is
|
||
`SetAttribute_InstanceID(row, 0x1000000D, fellowIid)` (lane A §3.1 row
|
||
table, `ghidra@0x0048F440:95`), the exact primitive both
|
||
`ListenToElementMessage` and `UpdateFellowSelection` read. The ledger's
|
||
four-item "Contradictions/deferrals" list does not mention this.
|
||
|
||
Minimum fix: give `UiTemplateListBox` a row-instance-id + selected-row
|
||
concept (or, if that is deferred, say so explicitly in the ledger and
|
||
file the register row), and subscribe the controller to
|
||
`SelectionState.Changed` — or simply re-derive `_selectedFellowGuid` from
|
||
`Selection.SelectedObjectId` in `RefreshButtonStates` when the selected
|
||
world object is a member — so the world→panel arm exists.
|
||
|
||
---
|
||
|
||
## MUST-FIX 5 — three shipped deviations have no register row (CLAUDE.md's register rule, plan §5)
|
||
|
||
The register rule is binding and explicit: *"Any commit that introduces a
|
||
deviation adds its register row IN THE SAME COMMIT… A deviation found
|
||
without a row is a bug twice over."* `38f08314` files AD-80 (D5 display
|
||
vs ACE) and AD-81 (StringInfo/FormatName) and amends AD-78. Missing:
|
||
|
||
1. **The D6 client-side invite intercept** — a client behavior retail
|
||
does not have (MUST-FIX 2). If it survives review at all it needs its
|
||
own row; if it is deleted, the row is not owed.
|
||
2. **The gold leader-name tint** (`LeaderNameColor` `(1, 0.84, 0, 1)`,
|
||
`SocialFellowshipPageController.cs:110,614`). This is an invented
|
||
user-visible color with no DAT or decomp anchor — the class doc says
|
||
so in as many words ("a minimal, clearly-adaptive visual cue rather
|
||
than inventing a DAT mechanism that was not found"). That is precisely
|
||
an AD row: a reviewer comparing a retail screenshot will see a color
|
||
retail never paints. Same class as AD-78's own rationale.
|
||
3. **Row selection restricted to the name text.** Retail's list box
|
||
selects on the *row* (`ListenToElementMessage` message `3`/`0x42`,
|
||
lane B §2.8). acdream binds `OnClick` to the row's name `UiText` only,
|
||
so clicking the stats text, any meter, or row whitespace does nothing.
|
||
Documented in `SelectFellow`'s doc comment; not registered.
|
||
|
||
Separately, the ledger's decision to keep the **Recruit "is the target a
|
||
player" gate** as "an inline comment, not a register row" is the wrong
|
||
call by the same rule. Retail *disables* the button
|
||
(`UpdateButtons`, lane B §2.8: "selected world object **is a player**,
|
||
not already a fellow, and `!IsFull`"); acdream enables it and lets the
|
||
server refuse. That is a divergence with a user-visible symptom (a lit
|
||
Recruit button that does nothing when a chest is selected), and the
|
||
justifying comment — "exactly like retail's own silent no-op" — is
|
||
inexact, because retail's click handler is unreachable when the button is
|
||
disabled.
|
||
|
||
---
|
||
|
||
## SHOULD-FIX
|
||
|
||
**SF-1 — `RefreshFellowshipName` reintroduces the per-frame `LinesProvider`
|
||
closure allocation that FA3's fix round removed from the sibling page one
|
||
commit earlier.**
|
||
`SocialFellowshipPageController.cs:489-493` assigns
|
||
`() => [new UiText.Line(name, MemberNameColor)]` on **every** `Tick`
|
||
while in a fellowship — a display-class + delegate allocation per frame,
|
||
and `SocialPanelController.Tick` calls the fellowship page
|
||
unconditionally, so it allocates while the panel is hidden too.
|
||
`35c40a9b` fixed exactly this in `SocialAllegiancePageController`
|
||
(hoisted `BlankLineProvider`/`NoLinesProvider` statics, doc: "zero
|
||
allocation while idle") in response to FA3 mechanism SF-2 / blast SF-4.
|
||
Cache the provider and only reassign when `name` actually changes.
|
||
|
||
**SF-2 — the commit message's carry-forward-4 claim does not describe the
|
||
code.** The message says "The Fellowship roster path never advances its
|
||
revision latch on a partial resolver failure until the NEXT real
|
||
membership change — never a per-frame retry loop."
|
||
`SocialFellowshipPageController.cs:433-437` advances `_lastRosterRevision`
|
||
**unconditionally**, before `RefreshRoster` runs. What actually happens
|
||
on a permanently-unbuildable template: `RebuildRoster` `continue`s past
|
||
the failed row (`:580-586`), leaving `_rows.Count < members.Count`, so
|
||
`membershipChanged` is true on **every** subsequent revision bump — i.e.
|
||
a full `FlushPreservingScroll` + N `LayoutImporter.Build` calls under the
|
||
shared DAT lock on every incoming `0x02C0` vitals tick, not "until the
|
||
next real membership change". `RowTemplateResolver` bounds the *import*
|
||
(a null import is cached, `RowTemplateResolverTests` pins it) but not the
|
||
*build*. Either make the claim accurate or bound the rebuild (e.g. latch
|
||
a per-generation "row template is unbuildable" flag).
|
||
|
||
**SF-3 — Recruit's "already a fellow" test reads render rows, not
|
||
membership.** `SocialFellowshipPageController.cs:527` uses
|
||
`!_rows.ContainsKey(id)`. Retail uses `Fellowship::IsFellow`. If a row
|
||
failed to build (SF-2's path), an existing fellow passes `targetValid`
|
||
and Recruit lights up for someone already in the fellowship. Use the
|
||
member set.
|
||
|
||
**SF-4 — `SocialPanelController.Dispose` does not unsubscribe
|
||
`ActivePageChanged`.** `:140` subscribes a `this`-capturing lambda;
|
||
`:305-309` only sets `_disposed`. `Tick()` guards on `_disposed`
|
||
(`:295`); the event handler does not, so a tab switch after disposal
|
||
still reaches `SetPageVisible` → a Runtime command. Add the unsubscribe
|
||
(or a `_disposed` check in `UpdateFellowshipPageVisibility`).
|
||
|
||
**SF-5 — test naming/coverage gaps on the slice's headline mechanisms.**
|
||
- `Tick_MembershipChange_RebuildsRoster_ButPreservesScrollPosition`
|
||
(`SocialFellowshipPageControllerTests.cs:244`) asserts only
|
||
`Children.Count == 2`; its own comment admits it cannot observe the
|
||
preserved offset. Rename it or build a roster tall enough to scroll.
|
||
(The widget-level `UiTemplateListBoxFlushPreservingScrollTests` **do**
|
||
cover the shrink case correctly — re-derived: `ClearContent` zeroes
|
||
`UiScrollablePanel.ContentHeight` but not `UiScrollable.ContentHeight`,
|
||
so the restore clamps against the stale height, and
|
||
`LayoutScrollableChildren` re-clamps against the fresh height at the
|
||
head of the next `OnDraw`, before anything paints. That is sound.)
|
||
- **D4's actual conjunction is untested.** The only D4 test is
|
||
`SetPageVisible_SendsPanelOpen_OnlyOnATransition` on the *page*
|
||
controller. Nothing tests `SocialPanelController`'s
|
||
`_visible && IsShowingFellowship` logic, the `ActivePageChanged`
|
||
subscription, or `OnShown`/`OnHidden` — the mechanism the slice is
|
||
named for. `SocialPanelControllerTests` gained a bindings helper and
|
||
zero new tests.
|
||
- No controller-level test for a member **leaving** (the shrink rebuild),
|
||
and none for `_selectedFellowGuid` being cleared when the selected
|
||
fellow departs (`:568-569`).
|
||
|
||
**SF-6 — the live-mount probe prints the strings it is cited for but does
|
||
not assert them.** `SocialPanelLiveMountProbeTests.cs:185-201` writes the
|
||
four checkbox labels and the two captions to the console with no
|
||
assertion, yet the ledger's live-DAT paragraph cites them as verified.
|
||
This is the same finding FA3's own mechanism SF-3 raised ("printed but
|
||
never asserted — deserves a real assertion, not just a hope") for
|
||
`0x10000492` and page exclusivity. Assert non-null (and, for the two
|
||
captions, the exact `"Open"`/`"Close"`).
|
||
|
||
**SF-7 — gate-script §FA4 defects (the FA3 MF-1/MF-2 class).**
|
||
- Step 3 contradicts itself: "Only the Quit button should be enabled …
|
||
Disband and Open should ALSO be enabled." A tester following the first
|
||
clause reports correct behavior as a defect.
|
||
- Steps 18/19 claim to gate D6, but per lane B ACE filters both bits
|
||
server-side and never sends the confirmation, so both steps pass
|
||
identically with or without the client intercept. They cannot fail.
|
||
- No reconnect step (MUST-FIX 3) and no six/eight-fellow percentage step
|
||
(MUST-FIX 1) — the two defects most likely to reach a user are both
|
||
outside the script's reach.
|
||
|
||
**SF-8 — D7's `FellowshipShareLoot` un-dim does not meet AD-78's own
|
||
definition of "Live".** AD-78 defines the dimmed set as rows that
|
||
"persist and, where auto-save, send the wire bit, but drive nothing
|
||
observable client-side". `FellowshipShareLoot`'s stated new consumer is
|
||
"a SECOND live checkbox surface on the fellowship page itself" — but a
|
||
second editor of the same stored value is not a consumer, nothing in
|
||
acdream reads the bit (`FormatStatsText` uses `snapshot.ShareXp` only,
|
||
and the `0x00A2` Create builder carries `shareXP` alone), and the live
|
||
DAT dump confirms that checkbox (`0x10000273`) is a child of the
|
||
NOT-in-fellowship frame `0x1000026B`, i.e. **invisible whenever you are
|
||
in a fellowship**. Re-dim it, or state the widened definition explicitly
|
||
in AD-78. (`FellowshipShareXP` is defensible: the Create click genuinely
|
||
reads it, `:325-326`.) The same "second surface" argument in
|
||
`CharacterOptionsPageController`'s doc should be corrected either way.
|
||
|
||
**SF-9 — AD-78's own row still says "35 of 50 rows dimmed".** (Carried
|
||
from the superseded review, verified independently.) `38f08314`'s message
|
||
claims "AD-78's derivation table gains its D7 addendum", but the addendum
|
||
landed in `CharacterOptionsPageController`'s **class doc** (in
|
||
`5bdd0528`); the register row's own "Where" column still reads
|
||
`CharacterOptionsPageController.cs (35 of 50 rows dimmed …)` while the
|
||
post-FA4 count is 31 of 50. The register is "the single auditable list" —
|
||
a stale count in an active row is exactly the drift AD-78's own "Risk"
|
||
column warns about. (Note this interacts with SHOULD-FIX 8 and MUST-FIX
|
||
2: if either lands, the correct number is not 31 either. Fix the count
|
||
last.)
|
||
|
||
---
|
||
|
||
## NIT
|
||
|
||
**N-0 — the Open/Close caption does not optimistically pre-toggle.**
|
||
(Carried from the superseded review, verified.) Lane B feature 11 records
|
||
that retail's Open button handler "client pre-toggles its own
|
||
`_open_fellow`" before `Event_ChangeFellowOpeness` — i.e. the caption
|
||
flips immediately and the server echo confirms it. `RefreshOpenCaption`
|
||
(`:495-508`) keys purely off server-authoritative `snapshot.IsOpen`, so
|
||
the caption flips only when the `0x02BE` echo lands. Convergent, and
|
||
invisible on localhost; not byte-faithful.
|
||
|
||
**N-1 — meter text vs authored child.** Retail's `UpdateFellowVitals
|
||
@0x0048ED60` sets meter attribute `0x69` to `cur/max` and writes the
|
||
cur/max ints into the meter's authored **child** text elements
|
||
(`0x10000286` under health, `0x10000288` under stamina). acdream uses
|
||
`UiMeter.Fill`/`UiMeter.Label` because `UiMeter.ConsumesDatChildren` is
|
||
true — a pre-existing widget decision, not FA4's, but worth one line in
|
||
`SetVitals`'s doc so the next reader does not go hunting for the
|
||
unbound `0x10000286`/`0x10000288`/`0x1000028A`.
|
||
|
||
**N-2 — `max > 0` guard.** `SetVitals` (`:661`) returns `0f` when
|
||
`max == 0`; retail divides unconditionally. The guard is the right
|
||
engineering call; just say so, since "verbatim port" is claimed nearby.
|
||
|
||
**N-3 — snapshot/roster read atomicity.** `Tick` takes
|
||
`_bindings.Snapshot()` and `_bindings.Members()` under two separate
|
||
acquisitions of `RuntimeFellowshipState._gate`, so `Revision` /
|
||
`MemberCount` can momentarily disagree with the returned roster. It is
|
||
self-correcting on the next revision bump and harmless in practice
|
||
(single-threaded tick today), but a one-line comment would stop a future
|
||
reader from assuming atomicity.
|
||
|
||
---
|
||
|
||
## What is faithful (checked, no action)
|
||
|
||
- **Button-enable table** (`RefreshButtonStates`, `:510-537`) matches lane
|
||
B §2.8 exactly, including the non-obvious Recruit rule
|
||
(`isLeader || snapshot.IsOpen`) and Quit-always-enabled.
|
||
- **Open/Close caption reads as the ACTION, not the state** (`:495-508`),
|
||
resolved once at `Bind` and swapped from cache — correct, and correctly
|
||
refuses to invent English when the strings do not resolve.
|
||
- **Create gating** — empty/whitespace name disables the button and the
|
||
click double-checks (`:322-327`, `:460-465`); no invented error text,
|
||
matching lane B §2.2's "the button IS the refusal mechanism".
|
||
- **Quit/Disband share `0x00A3` with the flag flipped**, and both route
|
||
through `IRuntimeFellowshipCommands.Quit` so the retail pre-quit
|
||
`0x0290` leader hand-off in `RequiresLeaderHandoffBeforeQuit` is not
|
||
bypassed — the reason the commit gives for not calling `WorldSession`
|
||
directly is correct and load-bearing.
|
||
- **The roster diff never rebuilds on a vitals tick** — the FA3
|
||
carry-forward's exact hazard. Verified by construction (set comparison
|
||
at `:551-560`, in-place `UpdateRow` otherwise) and by
|
||
`Tick_SameMemberSet_UpdatesRowsInPlace_NoRebuild`'s `Assert.Same` on
|
||
the row instance.
|
||
- **`FlushPreservingScroll` shrink semantics** — re-derived against
|
||
`UiScrollablePanel`/`UiScrollable`; correct, and pinned by
|
||
`FlushPreservingScroll_ClampsToTheNewShorterContent`.
|
||
- **`EvenSplitPercentTable` and the `>= 9` full check** — byte-verified
|
||
against the binary, including the out-of-range `0.0` default matching
|
||
retail's unsigned `ja` fallthrough for n=0.
|
||
- **`RowTemplateResolver`** — clean extraction, caches the import (miss
|
||
included) and rebuilds per row; carry-forward 2 satisfied.
|
||
- **`IRuntimeFellowshipView.GetMembers`** — materialized under the same
|
||
lock as every other read, honest ordering caveat in its doc.
|
||
- **`SelectionChangeSource.Social`** — additive; no exhaustive switch on
|
||
the enum exists in production, so no dispatch site was missed.
|
||
|
||
---
|
||
|
||
## Appendix — collision with a concurrent review at `6849b457`
|
||
|
||
A second mechanism-lens pass landed on this same path at `6849b457`
|
||
(2026-08-12 04:59:35) while this one was in progress; this document
|
||
replaces it. **Its text is not lost — read it with
|
||
`git show 6849b457:docs/research/2026-08-12-fa4-review-mechanism.md`.**
|
||
Its two unique findings are carried forward above (SF-9, N-0). The two
|
||
reviews disagree in exactly two places, both adjudicated here from
|
||
primary source:
|
||
|
||
| Topic | `6849b457` | This review | Why |
|
||
|---|---|---|---|
|
||
| `MathF.Round` vs `_ftol2` | **NIT 6, "non-issue"** — "For every value in the even-split table (×100 = 100/75/60/55/50/45/40/35/31.11/28) round and truncation agree, so there is no observable difference on any reachable input" | **MUST-FIX 1** | That reasoning uses the DECIMAL literals. The stored constants are floats: `0x007C91D4 = 0.44999998807907104` and `0x007E72BC = 0.3499999940395355` (byte-read from the PDB-paired binary this review), so retail's `×100` products are `44.999998…` and `34.999999…` and `_ftol2` chops them to **44** and **34**. Two of ten roster sizes differ. This is precisely the campaign's §6 rule ("BN literal-0 operands are byte-verified before use") applied one level deeper — to the `.rdata` float, not the decompiler's rendering of it |
|
||
| D6 invite auto-response | **"✓ verified clean"** — describes the intercept's shape and Runtime's mutual exclusion, then passes it | **MUST-FIX 2** | The review verified that the code does what the PLAN says. Mechanism faithfulness asks whether RETAIL does it. `Handle_Character__ConfirmationRequest @0x005640A0`, `RecvNotice_FellowshipRequest @0x00490880` and `MakeFellowRequestDialog @0x00490620` were read in full here: no option read on any confirmation path, and a whole-file sweep of both accessors finds reads only in chargen, the input-action toggle, `OnChanged`'s mutual exclusion, and the `PlayerModule` get/set switch |
|
||
|
||
The two reviews AGREE on D4-not-re-armed-across-reconnect (its SHOULD-FIX
|
||
3, raised here to MUST-FIX 3 because it silently disables the stream the
|
||
slice exists to enable), on the missing panel-level D4 conjunction test
|
||
(its SHOULD-FIX 4 = SF-5 here), on the leader-gold-tint register omission
|
||
(its MUST-FIX 1 = part of MUST-FIX 5 here), and on the live-DAT probe
|
||
result.
|