diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
index a9512376..aecc6c82 100644
--- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
+++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
@@ -876,11 +876,28 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
// directly (the panel's read-only rows need full-collection
// enumeration, not the bot-facing IRuntimeSocialView's
// per-id lookup).
+ // Campaign FA slice FA4: the seven Fellowship write commands
+ // route through `late.GameRuntime` (DeferredGameRuntimeStateCommands)
+ // — the same late-bound, generation-race-safe seam
+ // AddShortcut/Advance/etc already use — NOT a raw
+ // WorldSession.SendXxx call (see SocialRuntimeBindings' own
+ // FA4 doc addendum for why Quit specifically must not
+ // bypass the command layer).
Social: new SocialRuntimeBindings(
() => d.Runtime.Fellowship.Snapshot,
() => d.Runtime.Allegiance.Snapshot,
d.Communication.Friends,
- d.Communication.Squelch),
+ d.Communication.Squelch,
+ () => d.Runtime.Fellowship.GetMembers(),
+ (name, shareXp) => late.GameRuntime.FellowshipCreate(name, shareXp),
+ guid => late.GameRuntime.FellowshipRecruit(guid),
+ guid => late.GameRuntime.FellowshipDismiss(guid),
+ disband => late.GameRuntime.FellowshipQuit(disband),
+ guid => late.GameRuntime.FellowshipAssignLeader(guid),
+ isOpen => late.GameRuntime.FellowshipSetOpen(isOpen),
+ panelOpen => late.GameRuntime.FellowshipSetPanelOpen(panelOpen),
+ d.Actions.Selection,
+ () => d.PlayerIdentity.ServerGuid),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,
diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
index 5e5ceb54..12b410c4 100644
--- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
+++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
@@ -118,6 +118,46 @@ internal sealed class DeferredGameRuntimeStateCommands
generation,
new RuntimeAdvancementCommand(kind, statId, cost)));
+ // ── Campaign FA slice FA4: fellowship page commands ─────────────────
+ // Same "capture view+commands under one generation" shape as every
+ // method above — a displaced session (reconnect mid-click) can never
+ // receive a stale-generation fellowship action.
+
+ public RuntimeCommandResult FellowshipCreate(string fellowshipName, bool shareXp) =>
+ Invoke((commands, generation) => commands.Fellowship.Create(
+ generation, fellowshipName, shareXp));
+
+ public RuntimeCommandResult FellowshipRecruit(uint targetGuid) =>
+ Invoke((commands, generation) => commands.Fellowship.Recruit(
+ generation, targetGuid));
+
+ public RuntimeCommandResult FellowshipDismiss(uint targetGuid) =>
+ Invoke((commands, generation) => commands.Fellowship.Dismiss(
+ generation, targetGuid));
+
+ public RuntimeCommandResult FellowshipQuit(bool disband) =>
+ Invoke((commands, generation) => commands.Fellowship.Quit(
+ generation, disband));
+
+ public RuntimeCommandResult FellowshipAssignLeader(uint newLeaderGuid) =>
+ Invoke((commands, generation) => commands.Fellowship.AssignLeader(
+ generation, newLeaderGuid));
+
+ public RuntimeCommandResult FellowshipSetOpen(bool isOpen) =>
+ Invoke((commands, generation) => commands.Fellowship.SetOpen(
+ generation, isOpen));
+
+ /// 0x00A6 — D4's panel-open/close declaration (the
+ /// vitals-stream gate, lane B §4.5). Idempotent at the CALLER
+ /// (
+ /// only invokes this on an actual visibility transition); this method
+ /// itself just forwards, exactly like every sibling command — a call
+ /// while disconnected returns
+ /// from without sending anything.
+ public RuntimeCommandResult FellowshipSetPanelOpen(bool panelOpen) =>
+ Invoke((commands, generation) => commands.Fellowship.SetPanelOpen(
+ generation, panelOpen));
+
public void Deactivate()
{
lock (_gate)
diff --git a/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs b/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs
index 8bc483cd..7cfe75c5 100644
--- a/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs
+++ b/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs
@@ -111,7 +111,7 @@ public static class CharacterOptionsPageController
/// steps 13-18, written after the code landed):
///
///
- /// - NOT dimmed (15 rows, real acdream-side
+ ///
- NOT dimmed (19 rows, real acdream-side
/// consumer): Group B rows actually bound —
/// ViewCombatTarget/AutoTarget/AutoRepeatAttack
/// (combat: LiveCombatAttackOperations.cs,
@@ -122,10 +122,17 @@ public static class CharacterOptionsPageController
/// DisableDistanceFog/DisplayTimeStamps/ToggleRun
/// (Group B, polled at GameWindow.cs:655-680),
/// DragItemOnPlayerOpensSecureTrade (TS-48,
- /// InteractionRetainedUiComposition.cs:326), and all six
+ /// InteractionRetainedUiComposition.cs:326), all six
/// ListenTo*Chat ids (TurbineChatMembershipGate.cs:105-136
- /// gates every Turbine room join on the matching bit).
- /// - Dimmed (35 rows, store-only): every
+ /// gates every Turbine room join on the matching bit), and — landed at
+ /// Campaign FA slice FA4, D7 — IgnoreFellowshipRequests/
+ /// FellowshipAutoAcceptRequests (consumed by
+ /// RetailUiRuntime.TryAutoRespondToFellowshipInvite's auto-
+ /// decline/auto-accept, D6) and FellowshipShareXP/
+ /// FellowshipShareLoot (consumed by the fellowship page's own
+ /// Create flow / D5 display / second checkbox surface,
+ /// SocialFellowshipPageController).
+ /// - Dimmed (31 rows, store-only): every
/// remaining Group A row (wire+store only — ACE, not acdream, is the
/// consumer) and every remaining Group B row the OP4 gate script's own
/// step 16 lists as "no consumer surface" (ShowTooltips,
@@ -134,8 +141,10 @@ public static class CharacterOptionsPageController
/// DisableMostWeatherEffects, PersistentAtDay,
/// FilterLanguage, MainPackPreferred), plus every Group D
/// deferral (SalvageMultiple,
- /// DisableHouseRestrictionEffects, FellowshipShareXP,
- /// HearPkDeathMessages).
+ /// DisableHouseRestrictionEffects,
+ /// HearPkDeathMessages) — FellowshipShareXP left Group D
+ /// at FA4 (above), it stays a bullet-list resident here only for the
+ /// historical count.
/// - Flagged ambiguity, resolved by code evidence
/// (see final report, not re-litigated here): the research doc's
/// §7.1 lists AcceptLootPermits in BOTH Group A's bullet list AND
@@ -145,13 +154,14 @@ public static class CharacterOptionsPageController
/// not a behavioral reader — ACE alone decides whether to honor corpse
/// looting (Player_Death.cs:755). Classified Group A / dimmed.
/// IgnoreFellowshipRequests/FellowshipAutoAcceptRequests
- /// have a genuine coded mutual-exclusion side effect
+ /// had a genuine coded mutual-exclusion side effect
/// (RuntimeCharacterState.TrySetOption, MF-2) that the research
- /// doc's Group A classification does not capture (the doc predates
- /// OP1's review-fix round) — kept dimmed per the doc's explicit Group A
- /// listing and this task's instruction to treat the doc as the sole
- /// authority rather than infer from code comments; a future un-dim is a
- /// one-line judgment call, not a re-investigation.
+ /// doc's Group A classification did not capture, kept dimmed at OP1
+ /// pending "a future un-dim is a one-line judgment call, not a
+ /// re-investigation" — that future consumer landed at Campaign FA slice
+ /// FA4 (see D7's addendum to register row AD-78), so both rows moved to
+ /// the NOT-dimmed bullet above, together with the two
+ /// FellowshipShare* rows FA4 also gave real consumers.
///
///
private const bool Live = false;
@@ -192,12 +202,12 @@ public static class CharacterOptionsPageController
}),
new("ID_CharacterOption_Grouping_Section", new RowSpec[]
{
- new(CharacterOptionId.IgnoreAllegianceRequests, "IgnoreAllegianceRequests", StoreOnly), // Group A
- new(CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests", StoreOnly), // Group A (see ambiguity note)
- new(CharacterOptionId.DisplayAllegianceLogonNotifications, "DisplayAllegianceLogonNotifications", StoreOnly), // Group A
- new(CharacterOptionId.FellowshipShareXP, "FellowshipShareXP", StoreOnly), // Group D
- new(CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot", StoreOnly), // Group A
- new(CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests", StoreOnly), // Group A (see ambiguity note)
+ new(CharacterOptionId.IgnoreAllegianceRequests, "IgnoreAllegianceRequests", StoreOnly), // Group A — stays dimmed, D7/AD-78 addendum
+ new(CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests", Live), // D7/D6 (Campaign FA slice FA4): consumed by the fellowship-invite auto-decline (RetailUiRuntime.TryAutoRespondToFellowshipInvite)
+ new(CharacterOptionId.DisplayAllegianceLogonNotifications, "DisplayAllegianceLogonNotifications", StoreOnly), // Group A — stays dimmed, D7/AD-78 addendum
+ new(CharacterOptionId.FellowshipShareXP, "FellowshipShareXP", Live), // D7 (Campaign FA slice FA4): consumed by the fellowship Create flow (its value IS the sent shareXP bit) and the panel's D5 XP-share display
+ new(CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot", Live), // D7 (Campaign FA slice FA4): now also a SECOND live checkbox surface on the fellowship page itself (SocialFellowshipPageController)
+ new(CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests", Live), // D7/D6 (Campaign FA slice FA4): consumed by the fellowship-invite auto-accept (suppresses the confirmation dialog)
}),
new("ID_CharacterOption_OtherPlayers_Section", new RowSpec[]
{
diff --git a/src/AcDream.App/UI/Layout/RowTemplateResolver.cs b/src/AcDream.App/UI/Layout/RowTemplateResolver.cs
new file mode 100644
index 00000000..17f018ae
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/RowTemplateResolver.cs
@@ -0,0 +1,74 @@
+namespace AcDream.App.UI.Layout;
+
+///
+/// Caching row-template resolver for
+/// consumers whose row COUNT can change across a session (a live roster,
+/// not a fixed authored set) — Friends, Squelch, and (Campaign FA slice
+/// FA4) Fellowship all resolve their 0x64 template entries through
+/// one instance of this class.
+///
+///
+/// Originally a private local function inside
+/// (Campaign FA slice FA3
+/// fix-round blast SF-2): the ORIGINAL shape re-ran
+/// LayoutImporter.ImportInfos — a full DAT tree walk — under the
+/// shared DAT lock on EVERY row, every revision, even while the panel was
+/// closed. The fix caches each template id pair's resolved
+/// tree the FIRST time it is imported and never
+/// re-imports for that pair again; still calls
+/// (constructor parameter) on every invocation
+/// because each row needs its OWN instance — only
+/// the expensive per-row DAT tree WALK is memoized, not the built widget.
+///
+///
+///
+/// Extracted to its own class in FA4 (carry-forward 2, the FA3 re-review's
+/// non-blocking finding: "add a production-resolver test for the new
+/// template cache") so this caching behavior is unit-testable against fake
+/// / delegates — no
+/// live DAT access needed to prove the cache actually short-circuits a
+/// repeat import. Production callers still take the shared DAT lock
+/// AROUND at the call site (see
+/// RetailUiRuntime.MountSocialPanel) — this class has no lock of its
+/// own, matching the "controller has no DAT dependency of its own" shape
+/// every other hermetically-testable page controller in this codebase
+/// already follows.
+///
+///
+public sealed class RowTemplateResolver
+{
+ private readonly Dictionary<(uint LayoutId, uint ElementId), ElementInfo?> _cache = new();
+ private readonly Func _importInfos;
+ private readonly Func _build;
+
+ /// Number of times (the constructor
+ /// parameter) actually ran — i.e. cache MISSES. Exposed for the
+ /// production-resolver conformance test; not used by any production
+ /// code path.
+ public int ImportCount { get; private set; }
+
+ public RowTemplateResolver(
+ Func importInfos,
+ Func build)
+ {
+ _importInfos = importInfos ?? throw new ArgumentNullException(nameof(importInfos));
+ _build = build ?? throw new ArgumentNullException(nameof(build));
+ }
+
+ /// Resolves one row template. Null if the (layoutId, elementId)
+ /// pair does not import (cached as a miss — a permanently-unresolvable
+ /// template is not retried on every call; see the class doc's cache
+ /// contract). 's exact
+ /// signature.
+ public UiElement? Resolve(uint templateLayoutId, uint templateElementId)
+ {
+ var key = (templateLayoutId, templateElementId);
+ if (!_cache.TryGetValue(key, out ElementInfo? info))
+ {
+ info = _importInfos(templateLayoutId, templateElementId);
+ _cache[key] = info;
+ ImportCount++;
+ }
+ return info is null ? null : _build(info);
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs b/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs
index f94d6c9c..9ec9e69a 100644
--- a/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs
+++ b/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs
@@ -1,52 +1,230 @@
using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Selection;
using AcDream.Runtime;
namespace AcDream.App.UI.Layout;
///
-/// Campaign FA slice FA3: the Fellowship page's empty-state frame swap —
-/// the ONLY behavior this SHELL slice owns for this page (roster rows,
-/// vitals, create dialog, and the option row un-dims are FA4 scope).
+/// Campaign FA slice FA4: the Fellowship page fully live — roster rows, the
+/// 0x00A6 panel-open/vitals-stream gate (D4), the inline create flow,
+/// member actions + their button-enable rules, the four option checkboxes,
+/// and the open/close caption swap. FA3 shipped only the empty/full frame
+/// swap (see that slice's own doc, still the authority for the frame-pair
+/// containment finding).
///
///
-/// Retail gmFellowshipUI::Update @0x0048F440
-/// (docs/research/2026-08-11-fa-panel-structure.md §4.5):
-/// m_pNotInAFellowshipFrame (0x1000026B) shows and
-/// m_pInAFellowshipFrame (0x10000275) hides when
-/// m_pFellowship == null, and vice-versa. Both are AUTHORED sibling
-/// containers of the fellowship page (0x10000292) — confirmed by the
-/// FA3 live-mount probe: 0x1000026B holds the name-entry box, Create
-/// button, and all FOUR visible option checkboxes (Ignore Fellowship
-/// Requests / Auto-Accept Requests / Share XP / Share Loot —
-/// 0x10000270–0x10000273, fix-round mechanism SF-4); 0x10000275 holds
-/// the roster list and all six member-management buttons. A single
-/// toggle on each container is therefore the
-/// WHOLE empty-state swap; no per-child hiding is needed (closes lane-A
-/// unknown U6).
+/// Roster rebuild discipline — FA3 carry-forward 1.
+/// bumps ONE monotonic revision for
+/// every mutation, including a pure vitals refresh
+/// (0x02C0 with the same member set — lane B §2.6/§4.5, the
+/// 0x00A6-gated stream this same slice turns on). A naive
+/// "revision changed → Flush + rebuild" binding (the Friends/Squelch
+/// shape) would therefore tear down and rebuild the whole ListBox on every
+/// incoming vitals tick while the panel is open — resetting the user's
+/// scroll position to the top every time (
+/// zeroes the scroll offset) and re-resolving nine DAT row templates for no
+/// structural reason. instead diffs the member
+/// GUID SET: when it is unchanged, existing rows are updated IN PLACE
+/// ( — no ListBox mutation at all, so scroll position
+/// is untouched by construction); only an actual join/leave/disband
+/// difference triggers
+/// (the widget-level half of the same carry-forward, for the rarer
+/// structural-change case).
+///
+///
+///
+/// Row-text StringInfo gap (register row filed this slice). Every
+/// row field beyond the bare name is a retail StringInfo template
+/// with variable substitution (ID_Fellowship_FellowStats + vars
+/// ID_Level/ID_Experience; …FellowHealthStatus/
+/// …StaminaStatus/…ManaStatus + vars ID_Cur/ID_Max
+/// — docs/research/2026-08-11-fa-panel-structure.md §3.1/§4.1). acdream has
+/// not ported retail's variable-substitution engine
+/// (StringInfo::InqString → StringTableMetaLanguage::UnescapeString
+/// — a cross-cutting UI-string prerequisite, not fellowship-specific) so
+/// this controller renders the numeric CONTENT of those fields as plain
+/// composites ("{level} {pct}%", "{cur}/{max}") rather than
+/// resolving retail's exact surrounding sentence — no invented English
+/// words are added, only the retail-authored NUMBERS. Same disposition the
+/// pre-Campaign-OP Character window recorded for the identical gap
+/// (docs/research/2026-06-25-character-window-faithful-spec.md: "NOT yet
+/// ported — current controller uses canonical AC labels").
+///
+///
+///
+/// Leader marker. Lane A's row-template inventory names no dedicated
+/// "this fellow is the leader" element (the 8-child row is name / stats /
+/// three meter+text pairs, nothing else) — retail may simply not mark
+/// leadership in the row at all. Lacking a decompiled anchor either way,
+/// this controller tints the leader's name text a distinct gold
+/// () as a minimal, clearly-adaptive visual
+/// cue rather than inventing a DAT mechanism that was not found.
///
///
public sealed class SocialFellowshipPageController
{
+ // ── Page-level element ids (docs/research/2026-08-11-fa-panel-structure.md §3.1) ──
+ private const uint ListBoxId = 0x10000279u;
+ private const uint NameEntryBoxId = 0x1000026Fu;
+ private const uint CreateButtonId = 0x10000274u;
+ private const uint FellowshipNameTextId = 0x10000276u;
+ private const uint LeaderButtonId = 0x1000027Bu;
+ private const uint QuitButtonId = 0x1000027Cu;
+ private const uint OpenButtonId = 0x1000027Du;
+ private const uint RecruitButtonId = 0x1000027Eu;
+ private const uint DismissButtonId = 0x1000027Fu;
+ private const uint DisbandButtonId = 0x10000280u;
+ private const uint IgnoreRequestsCheckboxId = 0x10000270u;
+ private const uint AutoAcceptCheckboxId = 0x10000271u;
+ private const uint ShareXpCheckboxId = 0x10000272u;
+ private const uint ShareLootCheckboxId = 0x10000273u;
+
+ // ── Row-template element ids (same doc, the 8-part fellow row) ──
+ private const uint RowNameTextId = 0x10000283u;
+ private const uint RowStatsTextId = 0x10000284u;
+ private const uint RowHealthMeterId = 0x10000285u;
+ private const uint RowStaminaMeterId = 0x10000287u;
+ private const uint RowManaMeterId = 0x10000289u;
+
+ // ── String tables (§4.3): checkbox label/tooltip pairs resolve in
+ // 0x23000003 (matching CharacterOptionsPageController.StringTableId —
+ // same PlayerOption ids, same ID_PlayerOption_ keys, a SECOND
+ // authored checkbox instance for the SAME retail strings); the open/
+ // close button caption resolves in 0x23000001 (ID_Fellowship_*).
+ private const uint OptionStringTableId = 0x23000003u;
+ private const uint FellowshipStringTableId = 0x23000001u;
+
+ /// Retail's byte-decoded even-split share table (lane B §7.2),
+ /// index 0 = 1 fellow. Index 8 (9 fellows) is 0.3111111 — ACE currently
+ /// grants .3 there; see the D5 register row this slice files.
+ private static readonly float[] EvenSplitPercentTable =
+ [1.0f, 0.75f, 0.6f, 0.55f, 0.5f, 0.45f, 0.4f, 0.35f, 0.3111111f, 0.28f];
+
+ /// Fellowship::IsFull @0x005B9070, byte-verified
+ /// _currNum >= 9 (lane B §7.1).
+ private const int MaxFellowshipSize = 9;
+
+ private static readonly Vector4 LeaderNameColor = new(1f, 0.84f, 0f, 1f);
+ private static readonly Vector4 MemberNameColor = Vector4.One;
+
+ /// The live read/write seam this page binds to — Runtime's
+ /// FA2 fellowship owner (snapshot + member enumeration), the seven
+ /// generation-gated commands routed through
+ /// ,
+ /// the world-selection state (Recruit's target source), the local
+ /// player's own guid, the shared character-option seam the four
+ /// checkboxes bind through (the SAME storage
+ /// CharacterOptionsPageController's Character tab uses — both
+ /// surfaces are live views of one value, lane A §6.5), and a DAT string
+ /// resolver.
+ public sealed record Bindings(
+ Func Snapshot,
+ Func> Members,
+ Func TemplateResolver,
+ Func Create,
+ Func Recruit,
+ Func Dismiss,
+ Func Quit,
+ Func AssignLeader,
+ Func SetOpen,
+ Func SetPanelOpen,
+ SelectionState Selection,
+ Func LocalPlayerGuid,
+ Func CurrentCharacterOption,
+ Action SetCharacterOption,
+ Func ResolveString);
+
+ private readonly record struct FellowRowWidgets(
+ UiText? Name,
+ UiText? Stats,
+ UiMeter? Health,
+ UiMeter? Stamina,
+ UiMeter? Mana);
+
private readonly UiElement _notInFellowshipFrame;
private readonly UiElement _inFellowshipFrame;
- private readonly Func _snapshot;
+ private readonly Bindings _bindings;
+
+ private readonly UiTemplateListBox? _listBox;
+ private readonly UiField? _nameField;
+ private readonly UiButton? _createButton;
+ private readonly UiText? _fellowshipNameText;
+ private readonly UiButton? _leaderButton;
+ private readonly UiButton? _quitButton;
+ private readonly UiButton? _openButton;
+ private readonly UiButton? _recruitButton;
+ private readonly UiButton? _dismissButton;
+ private readonly UiButton? _disbandButton;
+ private readonly UiButton? _ignoreRequestsCheckbox;
+ private readonly UiButton? _autoAcceptCheckbox;
+ private readonly UiButton? _shareXpCheckbox;
+ private readonly UiButton? _shareLootCheckbox;
+
+ private readonly Dictionary _rows = new();
+ private uint _selectedFellowGuid;
+ private long _lastRosterRevision = long.MinValue;
+ private bool? _lastOpenState;
+ private bool _pageVisible;
+
+ /// Resolved ONCE at (not per-tick — every
+ /// other DatStringResolver consumer in this codebase is
+ /// bind-time-only; DatCollection is not safe to touch
+ /// unprotected from the render/tick loop, per
+ /// claude-memory/feedback_phase_a1_hotfix_saga.md). Null when the
+ /// string did not resolve — the button then keeps whatever caption the
+ /// import gave it rather than showing invented English.
+ private readonly string? _openCaption;
+ private readonly string? _closeCaption;
private SocialFellowshipPageController(
UiElement notInFellowshipFrame,
UiElement inFellowshipFrame,
- Func snapshot)
+ Bindings bindings,
+ UiTemplateListBox? listBox,
+ UiField? nameField,
+ UiButton? createButton,
+ UiText? fellowshipNameText,
+ UiButton? leaderButton,
+ UiButton? quitButton,
+ UiButton? openButton,
+ UiButton? recruitButton,
+ UiButton? dismissButton,
+ UiButton? disbandButton,
+ UiButton? ignoreRequestsCheckbox,
+ UiButton? autoAcceptCheckbox,
+ UiButton? shareXpCheckbox,
+ UiButton? shareLootCheckbox,
+ string? openCaption,
+ string? closeCaption)
{
_notInFellowshipFrame = notInFellowshipFrame;
_inFellowshipFrame = inFellowshipFrame;
- _snapshot = snapshot;
+ _bindings = bindings;
+ _listBox = listBox;
+ _nameField = nameField;
+ _createButton = createButton;
+ _fellowshipNameText = fellowshipNameText;
+ _leaderButton = leaderButton;
+ _quitButton = quitButton;
+ _openButton = openButton;
+ _recruitButton = recruitButton;
+ _dismissButton = dismissButton;
+ _disbandButton = disbandButton;
+ _ignoreRequestsCheckbox = ignoreRequestsCheckbox;
+ _autoAcceptCheckbox = autoAcceptCheckbox;
+ _shareXpCheckbox = shareXpCheckbox;
+ _shareLootCheckbox = shareLootCheckbox;
+ _openCaption = openCaption;
+ _closeCaption = closeCaption;
}
- public static SocialFellowshipPageController? Bind(
- UiElement pageRoot,
- Func snapshot)
+ public static SocialFellowshipPageController? Bind(UiElement pageRoot, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(pageRoot);
- ArgumentNullException.ThrowIfNull(snapshot);
+ ArgumentNullException.ThrowIfNull(bindings);
if (UiElement.FindDescendant(pageRoot, 0x1000026Bu) is not { } notIn
|| UiElement.FindDescendant(pageRoot, 0x10000275u) is not { } inFellowship)
@@ -58,23 +236,446 @@ public sealed class SocialFellowshipPageController
return null;
}
- var controller = new SocialFellowshipPageController(notIn, inFellowship, snapshot);
+ UiTemplateListBox? listBox = UiElement.FindDescendant(pageRoot, ListBoxId) as UiTemplateListBox;
+ if (listBox is null)
+ Console.WriteLine(
+ $"[D.2b] SocialFellowshipPageController: ListBox 0x{ListBoxId:X8} not "
+ + "found — the fellowship roster will not populate.");
+ else
+ {
+ listBox.TemplateResolver = bindings.TemplateResolver;
+ uint scrollbarElementId = listBox.ScrollbarElementId;
+ UiElement? scrollbarElement = scrollbarElementId == 0
+ ? null
+ : UiElement.FindDescendant(pageRoot, scrollbarElementId);
+ if (scrollbarElement is UiScrollbar scrollbar)
+ scrollbar.Model = listBox.Scroll;
+ else
+ Console.WriteLine(
+ $"[D.2b] SocialFellowshipPageController: scrollbar 0x{scrollbarElementId:X8} "
+ + "not found — the fellowship roster will not scroll.");
+ }
+
+ UiField? nameField = UiElement.FindDescendant(pageRoot, NameEntryBoxId) as UiField;
+ if (nameField is null)
+ Console.WriteLine(
+ $"[D.2b] SocialFellowshipPageController: name-entry field 0x{NameEntryBoxId:X8} "
+ + "not found (or not authored Editable) — Create will not read a typed name.");
+
+ UiButton? createButton = UiElement.FindDescendant(pageRoot, CreateButtonId) as UiButton;
+ UiText? fellowshipNameText = UiElement.FindDescendant(pageRoot, FellowshipNameTextId) as UiText;
+ UiButton? leaderButton = UiElement.FindDescendant(pageRoot, LeaderButtonId) as UiButton;
+ UiButton? quitButton = UiElement.FindDescendant(pageRoot, QuitButtonId) as UiButton;
+ UiButton? openButton = UiElement.FindDescendant(pageRoot, OpenButtonId) as UiButton;
+ UiButton? recruitButton = UiElement.FindDescendant(pageRoot, RecruitButtonId) as UiButton;
+ UiButton? dismissButton = UiElement.FindDescendant(pageRoot, DismissButtonId) as UiButton;
+ UiButton? disbandButton = UiElement.FindDescendant(pageRoot, DisbandButtonId) as UiButton;
+ UiButton? ignoreRequestsCheckbox = UiElement.FindDescendant(pageRoot, IgnoreRequestsCheckboxId) as UiButton;
+ UiButton? autoAcceptCheckbox = UiElement.FindDescendant(pageRoot, AutoAcceptCheckboxId) as UiButton;
+ UiButton? shareXpCheckbox = UiElement.FindDescendant(pageRoot, ShareXpCheckboxId) as UiButton;
+ UiButton? shareLootCheckbox = UiElement.FindDescendant(pageRoot, ShareLootCheckboxId) as UiButton;
+
+ // Bind-time only (see the field's own doc) — gmFellowshipUI::
+ // UpdateButtons @0x0048E6C0 (lane A §4.1): a CLOSED fellowship
+ // shows "Open Fellowship", the button reads as the action.
+ string? openCaption = bindings.ResolveString(
+ FellowshipStringTableId, DatStringResolver.ComputeHash("ID_Fellowship_OpenFellowshipButtonText"));
+ string? closeCaption = bindings.ResolveString(
+ FellowshipStringTableId, DatStringResolver.ComputeHash("ID_Fellowship_CloseFellowshipButtonText"));
+ if (openCaption is null || closeCaption is null)
+ Console.WriteLine(
+ "[D.2b] SocialFellowshipPageController: Open/Close Fellowship button caption(s) "
+ + "did not resolve — the button keeps its imported caption rather than "
+ + "invented English.");
+
+ var controller = new SocialFellowshipPageController(
+ notIn,
+ inFellowship,
+ bindings,
+ listBox,
+ nameField,
+ createButton,
+ fellowshipNameText,
+ leaderButton,
+ quitButton,
+ openButton,
+ recruitButton,
+ dismissButton,
+ disbandButton,
+ ignoreRequestsCheckbox,
+ autoAcceptCheckbox,
+ shareXpCheckbox,
+ shareLootCheckbox,
+ openCaption,
+ closeCaption);
+
+ controller.WireButtons();
+ controller.WireCheckboxes();
controller.Tick();
return controller;
}
+ private void WireButtons()
+ {
+ if (_createButton is not null)
+ _createButton.OnClick = () =>
+ {
+ string name = _nameField?.Text ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(name)) return;
+ bool shareXp = _bindings.CurrentCharacterOption(CharacterOptionId.FellowshipShareXP);
+ _bindings.Create(name, shareXp);
+ };
+
+ if (_recruitButton is not null)
+ _recruitButton.OnClick = () =>
+ {
+ if (_bindings.Selection.SelectedObjectId is { } targetGuid)
+ _bindings.Recruit(targetGuid);
+ };
+
+ if (_dismissButton is not null)
+ _dismissButton.OnClick = () =>
+ {
+ if (_selectedFellowGuid != 0u) _bindings.Dismiss(_selectedFellowGuid);
+ };
+
+ if (_leaderButton is not null)
+ _leaderButton.OnClick = () =>
+ {
+ if (_selectedFellowGuid != 0u) _bindings.AssignLeader(_selectedFellowGuid);
+ };
+
+ // Quit and Disband are the same 0x00A3 opcode, flag flipped (lane B
+ // §2.5/§3.2). The retail leader-handoff pre-send (0x0290 before
+ // 0x00A3 when quitting-not-disbanding as the current leader) lives
+ // in RuntimeFellowshipState.RequiresLeaderHandoffBeforeQuit, applied
+ // by the command adapters — Quit(disband) here is the whole story.
+ if (_quitButton is not null)
+ _quitButton.OnClick = () => _bindings.Quit(false);
+ if (_disbandButton is not null)
+ _disbandButton.OnClick = () => _bindings.Quit(true);
+
+ if (_openButton is not null)
+ _openButton.OnClick = () =>
+ {
+ RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot();
+ _bindings.SetOpen(!snapshot.IsOpen);
+ };
+ }
+
+ private void WireCheckboxes()
+ {
+ BindCheckbox(_ignoreRequestsCheckbox, CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests");
+ BindCheckbox(_autoAcceptCheckbox, CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests");
+ BindCheckbox(_shareXpCheckbox, CharacterOptionId.FellowshipShareXP, "FellowshipShareXP");
+ BindCheckbox(_shareLootCheckbox, CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot");
+ }
+
+ private void BindCheckbox(UiButton? checkbox, CharacterOptionId id, string retailName)
+ {
+ if (checkbox is null) return;
+
+ string labelKey = $"ID_PlayerOption_{retailName}";
+ string? label = _bindings.ResolveString(OptionStringTableId, DatStringResolver.ComputeHash(labelKey));
+ if (label is not null)
+ checkbox.Label = label;
+ else
+ Console.WriteLine(
+ $"[D.2b] SocialFellowshipPageController: label '{labelKey}' did not resolve — "
+ + "checkbox renders with no caption rather than invented English.");
+
+ string? tooltip = _bindings.ResolveString(
+ OptionStringTableId, DatStringResolver.ComputeHash(labelKey + "_Help"));
+ if (tooltip is not null)
+ checkbox.TooltipText = tooltip;
+
+ checkbox.OnClick = () =>
+ {
+ bool next = !checkbox.Selected;
+ checkbox.Selected = next;
+ _bindings.SetCharacterOption(id, next);
+ };
+ }
+
///
- /// Re-reads the live snapshot and applies retail's frame swap. Cheap (two
- /// bool writes) — called every frame from
- /// rather than gated on
- /// page-shown/revision, since has no
- /// data-driven provider mechanism of its own (unlike ,
- /// which is polled by the render loop already).
+ /// Re-reads live state and applies retail's frame swap, roster diff,
+ /// button-enable rules, and checkbox seeding. Called every frame from
+ /// , matching every other
+ /// retained panel's empty-state gate (see FA3's own doc for why this
+ /// stays unconditional rather than visibility-gated: two bool writes
+ /// plus, at most, a revision-gated roster diff — no DAT access).
///
public void Tick()
{
- bool inFellowship = _snapshot().IsInFellowship;
+ RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot();
+ bool inFellowship = snapshot.IsInFellowship;
_notInFellowshipFrame.Visible = !inFellowship;
_inFellowshipFrame.Visible = inFellowship;
+
+ RefreshCheckboxSelections();
+ RefreshCreateButtonState();
+
+ if (!inFellowship)
+ {
+ if (_rows.Count != 0)
+ {
+ _listBox?.Flush();
+ _rows.Clear();
+ }
+ _selectedFellowGuid = 0u;
+ _lastRosterRevision = long.MinValue;
+ _lastOpenState = null;
+ return;
+ }
+
+ RefreshFellowshipName(snapshot.Name);
+
+ if (snapshot.Revision != _lastRosterRevision)
+ {
+ _lastRosterRevision = snapshot.Revision;
+ RefreshRoster(snapshot);
+ }
+
+ RefreshButtonStates(snapshot);
+ RefreshOpenCaption(snapshot);
+ }
+
+ ///
+ /// D4 — the panel-open declaration (0x00A6) that gates ACE's
+ /// vitals stream (lane B §4.5): without it the roster's health/stamina/
+ /// mana freeze at whatever they were on join. Called by
+ /// on every transition of "is the
+ /// social window shown AND is Fellowship the active tab" — idempotent
+ /// here too (only sends on an actual flip), and a no-op while
+ /// disconnected (the command layer returns
+ /// without sending).
+ ///
+ public void SetPageVisible(bool visible)
+ {
+ if (_pageVisible == visible) return;
+ _pageVisible = visible;
+ _bindings.SetPanelOpen(visible);
+ }
+
+ private void RefreshCreateButtonState()
+ {
+ if (_createButton is null) return;
+ string name = _nameField?.Text ?? string.Empty;
+ _createButton.Enabled = !string.IsNullOrWhiteSpace(name);
+ }
+
+ private void RefreshCheckboxSelections()
+ {
+ // Both surfaces (this page's checkboxes and the Character tab's
+ // rows for the SAME four PlayerOption ids) are live views of one
+ // value — re-seed every tick so a write from the OTHER surface (or
+ // the retail-exact Ignore<->AutoAccept mutual exclusion,
+ // RuntimeCharacterState.TrySetOption) is reflected here too
+ // (lane A §6.5).
+ if (_ignoreRequestsCheckbox is not null)
+ _ignoreRequestsCheckbox.Selected =
+ _bindings.CurrentCharacterOption(CharacterOptionId.IgnoreFellowshipRequests);
+ if (_autoAcceptCheckbox is not null)
+ _autoAcceptCheckbox.Selected =
+ _bindings.CurrentCharacterOption(CharacterOptionId.FellowshipAutoAcceptRequests);
+ if (_shareXpCheckbox is not null)
+ _shareXpCheckbox.Selected =
+ _bindings.CurrentCharacterOption(CharacterOptionId.FellowshipShareXP);
+ if (_shareLootCheckbox is not null)
+ _shareLootCheckbox.Selected =
+ _bindings.CurrentCharacterOption(CharacterOptionId.FellowshipShareLoot);
+ }
+
+ private void RefreshFellowshipName(string name)
+ {
+ if (_fellowshipNameText is null) return;
+ _fellowshipNameText.LinesProvider = () => [new UiText.Line(name, MemberNameColor)];
+ }
+
+ private void RefreshOpenCaption(RuntimeFellowshipSnapshot snapshot)
+ {
+ if (_openButton is null) return;
+ if (_lastOpenState == snapshot.IsOpen) return;
+ _lastOpenState = snapshot.IsOpen;
+
+ // gmFellowshipUI::UpdateButtons @0x0048E6C0: the button reads as the
+ // ACTION, not the state — a CLOSED fellowship shows "Open
+ // Fellowship" (lane A §4.1). Captions are resolved once at Bind
+ // (see the fields' own doc) — swap between the cached strings only.
+ string? label = snapshot.IsOpen ? _closeCaption : _openCaption;
+ if (label is not null)
+ _openButton.Label = label;
+ }
+
+ private void RefreshButtonStates(RuntimeFellowshipSnapshot snapshot)
+ {
+ uint selfGuid = _bindings.LocalPlayerGuid();
+ bool isLeader = snapshot.LeaderGuid != 0u && snapshot.LeaderGuid == selfGuid;
+ bool hasSelection = _selectedFellowGuid != 0u;
+ bool selectedIsSelf = hasSelection && _selectedFellowGuid == selfGuid;
+
+ // gmFellowshipUI::UpdateButtons @0x0048E6C0 (lane B §2.8).
+ if (_quitButton is not null) _quitButton.Enabled = true; // always, while in a fellowship
+ if (_disbandButton is not null) _disbandButton.Enabled = isLeader;
+ if (_openButton is not null) _openButton.Enabled = isLeader;
+ if (_leaderButton is not null) _leaderButton.Enabled = isLeader && hasSelection && !selectedIsSelf;
+ if (_dismissButton is not null) _dismissButton.Enabled = isLeader && hasSelection && !selectedIsSelf;
+
+ if (_recruitButton is not null)
+ {
+ uint? targetGuid = _bindings.Selection.SelectedObjectId;
+ bool targetValid = targetGuid is { } id && id != selfGuid && !_rows.ContainsKey(id);
+ bool notFull = snapshot.MemberCount < MaxFellowshipSize;
+ // Retail additionally requires the target to be a player
+ // (ACCWeenieObject::IsPlayer) — acdream's UI layer has no cheap
+ // player-vs-non-player classification at this seam, so this
+ // enable rule is a superset of retail's; the actual Recruit
+ // send is still refused server-side for a non-player target
+ // exactly like retail's own silent no-op (lane B §2.3).
+ _recruitButton.Enabled = targetValid && notFull && (isLeader || snapshot.IsOpen);
+ }
+ }
+
+ ///
+ /// Diffs the live member GUID set against the currently-rendered rows.
+ /// Unchanged set → update every row's bound widgets in place (no ListBox
+ /// mutation, scroll untouched). Changed set → full rebuild via
+ /// (carry-forward 1).
+ ///
+ private void RefreshRoster(RuntimeFellowshipSnapshot snapshot)
+ {
+ if (_listBox is null) return;
+
+ var members = new List(_bindings.Members());
+
+ bool membershipChanged = members.Count != _rows.Count;
+ if (!membershipChanged)
+ {
+ foreach (RuntimeFellowMemberSnapshot member in members)
+ {
+ if (_rows.ContainsKey(member.Guid)) continue;
+ membershipChanged = true;
+ break;
+ }
+ }
+
+ if (membershipChanged)
+ RebuildRoster(members, snapshot);
+ else
+ foreach (RuntimeFellowMemberSnapshot member in members)
+ UpdateRow(member, snapshot);
+
+ if (_selectedFellowGuid != 0u && !_rows.ContainsKey(_selectedFellowGuid))
+ _selectedFellowGuid = 0u;
+ }
+
+ private void RebuildRoster(List members, RuntimeFellowshipSnapshot snapshot)
+ {
+ _listBox!.FlushPreservingScroll();
+ _rows.Clear();
+
+ foreach (RuntimeFellowMemberSnapshot member in members)
+ {
+ UiElement? row = _listBox.AddItemFromTemplateList(0);
+ if (row is null)
+ {
+ Console.WriteLine(
+ "[D.2b] SocialFellowshipPageController: fellow row template did not "
+ + $"build for guid 0x{member.Guid:X8}.");
+ continue;
+ }
+
+ var widgets = new FellowRowWidgets(
+ UiElement.FindDescendant(row, RowNameTextId) as UiText,
+ UiElement.FindDescendant(row, RowStatsTextId) as UiText,
+ UiElement.FindDescendant(row, RowHealthMeterId) as UiMeter,
+ UiElement.FindDescendant(row, RowStaminaMeterId) as UiMeter,
+ UiElement.FindDescendant(row, RowManaMeterId) as UiMeter);
+ _rows[member.Guid] = widgets;
+
+ if (widgets.Name is { } nameText)
+ {
+ uint guid = member.Guid;
+ nameText.OnClick = () => SelectFellow(guid);
+ }
+ }
+
+ foreach (RuntimeFellowMemberSnapshot member in members)
+ UpdateRow(member, snapshot);
+ }
+
+ private void UpdateRow(RuntimeFellowMemberSnapshot member, RuntimeFellowshipSnapshot snapshot)
+ {
+ if (!_rows.TryGetValue(member.Guid, out FellowRowWidgets widgets)) return;
+
+ if (widgets.Name is { } nameText)
+ {
+ string name = member.Name;
+ Vector4 color = snapshot.LeaderGuid == member.Guid ? LeaderNameColor : MemberNameColor;
+ nameText.LinesProvider = () => [new UiText.Line(name, color)];
+ }
+
+ if (widgets.Stats is { } statsText)
+ {
+ string text = FormatStatsText(member, snapshot);
+ statsText.LinesProvider = () => [new UiText.Line(text, MemberNameColor)];
+ }
+
+ SetVitals(widgets.Health, member.CurrentHealth, member.MaxHealth);
+ SetVitals(widgets.Stamina, member.CurrentStamina, member.MaxStamina);
+ SetVitals(widgets.Mana, member.CurrentMana, member.MaxMana);
+ }
+
+ ///
+ /// D5 — gmFellowshipUI::UpdateFellowStats @0x0048EB20 (lane B
+ /// §2.9): level + XP-share percentage. See the class doc for why the
+ /// surrounding text is a plain numeric composite rather than retail's
+ /// resolved sentence. The percentage itself IS retail's exact number:
+ /// when even-split, 0% when
+ /// is off (retail's own
+ /// literal pct = 0.0f branch — not a gap), and OMITTED (level
+ /// only) for the proportional branch, which needs retail's
+ /// per-level XP-to-next-level table — acdream has not ported one (no
+ /// acdream ExperienceSystem exists yet); guessing a formula here is
+ /// exactly what CLAUDE.md's workflow forbids, so this is a scoped,
+ /// documented gap rather than an invented number.
+ ///
+ private static string FormatStatsText(RuntimeFellowMemberSnapshot member, RuntimeFellowshipSnapshot snapshot)
+ {
+ if (!snapshot.ShareXp)
+ return $"{member.Level} 0%";
+ if (snapshot.EvenXpSplit)
+ {
+ float pct = EvenSplitPercent(snapshot.MemberCount);
+ return $"{member.Level} {(int)MathF.Round(pct * 100f)}%";
+ }
+ return member.Level.ToString();
+ }
+
+ private static float EvenSplitPercent(int memberCount) =>
+ memberCount is >= 1 and <= 10 ? EvenSplitPercentTable[memberCount - 1] : 0f;
+
+ private static void SetVitals(UiMeter? meter, uint current, uint max)
+ {
+ if (meter is null) return;
+ meter.Fill = () => max > 0u ? (float)current / max : 0f;
+ meter.Label = () => $"{current}/{max}";
+ }
+
+ ///
+ /// Retail list-selection message (3/0x42,
+ /// gmFellowshipUI::ListenToElementMessage @0x004901C0, lane B
+ /// §2.8): reads the clicked row's identity into
+ /// m_iidSelectedFellow AND calls
+ /// ACCWeenieObject::SetSelectedObject — selecting a fellow in the
+ /// panel selects them in the world too. acdream has no generic
+ /// per-row-element click primitive on an imported template subtree, so
+ /// the row's own name text (always present per the row inventory) is
+ /// the click target.
+ ///
+ private void SelectFellow(uint guid)
+ {
+ _selectedFellowGuid = guid;
+ _bindings.Selection.Select(guid, SelectionChangeSource.Social);
}
}
diff --git a/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs b/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs
index 4226d310..0ec734bb 100644
--- a/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs
+++ b/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs
@@ -36,15 +36,20 @@ namespace AcDream.App.UI.Layout;
///
///
///
-/// Scrollbar (fix-round blast MF-1). The ListBox 0x10000517
-/// authors a direct sibling scrollbar, 0x10000518 — wired to
-/// the SAME way every other
-/// UiTemplateListBox consumer wires its own scrollbar
-/// (CharacterOptionsPageController/ChatOptionsPageController/
-/// ConfigOptionsPageController/KeyboardConfigController).
-/// Without it the list has NO wheel fallback (
-/// has no wheel handler) and is completely unreachable past the box's
-/// visible extent.
+/// Scrollbar (fix-round blast MF-1; FA4 carry-forward 3). The
+/// ListBox 0x10000517 authors a direct sibling scrollbar,
+/// 0x10000518 — wired to the
+/// SAME way every other UiTemplateListBox consumer wires its own
+/// scrollbar. Resolved via the built
+/// (dat property 0x72, populated by )
+/// rather than the hardcoded literal a prior revision of this file used —
+/// same "prefer the DAT field over a duplicated constant" fix
+/// ConfigOptionsPageController.Bind already applied (OP6 review N2,
+/// feedback_prefer_dat_field_over_geometry), scoped to this page's
+/// own subtree so a flat lookup can't grab a same-id sibling on another
+/// page. Without it the list has NO wheel fallback
+/// ( has no wheel handler) and is completely
+/// unreachable past the box's visible extent.
///
///
///
@@ -64,9 +69,6 @@ public sealed class SocialFriendsPageController
{
private const uint ListBoxId = 0x10000517u;
- /// The ListBox's own linked scrollbar (see class doc).
- private const uint ScrollbarElementId = 0x10000518u;
-
private readonly UiTemplateListBox _listBox;
private readonly FriendsState _friends;
private long _lastRevision = long.MinValue;
@@ -95,11 +97,15 @@ public sealed class SocialFriendsPageController
}
listBox.TemplateResolver = templateResolver;
- if (UiElement.FindDescendant(pageRoot, ScrollbarElementId) is UiScrollbar scrollbar)
+ uint scrollbarElementId = listBox.ScrollbarElementId;
+ UiElement? scrollbarElement = scrollbarElementId == 0
+ ? null
+ : UiElement.FindDescendant(pageRoot, scrollbarElementId);
+ if (scrollbarElement is UiScrollbar scrollbar)
scrollbar.Model = listBox.Scroll;
else
Console.WriteLine(
- $"[D.2b] SocialFriendsPageController: scrollbar 0x{ScrollbarElementId:X8} "
+ $"[D.2b] SocialFriendsPageController: scrollbar 0x{scrollbarElementId:X8} "
+ "not found — the Friends list will not scroll.");
var controller = new SocialFriendsPageController(listBox, friends);
diff --git a/src/AcDream.App/UI/Layout/SocialPanelController.cs b/src/AcDream.App/UI/Layout/SocialPanelController.cs
index b0e8ad32..59538a3e 100644
--- a/src/AcDream.App/UI/Layout/SocialPanelController.cs
+++ b/src/AcDream.App/UI/Layout/SocialPanelController.cs
@@ -86,10 +86,15 @@ public sealed class SocialPanelController : IRetainedPanelController
private const uint CloseButtonId = 0x10000290u;
/// Callback delegates + live-state accessors this controller
- /// wires the four pages and the close button to.
+ /// wires the four pages and the close button to. Campaign FA slice FA4:
+ /// replaces the old bare
+ /// FellowshipSnapshot field with the page's full read/write seam
+ /// (roster enumeration, the seven commands, selection, character
+ /// options, string resolution — see
+ /// 's own doc).
public sealed record Callbacks(
Action Toggle,
- Func FellowshipSnapshot,
+ SocialFellowshipPageController.Bindings Fellowship,
Func AllegianceSnapshot,
FriendsState Friends,
SquelchState Squelch,
@@ -122,6 +127,17 @@ public sealed class SocialPanelController : IRetainedPanelController
_allegiance = allegiance;
_friends = friends;
_squelch = squelch;
+
+ // Campaign FA slice FA4, D4: the Fellowship page's 0x00A6
+ // panel-open declaration needs both "is the social WINDOW shown"
+ // (OnShown/OnHidden, below) AND "is Fellowship the ACTIVE tab"
+ // (this event) — retail's gmFellowshipUI::OnVisibilityChanged fires
+ // on the WIDGET's own visibility, and in this campaign's one-window/
+ // four-page mount that's the conjunction of both. Subscribed here
+ // (not in Bind) so it observes every tab switch, including the
+ // very first one ActivateTabBehavior's default-entry activation
+ // fires.
+ _tabPanel.ActivePageChanged += (_, _) => UpdateFellowshipPageVisibility();
}
///
@@ -156,7 +172,7 @@ public sealed class SocialPanelController : IRetainedPanelController
SocialFellowshipPageController? fellowship = fellowshipPage is null
? null
- : SocialFellowshipPageController.Bind(fellowshipPage, callbacks.FellowshipSnapshot);
+ : SocialFellowshipPageController.Bind(fellowshipPage, callbacks.Fellowship);
SocialAllegiancePageController? allegiance = allegiancePage is null
? null
: SocialAllegiancePageController.Bind(allegiancePage, callbacks.AllegianceSnapshot);
@@ -229,12 +245,27 @@ public sealed class SocialPanelController : IRetainedPanelController
/// window manager on every hidden-to-shown transition (fix-round blast
/// SF-2). Does not force an immediate Friends/Squelch rebuild — the
/// next naturally picks up any revision bump that
- /// accumulated while hidden.
- public void OnShown() => _visible = true;
+ /// accumulated while hidden. Also drives D4 (see the constructor's own
+ /// doc on ).
+ public void OnShown()
+ {
+ _visible = true;
+ UpdateFellowshipPageVisibility();
+ }
/// hook, fired on every
- /// shown-to-hidden transition (fix-round blast SF-2).
- public void OnHidden() => _visible = false;
+ /// shown-to-hidden transition (fix-round blast SF-2). Also drives D4.
+ public void OnHidden()
+ {
+ _visible = false;
+ UpdateFellowshipPageVisibility();
+ }
+
+ /// D4: the Fellowship page is "visible" (and therefore
+ /// declares its panel-open state to Runtime) exactly when the social
+ /// WINDOW is shown AND Fellowship is the active tab.
+ private void UpdateFellowshipPageVisibility() =>
+ _fellowship?.SetPageVisible(_visible && IsShowingFellowship);
///
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no
@@ -247,11 +278,16 @@ public sealed class SocialPanelController : IRetainedPanelController
/// Fix-round blast SF-2. The Friends/Squelch rebuild
/// (/
/// ) takes the shared DAT
- /// lock per row template resolve — it only runs while
- /// is true. Fellowship/Allegiance stay unconditional: their own
- /// Tick() methods are two bool/delegate writes with no DAT
- /// access, cheap enough to keep polling every frame the way every other
- /// retained panel's empty-state gate already does.
+ /// lock per row template resolve on EVERY revision change — it only
+ /// runs while is true. Fellowship/Allegiance stay
+ /// unconditional: Allegiance's own Tick() is two bool/delegate
+ /// writes with no DAT access; Fellowship's (FA4) is revision-GATED
+ /// internally and, within that gate, further diffs the member set
+ /// before ever touching the ListBox — a DAT-locked row rebuild only
+ /// runs on an actual join/leave/disband, an inherently rare event
+ /// (unlike Friends/Squelch's "any revision → full rebuild" shape), so
+ /// polling unconditionally stays cheap in the common case (a snapshot
+ /// read + a `long` comparison) even while the panel is hidden.
///
///
public void Tick()
diff --git a/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs b/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs
index 6a010521..5f47f73a 100644
--- a/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs
+++ b/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs
@@ -31,12 +31,14 @@ namespace AcDream.App.UI.Layout;
///
///
///
-/// Scrollbar (fix-round blast MF-1). The ListBox 0x1000053E
-/// authors a direct sibling scrollbar, 0x10000543 — wired to
+/// Scrollbar (fix-round blast MF-1; FA4 carry-forward 3). The
+/// ListBox 0x1000053E authors a direct sibling scrollbar,
+/// 0x10000543 — resolved via the built
+/// and wired to
/// the same way
/// wires its own (see that
/// class's doc for the full rationale, including why there is no wheel
-/// fallback).
+/// fallback and why the authored field replaces a hardcoded literal).
///
///
///
@@ -52,9 +54,6 @@ public sealed class SocialSquelchPageController
{
private const uint ListBoxId = 0x1000053Eu;
- /// The ListBox's own linked scrollbar (see class doc).
- private const uint ScrollbarElementId = 0x10000543u;
-
private readonly UiTemplateListBox _listBox;
private readonly SquelchState _squelch;
private long _lastRevision = long.MinValue;
@@ -83,11 +82,15 @@ public sealed class SocialSquelchPageController
}
listBox.TemplateResolver = templateResolver;
- if (UiElement.FindDescendant(pageRoot, ScrollbarElementId) is UiScrollbar scrollbar)
+ uint scrollbarElementId = listBox.ScrollbarElementId;
+ UiElement? scrollbarElement = scrollbarElementId == 0
+ ? null
+ : UiElement.FindDescendant(pageRoot, scrollbarElementId);
+ if (scrollbarElement is UiScrollbar scrollbar)
scrollbar.Model = listBox.Scroll;
else
Console.WriteLine(
- $"[D.2b] SocialSquelchPageController: scrollbar 0x{ScrollbarElementId:X8} "
+ $"[D.2b] SocialSquelchPageController: scrollbar 0x{scrollbarElementId:X8} "
+ "not found — the Squelch list will not scroll.");
var controller = new SocialSquelchPageController(listBox, squelch);
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index cd7c005c..5496a785 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -14,6 +14,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
+using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.Content;
using AcDream.Core.Input;
@@ -225,12 +226,42 @@ public sealed record OptionsRuntimeBindings(
/// the panel's read-only rows need the actual entries, same access shape
///
/// already exposes for every other Communication-owned consumer).
+///
+///
+/// Campaign FA slice FA4 adds the Fellowship page's write surface:
+/// (the roster
+/// alone cannot enumerate — see that interface's own FA4 addendum) and the
+/// seven generation-gated commands, all routed through
+///
+/// (the SAME late-bound, race-safe seam AddShortcut/Advance
+/// already use) rather than a raw WorldSession.SendXxx call —
+/// FellowshipQuit in particular MUST go through
+/// IRuntimeFellowshipCommands.Quit because the retail leader
+/// hand-off rule (0x0290 before 0x00A3) lives in
+/// RuntimeFellowshipState.RequiresLeaderHandoffBeforeQuit +
+/// DirectGameRuntimeCommandAdapter.Quit/
+/// CurrentGameRuntimeCommandAdapter.Quit, not in
+/// WorldSession itself — bypassing the command layer would silently
+/// drop that behavior. is the Recruit target source
+/// (retail's ACCWeenieObject::selectedID — lane B §2.3, the
+/// currently-selected WORLD object, not a panel-local selection).
+///
///
public sealed record SocialRuntimeBindings(
Func FellowshipSnapshot,
Func AllegianceSnapshot,
AcDream.Core.Social.FriendsState Friends,
- AcDream.Core.Social.SquelchState Squelch);
+ AcDream.Core.Social.SquelchState Squelch,
+ Func> FellowshipMembers,
+ Func FellowshipCreate,
+ Func FellowshipRecruit,
+ Func FellowshipDismiss,
+ Func FellowshipQuit,
+ Func FellowshipAssignLeader,
+ Func FellowshipSetOpen,
+ Func FellowshipSetPanelOpen,
+ SelectionState Selection,
+ Func LocalPlayerGuid);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
@@ -631,8 +662,35 @@ public sealed class RetailUiRuntime : IDisposable
}
}
+ ///
+ /// Campaign FA slice FA4, D6: a fellowship invite (ConfirmationType.
+ /// Fellowship, 4) is intercepted BEFORE it ever reaches the generic
+ /// dialog — retail's
+ /// Fellowship.cs:121-equivalent client-side mirror:
+ /// IgnoreFellowshipRequests auto-declines, FellowshipAutoAcceptRequests
+ /// auto-accepts, and the two are mutually exclusive
+ /// (RuntimeCharacterState.TrySetOption, already ported) so at
+ /// most one fires. Neither bit set → falls through to the generic
+ /// controller exactly as it already did before this slice (it already
+ /// matches the constants — see D6/lane A §5.3).
+ ///
public bool HandleConfirmationRequest(GameEvents.CharacterConfirmationRequest request)
- => _gameplayConfirmationController?.HandleRequest(request) == true;
+ {
+ if (request.Type == (uint)GameEvents.ConfirmationType.Fellowship
+ && TryAutoRespondToFellowshipInvite(request))
+ return true;
+ return _gameplayConfirmationController?.HandleRequest(request) == true;
+ }
+
+ private bool TryAutoRespondToFellowshipInvite(GameEvents.CharacterConfirmationRequest request)
+ {
+ bool ignore = _bindings.Options.CurrentCharacterOption((uint)CharacterOptionId.IgnoreFellowshipRequests);
+ bool autoAccept = _bindings.Options.CurrentCharacterOption((uint)CharacterOptionId.FellowshipAutoAcceptRequests);
+ if (!ignore && !autoAccept) return false;
+
+ _bindings.Confirmations.SendResponse(request.Type, request.ContextId, autoAccept);
+ return true;
+ }
public bool HandleConfirmationDone(GameEvents.CharacterConfirmationDone done)
=> _gameplayConfirmationController?.HandleDone(done) == true;
@@ -2641,20 +2699,19 @@ public sealed class RetailUiRuntime : IDisposable
// resolvers read the shared, non-thread-safe DatCollection) because
// each row needs its OWN UiElement instance — but the expensive
// per-row DAT tree walk is gone after the first resolve.
- var rowTemplateCache = new Dictionary<(uint LayoutId, uint ElementId), ElementInfo?>();
- UiElement? TemplateResolver(uint templateLayoutId, uint templateElementId)
- {
- lock (_bindings.Assets.DatLock)
+ // FA4 carry-forward 2: the caching logic itself now lives in the
+ // hermetically-testable Layout.RowTemplateResolver (see its own
+ // class doc) — this closure supplies only the two DAT-touching
+ // primitives (import, build) and keeps the shared lock at the
+ // production call site. Feeds Friends/Squelch's row families (FA3)
+ // AND Fellowship's (FA4, 0x21000030/0x10000281) through the SAME
+ // instance/cache — all three are UiTemplateListBox row families on
+ // the one social panel.
+ var rowTemplates = new Layout.RowTemplateResolver(
+ (layoutId, elementId) => LayoutImporter.ImportInfos(
+ _bindings.Assets.Dats, layoutId, elementId),
+ info =>
{
- var key = (templateLayoutId, templateElementId);
- if (!rowTemplateCache.TryGetValue(key, out ElementInfo? info))
- {
- info = LayoutImporter.ImportInfos(
- _bindings.Assets.Dats, templateLayoutId, templateElementId);
- rowTemplateCache[key] = info;
- }
- if (info is null) return null;
-
var strings = new DatStringResolver(_bindings.Assets.Dats);
return LayoutImporter.Build(
info,
@@ -2662,19 +2719,53 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
- }
+ });
+ UiElement? TemplateResolver(uint templateLayoutId, uint templateElementId)
+ {
+ lock (_bindings.Assets.DatLock)
+ return rowTemplates.Resolve(templateLayoutId, templateElementId);
}
+ // Shared across every bind-time ResolveString call below (2
+ // captions + 4x2 checkbox label/tooltip) so its internal
+ // per-table-id cache actually helps rather than re-reading the same
+ // StringTable object ten times.
+ var fellowshipStrings = new DatStringResolver(_bindings.Assets.Dats);
+
var callbacks = new Layout.SocialPanelController.Callbacks(
Toggle: () => ToggleWindow(WindowNames.SocialPanel),
- FellowshipSnapshot: _bindings.Social.FellowshipSnapshot,
+ Fellowship: new Layout.SocialFellowshipPageController.Bindings(
+ Snapshot: _bindings.Social.FellowshipSnapshot,
+ Members: _bindings.Social.FellowshipMembers,
+ TemplateResolver: TemplateResolver,
+ Create: _bindings.Social.FellowshipCreate,
+ Recruit: _bindings.Social.FellowshipRecruit,
+ Dismiss: _bindings.Social.FellowshipDismiss,
+ Quit: _bindings.Social.FellowshipQuit,
+ AssignLeader: _bindings.Social.FellowshipAssignLeader,
+ SetOpen: _bindings.Social.FellowshipSetOpen,
+ SetPanelOpen: _bindings.Social.FellowshipSetPanelOpen,
+ Selection: _bindings.Social.Selection,
+ LocalPlayerGuid: _bindings.Social.LocalPlayerGuid,
+ CurrentCharacterOption: id => _bindings.Options.CurrentCharacterOption((uint)id),
+ SetCharacterOption: (id, value) => _bindings.Options.CommandBus().Publish(
+ new SetSingleCharacterOptionRuntimeCmd((uint)id, value)),
+ ResolveString: (tableId, stringId) => fellowshipStrings.Resolve(tableId, stringId)),
AllegianceSnapshot: _bindings.Social.AllegianceSnapshot,
Friends: _bindings.Social.Friends,
Squelch: _bindings.Social.Squelch,
TemplateResolver: TemplateResolver);
- Layout.SocialPanelController? controller =
- Layout.SocialPanelController.Bind(layout, callbacks);
+ // A second DatLock scope (MountOptionsPanel's own precedent, above):
+ // SocialFellowshipPageController.Bind resolves the Open/Close
+ // caption pair and all four checkbox label/tooltip strings at BIND
+ // TIME (never per-tick — see that class's own doc on why), and
+ // Friends/Squelch/Fellowship's row-building all reach the SAME
+ // DatLock through TemplateResolver above (re-entrant on this
+ // thread — Monitor.Enter is safe to re-acquire recursively).
+ Layout.SocialPanelController? controller;
+ lock (_bindings.Assets.DatLock)
+ controller = Layout.SocialPanelController.Bind(layout, callbacks);
if (controller is null)
{
Console.WriteLine("[UI] social panel: required root did not build as UiTabPanel.");
diff --git a/src/AcDream.App/UI/UiTemplateListBox.cs b/src/AcDream.App/UI/UiTemplateListBox.cs
index 1560f404..f4dfaecc 100644
--- a/src/AcDream.App/UI/UiTemplateListBox.cs
+++ b/src/AcDream.App/UI/UiTemplateListBox.cs
@@ -266,4 +266,35 @@ public sealed class UiTemplateListBox : UiDatElement
///
///
public void Flush() => _viewport?.ClearContent();
+
+ ///
+ /// FA3 carry-forward 1 (folded into Campaign FA slice FA4): like
+ /// , but preserves the CURRENT scroll offset instead
+ /// of resetting it to 0. 's ClearContent zeroes
+ /// 's position — fine for
+ /// Friends/Squelch (revision-polled rebuild while visible, no mid-scroll
+ /// disturbance concern documented) but wrong for a roster whose rebuild
+ /// can be driven by a per-vitals-tick revision bump: resetting to the
+ /// top on every incoming 0x02C0 would fight the user's own
+ /// scrolling the instant they touch the fellowship list.
+ /// only calls this method
+ /// when the member SET actually changed (join/leave/disband) — a plain
+ /// vitals/stat refresh updates the existing rows' bound widgets in place
+ /// and never touches the ListBox structure at all, so this path is the
+ /// rare case, not the common one. The restored offset is clamped against
+ /// the STALE (pre-rebuild) content height at the moment of the call;
+ /// re-clamps it
+ /// against the fresh height on the very next draw, before anything is
+ /// ever painted with a stale bound — the same "clamp now, correct at
+ /// next layout" contract
+ /// already relies on for every ordinary
+ /// call. A no-op (same as ) while dormant.
+ ///
+ public void FlushPreservingScroll()
+ {
+ if (_viewport is null) return;
+ int savedScrollY = _viewport.Scroll.ScrollY;
+ _viewport.ClearContent();
+ _viewport.Scroll.SetScrollY(savedScrollY);
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs
index 066fdb3e..aa9bd75a 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs
@@ -698,14 +698,19 @@ public sealed class CharacterOptionsPageControllerTests
// ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ───────────
///
- /// The exact 35 values this slice dims
+ /// The exact 31 values this slice dims
/// ( == true), transcribed independently
/// of CharacterOptionsPageController.Groups from the derivation
/// table in that class's own doc comment. Wiring a future consumer
/// for any of these means removing it from this literal set AND
/// flipping its Groups table entry from StoreOnly to
/// Live consciously — leaving either one stale fails this
- /// test.
+ /// test. Campaign FA slice FA4, D7 removed four ids from this set —
+ /// IgnoreFellowshipRequests/FellowshipAutoAcceptRequests
+ /// (the fellowship-invite auto-decline/auto-accept, D6) and
+ /// FellowshipShareXP/FellowshipShareLoot (the fellowship
+ /// page's Create flow / D5 display / second checkbox surface) all
+ /// gained real acdream-side consumers.
///
private static readonly HashSet ExpectedStoreOnlyIds =
[
@@ -724,13 +729,9 @@ public sealed class CharacterOptionsPageControllerTests
CharacterOptionId.FilterLanguage,
CharacterOptionId.ShowHelm,
CharacterOptionId.ShowCloak,
- // Group 3 (Grouping) — all 6
+ // Group 3 (Grouping) — 2 of 6 (FA4, D7: the other four — see class doc)
CharacterOptionId.IgnoreAllegianceRequests,
- CharacterOptionId.IgnoreFellowshipRequests,
CharacterOptionId.DisplayAllegianceLogonNotifications,
- CharacterOptionId.FellowshipShareXP,
- CharacterOptionId.FellowshipShareLoot,
- CharacterOptionId.FellowshipAutoAcceptRequests,
// Group 4 (Other Players) — 10 of 11
CharacterOptionId.AcceptLootPermits,
CharacterOptionId.UseDeception,
@@ -761,8 +762,8 @@ public sealed class CharacterOptionsPageControllerTests
.ToHashSet();
Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly);
- Assert.Equal(35, actualStoreOnly.Count);
- Assert.Equal(15, 50 - actualStoreOnly.Count); // the 15 live rows
+ Assert.Equal(31, actualStoreOnly.Count);
+ Assert.Equal(19, 50 - actualStoreOnly.Count); // the 19 live rows (FA4, D7: +4)
}
[Fact]
diff --git a/tests/AcDream.App.Tests/UI/Layout/RowTemplateResolverTests.cs b/tests/AcDream.App.Tests/UI/Layout/RowTemplateResolverTests.cs
new file mode 100644
index 00000000..2e973068
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/RowTemplateResolverTests.cs
@@ -0,0 +1,91 @@
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// Campaign FA slice FA4, carry-forward 2 (the FA3 re-review's non-blocking
+/// finding: "add a production-resolver test for the new template cache").
+/// is the extracted, hermetically-testable
+/// caching resolver RetailUiRuntime.MountSocialPanel actually uses in
+/// production (Friends/Squelch since FA3, Fellowship since FA4) — these
+/// tests exercise ITS class directly, with fake import/build delegates, no
+/// live DAT.
+///
+public sealed class RowTemplateResolverTests
+{
+ [Fact]
+ public void Resolve_CachesTheImport_ButBuildsAFreshWidgetEveryCall()
+ {
+ int importCalls = 0;
+ int buildCalls = 0;
+ var resolver = new RowTemplateResolver(
+ importInfos: (layoutId, elementId) =>
+ {
+ importCalls++;
+ return new ElementInfo { Id = elementId, Type = 0xCu };
+ },
+ build: info =>
+ {
+ buildCalls++;
+ return new UiText();
+ });
+
+ UiElement? first = resolver.Resolve(0x21000030u, 0x10000281u);
+ UiElement? second = resolver.Resolve(0x21000030u, 0x10000281u);
+ UiElement? third = resolver.Resolve(0x21000030u, 0x10000281u);
+
+ Assert.NotNull(first);
+ Assert.NotNull(second);
+ Assert.NotNull(third);
+ // The expensive DAT tree walk (ImportInfos) ran exactly once...
+ Assert.Equal(1, importCalls);
+ Assert.Equal(1, resolver.ImportCount);
+ // ...but every row still gets its OWN built widget instance (a
+ // shared instance would mean every fellow's row points at the same
+ // UiElement, breaking per-row state like OnClick/LinesProvider).
+ Assert.Equal(3, buildCalls);
+ Assert.NotSame(first, second);
+ Assert.NotSame(second, third);
+ }
+
+ [Fact]
+ public void Resolve_DifferentTemplatePairs_ImportIndependently()
+ {
+ int importCalls = 0;
+ var resolver = new RowTemplateResolver(
+ importInfos: (layoutId, elementId) =>
+ {
+ importCalls++;
+ return new ElementInfo { Id = elementId, Type = 0xCu };
+ },
+ build: _ => new UiText());
+
+ // Fellowship's own template pair, and a DIFFERENT one (Friends'),
+ // through the SAME resolver instance — matches production, where
+ // MountSocialPanel feeds all three row families through one
+ // RowTemplateResolver.
+ resolver.Resolve(0x21000030u, 0x10000281u);
+ resolver.Resolve(0x2100005Du, 0x10000519u);
+ resolver.Resolve(0x21000030u, 0x10000281u);
+ resolver.Resolve(0x2100005Du, 0x10000519u);
+
+ Assert.Equal(2, importCalls);
+ }
+
+ [Fact]
+ public void Resolve_ImportMiss_CachesTheNull_AndNeverRetriesTheImport()
+ {
+ int importCalls = 0;
+ var resolver = new RowTemplateResolver(
+ importInfos: (_, _) => { importCalls++; return null; },
+ build: _ => throw new InvalidOperationException("build must not run for a null import"));
+
+ UiElement? first = resolver.Resolve(0x21000030u, 0x10000281u);
+ UiElement? second = resolver.Resolve(0x21000030u, 0x10000281u);
+
+ Assert.Null(first);
+ Assert.Null(second);
+ Assert.Equal(1, importCalls);
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs
new file mode 100644
index 00000000..8c2d196d
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs
@@ -0,0 +1,555 @@
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Selection;
+using AcDream.Runtime;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// Campaign FA slice FA4: hermetic (no DAT, no live runtime) tests for
+/// against a hand-built page
+/// tree carrying every element id lane A's structural inventory names
+/// (docs/research/2026-08-11-fa-panel-structure.md §3.1).
+///
+public sealed class SocialFellowshipPageControllerTests
+{
+ private const uint NotInFellowshipFrameId = 0x1000026Bu;
+ private const uint InFellowshipFrameId = 0x10000275u;
+ private const uint NameEntryBoxId = 0x1000026Fu;
+ private const uint CreateButtonId = 0x10000274u;
+ private const uint FellowshipNameTextId = 0x10000276u;
+ private const uint ListBoxId = 0x10000279u;
+ private const uint ScrollbarId = 0x1000027Au;
+ private const uint LeaderButtonId = 0x1000027Bu;
+ private const uint QuitButtonId = 0x1000027Cu;
+ private const uint OpenButtonId = 0x1000027Du;
+ private const uint RecruitButtonId = 0x1000027Eu;
+ private const uint DismissButtonId = 0x1000027Fu;
+ private const uint DisbandButtonId = 0x10000280u;
+ private const uint IgnoreCheckboxId = 0x10000270u;
+ private const uint AutoAcceptCheckboxId = 0x10000271u;
+ private const uint ShareXpCheckboxId = 0x10000272u;
+ private const uint ShareLootCheckboxId = 0x10000273u;
+
+ private const uint RowNameTextId = 0x10000283u;
+ private const uint RowStatsTextId = 0x10000284u;
+ private const uint RowHealthMeterId = 0x10000285u;
+ private const uint RowStaminaMeterId = 0x10000287u;
+ private const uint RowManaMeterId = 0x10000289u;
+
+ private static readonly Func NoTex = static _ => (0u, 0, 0);
+
+ private static UiButton MakeButton(uint id)
+ {
+ var button = new UiButton(new ElementInfo { Id = id, Type = 1u }, NoTex);
+ button.DatElementId = id;
+ return button;
+ }
+
+ private static UiElement BuildPageRoot(out UiTemplateListBox listBox, out UiField nameField)
+ {
+ var root = new UiPanel();
+ void AddPlain(UiElement child, uint id)
+ {
+ child.DatElementId = id;
+ root.AddChild(child);
+ }
+
+ AddPlain(new UiPanel(), NotInFellowshipFrameId);
+ AddPlain(new UiPanel(), InFellowshipFrameId);
+
+ nameField = new UiField();
+ AddPlain(nameField, NameEntryBoxId);
+
+ AddPlain(MakeButton(CreateButtonId), CreateButtonId);
+ AddPlain(new UiText(), FellowshipNameTextId);
+
+ listBox = new UiTemplateListBox(
+ new ElementInfo { Id = ListBoxId, Type = 5u },
+ NoTex,
+ new[] { new UiTemplateListEntry(0x21000030u, 0x10000281u) },
+ scrollbarElementId: ScrollbarId);
+ AddPlain(listBox, ListBoxId);
+
+ AddPlain(new UiScrollbar(), ScrollbarId);
+
+ AddPlain(MakeButton(LeaderButtonId), LeaderButtonId);
+ AddPlain(MakeButton(QuitButtonId), QuitButtonId);
+ AddPlain(MakeButton(OpenButtonId), OpenButtonId);
+ AddPlain(MakeButton(RecruitButtonId), RecruitButtonId);
+ AddPlain(MakeButton(DismissButtonId), DismissButtonId);
+ AddPlain(MakeButton(DisbandButtonId), DisbandButtonId);
+ AddPlain(MakeButton(IgnoreCheckboxId), IgnoreCheckboxId);
+ AddPlain(MakeButton(AutoAcceptCheckboxId), AutoAcceptCheckboxId);
+ AddPlain(MakeButton(ShareXpCheckboxId), ShareXpCheckboxId);
+ AddPlain(MakeButton(ShareLootCheckboxId), ShareLootCheckboxId);
+
+ return root;
+ }
+
+ /// Mirrors lane A's 8-child fellow row shape (name, stats, three
+ /// meter+text-consumed-by-meter pairs — the meter text is NOT a separate
+ /// resolved child, per ).
+ private static UiElement BuildFakeFellowRow()
+ {
+ var row = new UiPanel();
+ void Add(UiElement child, uint id)
+ {
+ child.DatElementId = id;
+ row.AddChild(child);
+ }
+ Add(new UiText(), RowNameTextId);
+ Add(new UiText(), RowStatsTextId);
+ Add(new UiMeter(), RowHealthMeterId);
+ Add(new UiMeter(), RowStaminaMeterId);
+ Add(new UiMeter(), RowManaMeterId);
+ return row;
+ }
+
+ private static int _rowBuildCount;
+
+ private static UiElement? FakeRowResolver(uint layoutId, uint elementId)
+ {
+ _rowBuildCount++;
+ return BuildFakeFellowRow();
+ }
+
+ private static readonly RuntimeCommandResult InactiveResult = new(RuntimeCommandStatus.Inactive, default);
+
+ private sealed class FellowshipBindingsBuilder
+ {
+ public RuntimeFellowshipSnapshot Snapshot;
+ public List Members = [];
+ public readonly List Calls = [];
+ public readonly Dictionary Options = new();
+ public SelectionState Selection = new();
+ public uint LocalPlayerGuid;
+ public Func TemplateResolver = FakeRowResolver;
+
+ public SocialFellowshipPageController.Bindings Build() => new(
+ Snapshot: () => Snapshot,
+ Members: () => Members,
+ TemplateResolver: TemplateResolver,
+ Create: (name, shareXp) => { Calls.Add($"create:{name}:{shareXp}"); return InactiveResult; },
+ Recruit: guid => { Calls.Add($"recruit:{guid:X8}"); return InactiveResult; },
+ Dismiss: guid => { Calls.Add($"dismiss:{guid:X8}"); return InactiveResult; },
+ Quit: disband => { Calls.Add($"quit:{disband}"); return InactiveResult; },
+ AssignLeader: guid => { Calls.Add($"assign-leader:{guid:X8}"); return InactiveResult; },
+ SetOpen: isOpen => { Calls.Add($"set-open:{isOpen}"); return InactiveResult; },
+ SetPanelOpen: panelOpen => { Calls.Add($"set-panel-open:{panelOpen}"); return InactiveResult; },
+ Selection: Selection,
+ LocalPlayerGuid: () => LocalPlayerGuid,
+ CurrentCharacterOption: id => Options.TryGetValue(id, out bool v) && v,
+ SetCharacterOption: (id, value) => { Options[id] = value; Calls.Add($"set-option:{id}:{value}"); },
+ ResolveString: (_, _) => null);
+ }
+
+ // ── Empty/full frame swap (still correct after the FA4 rewrite) ────────
+
+ [Fact]
+ public void Bind_NotInFellowship_ShowsEmptyFrame()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
+
+ SocialFellowshipPageController? controller = SocialFellowshipPageController.Bind(root, b.Build());
+
+ Assert.NotNull(controller);
+ Assert.True(UiElement.FindDescendant(root, NotInFellowshipFrameId)!.Visible);
+ Assert.False(UiElement.FindDescendant(root, InFellowshipFrameId)!.Visible);
+ }
+
+ [Fact]
+ public void Bind_InFellowship_ShowsPopulatedFrame()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true } };
+
+ SocialFellowshipPageController.Bind(root, b.Build());
+
+ Assert.False(UiElement.FindDescendant(root, NotInFellowshipFrameId)!.Visible);
+ Assert.True(UiElement.FindDescendant(root, InFellowshipFrameId)!.Visible);
+ }
+
+ // ── Roster building ──────────────────────────────────────────────────
+
+ [Fact]
+ public void Tick_BuildsOneRowPerMember_WithNameLevelAndVitals()
+ {
+ UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot
+ {
+ IsInFellowship = true,
+ Revision = 1,
+ ShareXp = true,
+ EvenXpSplit = true,
+ MemberCount = 1,
+ },
+ Members =
+ [
+ new RuntimeFellowMemberSnapshot(
+ Guid: 0x50000001u, Name: "Alice", Level: 12,
+ MaxHealth: 100, MaxStamina: 80, MaxMana: 60,
+ CurrentHealth: 55, CurrentStamina: 80, CurrentMana: 10,
+ ShareLoot: true),
+ ],
+ };
+
+ SocialFellowshipPageController.Bind(root, b.Build());
+
+ UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
+ UiText name = Assert.IsType(UiElement.FindDescendant(row, RowNameTextId));
+ Assert.Equal("Alice", name.LinesProvider().Single().Text);
+
+ UiText stats = Assert.IsType(UiElement.FindDescendant(row, RowStatsTextId));
+ // 1-member even split -> 1.0 -> "100%" (lane B §7.2 table index 0).
+ Assert.Equal("12 100%", stats.LinesProvider().Single().Text);
+
+ var health = Assert.IsType(UiElement.FindDescendant(row, RowHealthMeterId));
+ Assert.Equal(0.55f, health.Fill()!.Value, 3);
+ Assert.Equal("55/100", health.Label());
+ }
+
+ [Fact]
+ public void Tick_SameMemberSet_UpdatesRowsInPlace_NoRebuild()
+ {
+ UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
+ var member = new RuntimeFellowMemberSnapshot(
+ 0x50000001u, "Alice", 12, 100, 80, 60, 55, 80, 10, false);
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1 },
+ Members = [member],
+ };
+
+ SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
+ UiElement firstRow = Assert.Single(listBox.ViewportForTest!.Children);
+
+ // A vitals-only change: SAME guid, new health, revision bumped (the
+ // exact shape ApplyUpdateFellow produces on a 0x02C0 vitals tick).
+ b.Snapshot = b.Snapshot with { Revision = 2 };
+ b.Members = [member with { CurrentHealth = 10 }];
+ controller.Tick();
+
+ UiElement onlyRow = Assert.Single(listBox.ViewportForTest!.Children);
+ Assert.Same(firstRow, onlyRow); // NOT rebuilt — same row instance
+ var health = Assert.IsType(UiElement.FindDescendant(onlyRow, RowHealthMeterId));
+ Assert.Equal(0.10f, health.Fill()!.Value, 3);
+ }
+
+ [Fact]
+ public void Tick_MembershipChange_RebuildsRoster_ButPreservesScrollPosition()
+ {
+ UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
+ var alice = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1 },
+ Members = [alice],
+ };
+
+ SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
+ listBox.ViewportForTest!.ApplyAnchor(listBox.Width, listBox.Height);
+ listBox.ViewportForTest!.LayoutScrollableChildren();
+ listBox.Scroll.SetScrollY(0); // only one short row — nothing to scroll, but exercise the path
+
+ // Carry-forward 1: a genuine roster change (Bob joins) must not
+ // silently desync — verified via row count — AND must go through
+ // the scroll-preserving flush, not the scroll-resetting one. We
+ // can't observe a NONZERO preserved offset with only one short
+ // row, so this test's decisive assertion is closer to the wiring:
+ // FlushPreservingScroll leaves Scroll.ScrollY untouched immediately
+ // after the clear, unlike Flush.
+ var bob = new RuntimeFellowMemberSnapshot(0x50000002u, "Bob", 10, 90, 70, 50, 90, 70, 50, false);
+ b.Snapshot = b.Snapshot with { Revision = 2, MemberCount = 2 };
+ b.Members = [alice, bob];
+ controller.Tick();
+
+ Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
+ }
+
+ [Fact]
+ public void Tick_NotInFellowship_ClearsAnyStaleRoster()
+ {
+ UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
+ var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1 },
+ Members = [member],
+ };
+ SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
+ Assert.Single(listBox.ViewportForTest!.Children);
+
+ b.Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false };
+ controller.Tick();
+
+ Assert.Empty(listBox.ViewportForTest!.Children);
+ }
+
+ // ── D5 display ───────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(false, true, 1, "12 0%")] // ShareXp off -> retail's literal 0.0
+ [InlineData(true, true, 9, "12 31%")] // even split, 9 fellows -> 0.3111111 -> round(31.11) = 31
+ [InlineData(true, false, 3, "12")] // proportional -> no acdream XP table -> level only, no invented %
+ public void FormatStatsText_MatchesD5Rules(bool shareXp, bool evenSplit, int memberCount, string expected)
+ {
+ UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
+ var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot
+ {
+ IsInFellowship = true, Revision = 1, ShareXp = shareXp,
+ EvenXpSplit = evenSplit, MemberCount = memberCount,
+ },
+ Members = [member],
+ };
+
+ SocialFellowshipPageController.Bind(root, b.Build());
+
+ UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
+ UiText stats = Assert.IsType(UiElement.FindDescendant(row, RowStatsTextId));
+ Assert.Equal(expected, stats.LinesProvider().Single().Text);
+ }
+
+ // ── Create flow ──────────────────────────────────────────────────────
+
+ [Fact]
+ public void CreateButton_DisabledWhileNameFieldEmpty_EnabledOnceTyped()
+ {
+ UiElement root = BuildPageRoot(out _, out UiField nameField);
+ var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
+ SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
+ var createButton = (UiButton)UiElement.FindDescendant(root, CreateButtonId)!;
+
+ Assert.False(createButton.Enabled);
+
+ nameField.SetText("The Wanderers");
+ controller.Tick();
+ Assert.True(createButton.Enabled);
+
+ nameField.SetText(" ");
+ controller.Tick();
+ Assert.False(createButton.Enabled);
+ }
+
+ [Fact]
+ public void CreateButton_Click_SendsTypedName_AndTheShareXpOptionValue()
+ {
+ UiElement root = BuildPageRoot(out _, out UiField nameField);
+ var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
+ b.Options[CharacterOptionId.FellowshipShareXP] = true;
+ SocialFellowshipPageController.Bind(root, b.Build());
+ var createButton = (UiButton)UiElement.FindDescendant(root, CreateButtonId)!;
+
+ nameField.SetText("The Wanderers");
+ createButton.OnClick!();
+
+ Assert.Contains("create:The Wanderers:True", b.Calls);
+ }
+
+ [Fact]
+ public void CreateButton_Click_WithEmptyName_DoesNotSend()
+ {
+ UiElement root = BuildPageRoot(out _, out UiField nameField);
+ var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
+ SocialFellowshipPageController.Bind(root, b.Build());
+ var createButton = (UiButton)UiElement.FindDescendant(root, CreateButtonId)!;
+
+ createButton.OnClick!();
+
+ Assert.DoesNotContain(b.Calls, c => c.StartsWith("create:"));
+ }
+
+ // ── Member action buttons ───────────────────────────────────────────
+
+ [Fact]
+ public void QuitButton_Click_SendsQuit_WithDisbandFalse()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true } };
+ SocialFellowshipPageController.Bind(root, b.Build());
+ ((UiButton)UiElement.FindDescendant(root, QuitButtonId)!).OnClick!();
+
+ Assert.Contains("quit:False", b.Calls);
+ }
+
+ [Fact]
+ public void DisbandButton_Click_SendsQuit_WithDisbandTrue()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true } };
+ SocialFellowshipPageController.Bind(root, b.Build());
+ ((UiButton)UiElement.FindDescendant(root, DisbandButtonId)!).OnClick!();
+
+ Assert.Contains("quit:True", b.Calls);
+ }
+
+ [Fact]
+ public void OpenButton_Click_TogglesTheCurrentOpenState()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, IsOpen = false, LeaderGuid = 1u },
+ LocalPlayerGuid = 1u,
+ };
+ SocialFellowshipPageController.Bind(root, b.Build());
+ ((UiButton)UiElement.FindDescendant(root, OpenButtonId)!).OnClick!();
+
+ Assert.Contains("set-open:True", b.Calls);
+ }
+
+ [Fact]
+ public void RecruitButton_Click_SendsTheCurrentWorldSelection()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var selection = new SelectionState();
+ selection.Select(0x60000001u, SelectionChangeSource.World);
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true },
+ Selection = selection,
+ };
+ SocialFellowshipPageController.Bind(root, b.Build());
+ ((UiButton)UiElement.FindDescendant(root, RecruitButtonId)!).OnClick!();
+
+ Assert.Contains("recruit:60000001", b.Calls);
+ }
+
+ [Fact]
+ public void DismissAndLeaderButtons_Click_TargetTheSelectedFellow()
+ {
+ UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
+ var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 0xFFu },
+ Members = [member],
+ LocalPlayerGuid = 0xFFu, // leader, so Dismiss/Leader are enabled once a fellow is selected
+ };
+ SocialFellowshipPageController.Bind(root, b.Build());
+
+ UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
+ UiText name = Assert.IsType(UiElement.FindDescendant(row, RowNameTextId));
+ name.OnClick!(); // selects Alice, per SelectFellow
+
+ ((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).OnClick!();
+ ((UiButton)UiElement.FindDescendant(root, LeaderButtonId)!).OnClick!();
+
+ Assert.Contains("dismiss:50000001", b.Calls);
+ Assert.Contains("assign-leader:50000001", b.Calls);
+ Assert.Equal(0x50000001u, b.Selection.SelectedObjectId);
+ }
+
+ // ── Button enable rules (lane B §2.8) ───────────────────────────────
+
+ [Fact]
+ public void ButtonStates_NonLeader_DisbandOpenLeaderDismiss_AreDisabled_QuitStaysEnabled()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, LeaderGuid = 0x1u },
+ LocalPlayerGuid = 0x2u, // not the leader
+ };
+ SocialFellowshipPageController.Bind(root, b.Build());
+
+ Assert.True(((UiButton)UiElement.FindDescendant(root, QuitButtonId)!).Enabled);
+ Assert.False(((UiButton)UiElement.FindDescendant(root, DisbandButtonId)!).Enabled);
+ Assert.False(((UiButton)UiElement.FindDescendant(root, OpenButtonId)!).Enabled);
+ Assert.False(((UiButton)UiElement.FindDescendant(root, LeaderButtonId)!).Enabled);
+ Assert.False(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
+ }
+
+ [Fact]
+ public void ButtonStates_Leader_DisbandAndOpen_AreEnabled()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, LeaderGuid = 0x1u },
+ LocalPlayerGuid = 0x1u,
+ };
+ SocialFellowshipPageController.Bind(root, b.Build());
+
+ Assert.True(((UiButton)UiElement.FindDescendant(root, DisbandButtonId)!).Enabled);
+ Assert.True(((UiButton)UiElement.FindDescendant(root, OpenButtonId)!).Enabled);
+ }
+
+ [Fact]
+ public void RecruitButton_DisabledWhenFellowshipIsFull()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var selection = new SelectionState();
+ selection.Select(0x60000001u, SelectionChangeSource.World);
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, MemberCount = 9, LeaderGuid = 1u },
+ LocalPlayerGuid = 1u,
+ Selection = selection,
+ };
+ SocialFellowshipPageController.Bind(root, b.Build());
+
+ Assert.False(((UiButton)UiElement.FindDescendant(root, RecruitButtonId)!).Enabled);
+ }
+
+ // ── Checkboxes ───────────────────────────────────────────────────────
+
+ [Fact]
+ public void Checkbox_Click_TogglesAndWritesTheCharacterOption()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
+ SocialFellowshipPageController.Bind(root, b.Build());
+ var shareXp = (UiButton)UiElement.FindDescendant(root, ShareXpCheckboxId)!;
+ Assert.False(shareXp.Selected);
+
+ shareXp.OnClick!();
+
+ Assert.True(shareXp.Selected);
+ Assert.True(b.Options[CharacterOptionId.FellowshipShareXP]);
+ }
+
+ [Fact]
+ public void Checkbox_RefreshesFromLiveState_WhenTheOtherSurfaceWrites()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
+ SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
+ var ignore = (UiButton)UiElement.FindDescendant(root, IgnoreCheckboxId)!;
+ Assert.False(ignore.Selected);
+
+ // Simulate the Character tab's OWN checkbox writing the same option.
+ b.Options[CharacterOptionId.IgnoreFellowshipRequests] = true;
+ controller.Tick();
+
+ Assert.True(ignore.Selected);
+ }
+
+ // ── D4: 0x00A6 panel-open declaration ───────────────────────────────
+
+ [Fact]
+ public void SetPageVisible_SendsPanelOpen_OnlyOnATransition()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true } };
+ SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
+ b.Calls.Clear(); // discard any Bind-time noise
+
+ controller.SetPageVisible(true);
+ Assert.Contains("set-panel-open:True", b.Calls);
+
+ b.Calls.Clear();
+ controller.SetPageVisible(true); // no transition -> no second send
+ Assert.Empty(b.Calls);
+
+ controller.SetPageVisible(false);
+ Assert.Contains("set-panel-open:False", b.Calls);
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs
index ff7391c0..ff0db6a8 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs
@@ -1,6 +1,7 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages;
+using AcDream.Core.Selection;
using AcDream.Core.Social;
using AcDream.Runtime;
@@ -16,6 +17,45 @@ namespace AcDream.App.Tests.UI.Layout;
///
public sealed class SocialPanelControllerTests
{
+ private static readonly RuntimeCommandResult InactiveResult =
+ new(RuntimeCommandStatus.Inactive, default);
+
+ /// Campaign FA slice FA4: the Fellowship page's full read/write
+ /// seam. Every command records its call (by name) into
+ /// and returns —
+ /// hermetic tests here exercise WIRING (does clicking X call the right
+ /// delegate with the right argument), not live command semantics
+ /// (covered by RuntimeFellowshipStateTests/
+ /// DirectGameRuntimeCommandAdapterTests).
+ private static SocialFellowshipPageController.Bindings MakeFellowshipBindings(
+ List? calls = null,
+ RuntimeFellowshipSnapshot snapshot = default,
+ IEnumerable? members = null,
+ SelectionState? selection = null,
+ uint localPlayerGuid = 0u,
+ Func? currentCharacterOption = null,
+ Func? templateResolver = null,
+ Func? resolveString = null)
+ {
+ calls ??= new List();
+ return new SocialFellowshipPageController.Bindings(
+ Snapshot: () => snapshot,
+ Members: () => members ?? [],
+ TemplateResolver: templateResolver ?? FakeRowTemplateResolver,
+ Create: (name, shareXp) => { calls.Add($"fellowship-create:{name}:{shareXp}"); return InactiveResult; },
+ Recruit: guid => { calls.Add($"fellowship-recruit:{guid:X8}"); return InactiveResult; },
+ Dismiss: guid => { calls.Add($"fellowship-dismiss:{guid:X8}"); return InactiveResult; },
+ Quit: disband => { calls.Add($"fellowship-quit:{disband}"); return InactiveResult; },
+ AssignLeader: guid => { calls.Add($"fellowship-assign-leader:{guid:X8}"); return InactiveResult; },
+ SetOpen: isOpen => { calls.Add($"fellowship-set-open:{isOpen}"); return InactiveResult; },
+ SetPanelOpen: panelOpen => { calls.Add($"fellowship-set-panel-open:{panelOpen}"); return InactiveResult; },
+ Selection: selection ?? new SelectionState(),
+ LocalPlayerGuid: () => localPlayerGuid,
+ CurrentCharacterOption: currentCharacterOption ?? (_ => false),
+ SetCharacterOption: (id, value) => calls.Add($"fellowship-set-option:{id}:{value}"),
+ ResolveString: resolveString ?? ((_, _) => null));
+ }
+
private static SocialPanelController.Callbacks MakeCallbacks(
List? calls = null,
RuntimeFellowshipSnapshot fellowship = default,
@@ -26,7 +66,7 @@ public sealed class SocialPanelControllerTests
calls ??= new List();
return new SocialPanelController.Callbacks(
Toggle: () => calls.Add("toggle"),
- FellowshipSnapshot: () => fellowship,
+ Fellowship: MakeFellowshipBindings(calls, fellowship),
AllegianceSnapshot: () => allegiance,
Friends: friends ?? new FriendsState(),
Squelch: squelch ?? new SquelchState(),
diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs
index d4fecac9..bf6d50c4 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs
@@ -1,6 +1,7 @@
using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
+using AcDream.Runtime;
using DatReaderWriter;
using DatReaderWriter.Options;
@@ -109,6 +110,137 @@ public sealed class SocialPanelLiveMountProbeTests
Assert.NotNull(el);
}
+ // Campaign FA slice FA4, item 8: the fellowship page's own control
+ // inventory against the PRODUCTION mount (not a fixture) — the
+ // name-entry field must build as UiField (proving the DAT authors
+ // Editable=1, SocialFellowshipPageController's load-bearing
+ // assumption), and every button/checkbox must resolve as UiButton.
+ UiElement? nameField = UiElement.FindDescendant(tabs, 0x1000026Fu);
+ Console.WriteLine(
+ $"[socialprobe] fellowship name-entry field 0x1000026F -> {(nameField is null ? "MISSING" : nameField.GetType().Name)}");
+ Assert.IsType(nameField);
+
+ foreach ((uint id, string name) in new[]
+ {
+ (0x10000274u, "CreateFellowshipButton"),
+ (0x1000027Bu, "FellowLeaderButton"),
+ (0x1000027Cu, "FellowQuitButton"),
+ (0x1000027Du, "FellowOpenButton"),
+ (0x1000027Eu, "FellowRecruitButton"),
+ (0x1000027Fu, "FellowDismissButton"),
+ (0x10000280u, "FellowDisbandButton"),
+ (0x10000270u, "IgnoreFellowshipRequestsCheckbox"),
+ (0x10000271u, "FellowshipAutoAcceptRequestsCheckbox"),
+ (0x10000272u, "FellowshipShareXPCheckbox"),
+ (0x10000273u, "FellowshipShareLootCheckbox"),
+ })
+ {
+ UiElement? el = UiElement.FindDescendant(tabs, id);
+ Console.WriteLine($"[socialprobe] fellowship control {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}");
+ Assert.IsType(el);
+ }
+
+ // The fellowship ListBox's own authored template pair (lane A U10 —
+ // read P0x64 on 0x10000279) and its scrollbar (P0x72).
+ UiElement? fellowshipListBoxEl = UiElement.FindDescendant(tabs, 0x10000279u);
+ UiTemplateListBox fellowshipListBox = Assert.IsType(fellowshipListBoxEl);
+ Console.WriteLine(
+ $"[socialprobe] fellowship ListBox 0x10000279 templates={fellowshipListBox.Templates.Count} "
+ + $"scrollbar=0x{fellowshipListBox.ScrollbarElementId:X8}");
+ Assert.NotEmpty(fellowshipListBox.Templates);
+ UiTemplateListEntry fellowRowTemplate = fellowshipListBox.Templates[0];
+
+ // Production-resolver build of the row template (FA4 carry-forward
+ // 2): the SAME RowTemplateResolver shape MountSocialPanel uses in
+ // production — Import then Build against the LIVE dats, not a
+ // fixture — proving the 8-part row (lane A §3.1) actually resolves
+ // end-to-end, not merely that the id pair is authored.
+ var rowTemplates = new RowTemplateResolver(
+ (layoutId, elementId) => LayoutImporter.ImportInfos(dats, layoutId, elementId),
+ info => LayoutImporter.Build(info, _ => (1u, 8, 8), null, null, strings.Resolve).Root);
+ UiElement? fellowRow = rowTemplates.Resolve(
+ fellowRowTemplate.TemplateLayoutId, fellowRowTemplate.TemplateElementId);
+ Console.WriteLine(
+ $"[socialprobe] fellowship row template 0x{fellowRowTemplate.TemplateLayoutId:X8}/"
+ + $"0x{fellowRowTemplate.TemplateElementId:X8} -> {(fellowRow is null ? "IMPORT NULL" : fellowRow.GetType().Name)}");
+ Assert.NotNull(fellowRow);
+
+ foreach ((uint id, string name, Type expectedType) in new (uint, string, Type)[]
+ {
+ (0x10000283u, "FellowName", typeof(UiText)),
+ (0x10000284u, "FellowStats", typeof(UiText)),
+ (0x10000285u, "HealthMeter", typeof(UiMeter)),
+ (0x10000287u, "StaminaMeter", typeof(UiMeter)),
+ (0x10000289u, "ManaMeter", typeof(UiMeter)),
+ })
+ {
+ UiElement? el = UiElement.FindDescendant(fellowRow!, id);
+ Console.WriteLine($"[socialprobe] fellowship row field {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}");
+ Assert.IsType(expectedType, el);
+ }
+
+ // Checkbox labels/tooltips (0x23000003) and the Open/Close caption
+ // pair (0x23000001) — the two string tables SocialFellowshipPageController
+ // resolves at Bind time (lane A §4.3).
+ foreach (string retailName in new[]
+ {
+ "IgnoreFellowshipRequests", "FellowshipAutoAcceptRequests",
+ "FellowshipShareXP", "FellowshipShareLoot",
+ })
+ {
+ string? label = strings.Resolve(0x23000003u, DatStringResolver.ComputeHash($"ID_PlayerOption_{retailName}"));
+ Console.WriteLine($"[socialprobe] checkbox label ID_PlayerOption_{retailName} -> '{label}'");
+ }
+ foreach (string key in new[]
+ {
+ "ID_Fellowship_OpenFellowshipButtonText", "ID_Fellowship_CloseFellowshipButtonText",
+ })
+ {
+ string? label = strings.Resolve(0x23000001u, DatStringResolver.ComputeHash(key));
+ Console.WriteLine($"[socialprobe] fellowship caption {key} -> '{label}'");
+ }
+
+ // Full end-to-end Bind against the PRODUCTION mount (the same
+ // resolver/scrollbar/frame wiring MountSocialPanel does) — proves
+ // Bind() finds every element it needs with zero "not found"
+ // console warnings, not just that the individual ids resolve in
+ // isolation above.
+ UiElement? fellowshipPageForBind = UiElement.FindDescendant(tabs, 0x10000292u);
+ Assert.NotNull(fellowshipPageForBind);
+ var originalOut = Console.Out;
+ var capture = new StringWriter();
+ Console.SetOut(capture);
+ SocialFellowshipPageController? fellowshipController;
+ try
+ {
+ fellowshipController = SocialFellowshipPageController.Bind(
+ fellowshipPageForBind!,
+ new SocialFellowshipPageController.Bindings(
+ Snapshot: () => new RuntimeFellowshipSnapshot(),
+ Members: () => [],
+ TemplateResolver: rowTemplates.Resolve,
+ Create: (_, _) => default,
+ Recruit: _ => default,
+ Dismiss: _ => default,
+ Quit: _ => default,
+ AssignLeader: _ => default,
+ SetOpen: _ => default,
+ SetPanelOpen: _ => default,
+ Selection: new AcDream.Core.Selection.SelectionState(),
+ LocalPlayerGuid: () => 0u,
+ CurrentCharacterOption: _ => false,
+ SetCharacterOption: (_, _) => { },
+ ResolveString: (tableId, stringId) => strings.Resolve(tableId, stringId)));
+ }
+ finally
+ {
+ Console.SetOut(originalOut);
+ }
+ string bindLog = capture.ToString();
+ Console.WriteLine($"[socialprobe] fellowship Bind() console output:\n{bindLog}");
+ Assert.NotNull(fellowshipController);
+ Assert.DoesNotContain("not found", bindLog);
+
// Allegiance signature elements.
foreach ((uint id, string name) in new[]
{
diff --git a/tests/AcDream.App.Tests/UI/UiTemplateListBoxFlushPreservingScrollTests.cs b/tests/AcDream.App.Tests/UI/UiTemplateListBoxFlushPreservingScrollTests.cs
new file mode 100644
index 00000000..0311d6d1
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/UiTemplateListBoxFlushPreservingScrollTests.cs
@@ -0,0 +1,99 @@
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+
+namespace AcDream.App.Tests.UI;
+
+///
+/// FA3 carry-forward 1 (folded into Campaign FA slice FA4): unlike
+/// ,
+/// must not reset the scroll offset to 0 — it exists specifically so a
+/// roster rebuild triggered by a server-side change (not user action)
+/// doesn't yank a scrolled-down user back to the top.
+///
+public sealed class UiTemplateListBoxFlushPreservingScrollTests
+{
+ private static UiTemplateListBox MakeListBox(float width, float height)
+ {
+ var info = new ElementInfo { Id = 0x10000279u, Type = 5u };
+ return new UiTemplateListBox(
+ info,
+ _ => (0u, 0, 0),
+ new[] { new UiTemplateListEntry(0x21000030u, 0x10000281u) },
+ scrollbarElementId: 0x1000027Au)
+ {
+ Width = width,
+ Height = height,
+ };
+ }
+
+ private static void AddRows(UiTemplateListBox box, int count, float rowHeight = 40f)
+ {
+ for (int i = 0; i < count; i++)
+ box.AddPrebuiltRow(new UiText { Width = box.Width, Height = rowHeight });
+ }
+
+ [Fact]
+ public void Flush_ResetsScrollToZero_TheOrdinaryContract()
+ {
+ var box = MakeListBox(200f, 100f);
+ AddRows(box, 10); // 400px of content in a 100px viewport
+ box.ViewportForTest!.ApplyAnchor(box.Width, box.Height);
+ box.ViewportForTest!.LayoutScrollableChildren();
+ box.Scroll.SetScrollY(150);
+ Assert.Equal(150, box.Scroll.ScrollY);
+
+ box.Flush();
+
+ Assert.Equal(0, box.Scroll.ScrollY);
+ }
+
+ [Fact]
+ public void FlushPreservingScroll_KeepsTheScrollOffset()
+ {
+ var box = MakeListBox(200f, 100f);
+ AddRows(box, 10); // 400px of content in a 100px viewport
+ box.ViewportForTest!.ApplyAnchor(box.Width, box.Height);
+ box.ViewportForTest!.LayoutScrollableChildren();
+ box.Scroll.SetScrollY(150);
+ Assert.Equal(150, box.Scroll.ScrollY);
+
+ box.FlushPreservingScroll();
+
+ Assert.Equal(150, box.Scroll.ScrollY);
+
+ // Rebuild the SAME row count — the offset should still be valid
+ // (and stay put) once the next layout pass recomputes content
+ // height against the rebuilt rows.
+ AddRows(box, 10);
+ box.ViewportForTest!.LayoutScrollableChildren();
+ Assert.Equal(150, box.Scroll.ScrollY);
+ }
+
+ [Fact]
+ public void FlushPreservingScroll_ClampsToTheNewShorterContent()
+ {
+ var box = MakeListBox(200f, 100f);
+ AddRows(box, 10); // 400px of content
+ box.ViewportForTest!.ApplyAnchor(box.Width, box.Height);
+ box.ViewportForTest!.LayoutScrollableChildren();
+ box.Scroll.SetScrollY(300); // near the bottom of the 10-row content
+
+ box.FlushPreservingScroll();
+ // Rebuild with far FEWER rows (e.g. most of the fellowship left).
+ AddRows(box, 2); // 80px of content, less than the 100px viewport
+ box.ViewportForTest!.LayoutScrollableChildren();
+
+ // Clamped to the new MaxScroll (0, since content < viewport) —
+ // never left dangling past the real content, and never throws.
+ Assert.Equal(0, box.Scroll.ScrollY);
+ }
+
+ [Fact]
+ public void FlushPreservingScroll_DormantBox_IsANoOp()
+ {
+ var box = MakeListBox(200f, 100f);
+ // No rows ever added — the viewport was never created.
+ box.FlushPreservingScroll();
+ Assert.Null(box.ViewportForTest);
+ }
+}