docs: FA3 mechanism review -- APPROVE-WITH-FIXES (2 MUST-FIX, 9 SHOULD-FIX)
Campaign FA slice FA3 mechanism-faithfulness review of0a9ca2f1/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>
This commit is contained in:
parent
e9eb756480
commit
9e622f565e
1 changed files with 514 additions and 0 deletions
514
docs/research/2026-08-12-fa3-review-mechanism.md
Normal file
514
docs/research/2026-08-12-fa3-review-mechanism.md
Normal file
|
|
@ -0,0 +1,514 @@
|
||||||
|
# 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 1–3 (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 7–8'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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue