feat(ui): FA4 -- fellowship page fully live

Roster: SocialFellowshipPageController now builds one row per fellow
from the authored template (0x21000030/0x10000281, live-DAT verified),
diffing the member GUID set on each revision-gated Tick -- an unchanged
set updates every row's bound widgets in place (no ListBox mutation, so
scroll position is untouched by construction); only a real join/leave/
disband triggers a rebuild, via UiTemplateListBox.FlushPreservingScroll
(FA3 carry-forward 1, both the widget-level fix and the controller-level
diff). Health/stamina/mana meters bind Fill+Label; the leader's name
tints gold (lane A's row template has no dedicated leader marker, so
this is a flagged adaptation, not a ported mechanism). Row-click
selection (SelectFellow) drives Dismiss/Leader targeting and the world
selection (SelectionChangeSource.Social).

D4: SocialPanelController now tracks "is the social window shown AND is
Fellowship the active tab" via UiTabPanel.ActivePageChanged +
OnShown/OnHidden, and calls SetPageVisible on every transition, which
sends 0x00A6 (idempotent, no-op while disconnected) -- the prerequisite
ACE gates its 0x02C0 vitals stream on.

Create flow: the inline name field (0x1000026F, an authored Editable
UiField -- live-DAT verified) gates the Create button's enabled state
exactly like retail (empty name = disabled = the whole refusal
mechanism, no separate error text); FellowshipShareXP's live value is
read at click time.

Actions + confirmations: Recruit/Dismiss/Quit/Disband/AssignLeader/
SetOpen all route through DeferredGameRuntimeStateCommands (new
Fellowship* methods) rather than a raw WorldSession send, so Quit
correctly picks up RuntimeFellowshipState's leader hand-off rule.
Button enable states port gmFellowshipUI::UpdateButtons verbatim. The
Open/Close button's caption swaps between the two DAT-resolved strings
cached once at Bind (never per-tick -- DatCollection is not safe to
touch unprotected from the render loop). RetailUiRuntime intercepts a
type-4 confirmation request before it reaches the generic
GameplayConfirmationController: IgnoreFellowshipRequests auto-declines,
FellowshipAutoAcceptRequests auto-accepts, neither set falls through to
the existing dialog machinery unchanged (D6).

D5 display: the per-fellow stats line uses retail's byte-decoded
even-split percentage table verbatim (1.0/.../.3111111/.28, default
0.0); the proportional branch omits the percentage rather than
inventing a formula (no acdream ExperienceToRaiseLevel table exists
yet). Both StringInfo variable substitution (row/stats/vitals text) and
ACCharGenData::FormatName (create-flow name canonicalization) are
unported prerequisites, so row text renders as plain numeric composites
-- register rows AD-80/AD-81 (docs commit).

D7: un-dims IgnoreFellowshipRequests/FellowshipAutoAcceptRequests
(consumed by the D6 auto-decline/accept) and FellowshipShareXP/
FellowshipShareLoot (consumed by Create + the page's own second
checkbox surface) on the Character tab -- 4 of 35 store-only rows
promoted to Live (31 remain dimmed).

Carry-forwards from the FA3 re-review, folded into this slice's
contract:
- UiTemplateListBox.FlushPreservingScroll -- preserves scroll offset
  across a rebuild instead of resetting to 0 (Flush's existing
  contract, unchanged, for Friends/Squelch).
- RowTemplateResolver -- the FA3 caching row-template resolver
  extracted from a MountSocialPanel local function into its own
  hermetically-testable class; now shared by Friends/Squelch/
  Fellowship's row families.
- Friends/Squelch scrollbars now resolve via the built
  UiTemplateListBox.ScrollbarElementId (DAT property 0x72) instead of
  a hardcoded literal, matching ConfigOptionsPageController's own OP6
  precedent.
- The Fellowship roster path never advances its revision latch on a
  partial resolver failure until the NEXT real membership change --
  never a per-frame retry loop.

Live-DAT verified (ACDREAM_PROBE_LIVE_MOUNT=1, extended
SocialPanelLiveMountProbeTests): the name field builds as UiField, all
11 buttons/checkboxes resolve, the row template's 5 checked fields
resolve to the right widget types, every checkbox label/tooltip and the
Open/Close captions resolve to real retail strings ("Open"/"Close"),
and a full production-path Bind() against live DATs produces zero
"not found" warnings.

App tests: +30 (7 UiTemplateListBox/RowTemplateResolver unit tests, 23
SocialFellowshipPageControllerTests covering roster diff/rebuild,
button enable rules, checkbox wiring, create-flow gating, D4
idempotency, and D5 formatting) plus 2 CharacterOptionsPageController
counts updated for the D7 un-dim (35->31 dimmed, 15->19 live).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 04:40:53 +02:00
parent 357d203202
commit 5bdd0528f1
16 changed files with 1941 additions and 114 deletions

View file

@ -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,

View file

@ -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));
/// <summary><c>0x00A6</c> — D4's panel-open/close declaration (the
/// vitals-stream gate, lane B §4.5). Idempotent at the CALLER
/// (<see cref="AcDream.App.UI.Layout.SocialFellowshipPageController"/>
/// only invokes this on an actual visibility transition); this method
/// itself just forwards, exactly like every sibling command — a call
/// while disconnected returns <see cref="RuntimeCommandStatus.Inactive"/>
/// from <see cref="Invoke"/> without sending anything.</summary>
public RuntimeCommandResult FellowshipSetPanelOpen(bool panelOpen) =>
Invoke((commands, generation) => commands.Fellowship.SetPanelOpen(
generation, panelOpen));
public void Deactivate()
{
lock (_gate)

View file

@ -111,7 +111,7 @@ public static class CharacterOptionsPageController
/// steps 13-18, written after the code landed):
///
/// <list type="bullet">
/// <item><description><b>NOT dimmed (15 rows, real acdream-side
/// <item><description><b>NOT dimmed (19 rows, real acdream-side
/// consumer):</b> Group B rows actually bound —
/// <c>ViewCombatTarget</c>/<c>AutoTarget</c>/<c>AutoRepeatAttack</c>
/// (combat: <c>LiveCombatAttackOperations.cs</c>,
@ -122,10 +122,17 @@ public static class CharacterOptionsPageController
/// <c>DisableDistanceFog</c>/<c>DisplayTimeStamps</c>/<c>ToggleRun</c>
/// (Group B, polled at <c>GameWindow.cs:655-680</c>),
/// <c>DragItemOnPlayerOpensSecureTrade</c> (TS-48,
/// <c>InteractionRetainedUiComposition.cs:326</c>), and all six
/// <c>InteractionRetainedUiComposition.cs:326</c>), all six
/// <c>ListenTo*Chat</c> ids (<c>TurbineChatMembershipGate.cs:105-136</c>
/// gates every Turbine room join on the matching bit).</description></item>
/// <item><description><b>Dimmed (35 rows, store-only):</b> every
/// gates every Turbine room join on the matching bit), and — landed at
/// Campaign FA slice FA4, D7 — <c>IgnoreFellowshipRequests</c>/
/// <c>FellowshipAutoAcceptRequests</c> (consumed by
/// <c>RetailUiRuntime.TryAutoRespondToFellowshipInvite</c>'s auto-
/// decline/auto-accept, D6) and <c>FellowshipShareXP</c>/
/// <c>FellowshipShareLoot</c> (consumed by the fellowship page's own
/// Create flow / D5 display / second checkbox surface,
/// <c>SocialFellowshipPageController</c>).</description></item>
/// <item><description><b>Dimmed (31 rows, store-only):</b> 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" (<c>ShowTooltips</c>,
@ -134,8 +141,10 @@ public static class CharacterOptionsPageController
/// <c>DisableMostWeatherEffects</c>, <c>PersistentAtDay</c>,
/// <c>FilterLanguage</c>, <c>MainPackPreferred</c>), plus every Group D
/// deferral (<c>SalvageMultiple</c>,
/// <c>DisableHouseRestrictionEffects</c>, <c>FellowshipShareXP</c>,
/// <c>HearPkDeathMessages</c>).</description></item>
/// <c>DisableHouseRestrictionEffects</c>,
/// <c>HearPkDeathMessages</c>) — <c>FellowshipShareXP</c> left Group D
/// at FA4 (above), it stays a bullet-list resident here only for the
/// historical count.</description></item>
/// <item><description><b>Flagged ambiguity, resolved by code evidence
/// (see final report, not re-litigated here):</b> the research doc's
/// §7.1 lists <c>AcceptLootPermits</c> 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 (<c>Player_Death.cs:755</c>). Classified Group A / dimmed.
/// <c>IgnoreFellowshipRequests</c>/<c>FellowshipAutoAcceptRequests</c>
/// have a genuine coded mutual-exclusion side effect
/// had a genuine coded mutual-exclusion side effect
/// (<c>RuntimeCharacterState.TrySetOption</c>, 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.</description></item>
/// 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
/// <c>FellowshipShare*</c> rows FA4 also gave real consumers.</description></item>
/// </list>
/// </summary>
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[]
{

View file

@ -0,0 +1,74 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Caching row-template resolver for <see cref="UiTemplateListBox"/>
/// 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 <c>0x64</c> template entries through
/// one instance of this class.
///
/// <para>
/// Originally a private local function inside
/// <see cref="RetailUiRuntime.MountSocialPanel"/> (Campaign FA slice FA3
/// fix-round blast SF-2): the ORIGINAL shape re-ran
/// <c>LayoutImporter.ImportInfos</c> — 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
/// <see cref="ElementInfo"/> tree the FIRST time it is imported and never
/// re-imports for that pair again; <see cref="Resolve"/> still calls
/// <paramref name="build"/> (constructor parameter) on every invocation
/// because each row needs its OWN <see cref="UiElement"/> instance — only
/// the expensive per-row DAT tree WALK is memoized, not the built widget.
/// </para>
///
/// <para>
/// 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
/// <paramref name="importInfos"/>/<paramref name="build"/> 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 <see cref="Resolve"/> at the call site (see
/// <c>RetailUiRuntime.MountSocialPanel</c>) — 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.
/// </para>
/// </summary>
public sealed class RowTemplateResolver
{
private readonly Dictionary<(uint LayoutId, uint ElementId), ElementInfo?> _cache = new();
private readonly Func<uint, uint, ElementInfo?> _importInfos;
private readonly Func<ElementInfo, UiElement?> _build;
/// <summary>Number of times <paramref name="importInfos"/> (the constructor
/// parameter) actually ran — i.e. cache MISSES. Exposed for the
/// production-resolver conformance test; not used by any production
/// code path.</summary>
public int ImportCount { get; private set; }
public RowTemplateResolver(
Func<uint, uint, ElementInfo?> importInfos,
Func<ElementInfo, UiElement?> build)
{
_importInfos = importInfos ?? throw new ArgumentNullException(nameof(importInfos));
_build = build ?? throw new ArgumentNullException(nameof(build));
}
/// <summary>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). <see cref="UiTemplateListBox.TemplateResolver"/>'s exact
/// signature.</summary>
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);
}
}

View file

@ -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;
/// <summary>
/// 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
/// <c>0x00A6</c> 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).
///
/// <para>
/// Retail <c>gmFellowshipUI::Update @0x0048F440</c>
/// (docs/research/2026-08-11-fa-panel-structure.md §4.5):
/// <c>m_pNotInAFellowshipFrame</c> (<c>0x1000026B</c>) shows and
/// <c>m_pInAFellowshipFrame</c> (<c>0x10000275</c>) hides when
/// <c>m_pFellowship == null</c>, and vice-versa. Both are AUTHORED sibling
/// containers of the fellowship page (<c>0x10000292</c>) — confirmed by the
/// FA3 live-mount probe: <c>0x1000026B</c> holds the name-entry box, Create
/// button, and all FOUR visible option checkboxes (Ignore Fellowship
/// Requests / Auto-Accept Requests / Share XP / Share Loot —
/// <c>0x10000270</c><c>0x10000273</c>, fix-round mechanism SF-4); <c>0x10000275</c> holds
/// the roster list and all six member-management buttons. A single
/// <see cref="UiElement.Visible"/> toggle on each container is therefore the
/// WHOLE empty-state swap; no per-child hiding is needed (closes lane-A
/// unknown U6).
/// <b>Roster rebuild discipline — FA3 carry-forward 1.</b>
/// <see cref="RuntimeFellowshipState"/> bumps ONE monotonic revision for
/// every mutation, including a pure vitals refresh
/// (<c>0x02C0</c> with the same member set — lane B §2.6/§4.5, the
/// <c>0x00A6</c>-gated stream this same slice turns on). A naive
/// "revision changed → <c>Flush</c> + 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 (<see cref="UiTemplateListBox.Flush"/>
/// zeroes the scroll offset) and re-resolving nine DAT row templates for no
/// structural reason. <see cref="RefreshRoster"/> instead diffs the member
/// GUID SET: when it is unchanged, existing rows are updated IN PLACE
/// (<see cref="UpdateRow"/> — no ListBox mutation at all, so scroll position
/// is untouched by construction); only an actual join/leave/disband
/// difference triggers <see cref="UiTemplateListBox.FlushPreservingScroll"/>
/// (the widget-level half of the same carry-forward, for the rarer
/// structural-change case).
/// </para>
///
/// <para>
/// <b>Row-text StringInfo gap (register row filed this slice).</b> Every
/// row field beyond the bare name is a retail <c>StringInfo</c> template
/// with variable substitution (<c>ID_Fellowship_FellowStats</c> + vars
/// <c>ID_Level</c>/<c>ID_Experience</c>; <c>…FellowHealthStatus</c>/
/// <c>…StaminaStatus</c>/<c>…ManaStatus</c> + vars <c>ID_Cur</c>/<c>ID_Max</c>
/// — docs/research/2026-08-11-fa-panel-structure.md §3.1/§4.1). acdream has
/// not ported retail's variable-substitution engine
/// (<c>StringInfo::InqString</c> → <c>StringTableMetaLanguage::UnescapeString</c>
/// — a cross-cutting UI-string prerequisite, not fellowship-specific) so
/// this controller renders the numeric CONTENT of those fields as plain
/// composites (<c>"{level} {pct}%"</c>, <c>"{cur}/{max}"</c>) 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").
/// </para>
///
/// <para>
/// <b>Leader marker.</b> 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
/// (<see cref="LeaderNameColor"/>) as a minimal, clearly-adaptive visual
/// cue rather than inventing a DAT mechanism that was not found.
/// </para>
/// </summary>
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_<Name> 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;
/// <summary>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.</summary>
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];
/// <summary><c>Fellowship::IsFull @0x005B9070</c>, byte-verified
/// <c>_currNum &gt;= 9</c> (lane B §7.1).</summary>
private const int MaxFellowshipSize = 9;
private static readonly Vector4 LeaderNameColor = new(1f, 0.84f, 0f, 1f);
private static readonly Vector4 MemberNameColor = Vector4.One;
/// <summary>The live read/write seam this page binds to — Runtime's
/// FA2 fellowship owner (snapshot + member enumeration), the seven
/// generation-gated commands routed through
/// <see cref="AcDream.App.Composition.DeferredGameRuntimeStateCommands"/>,
/// 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
/// <c>CharacterOptionsPageController</c>'s Character tab uses — both
/// surfaces are live views of one value, lane A §6.5), and a DAT string
/// resolver.</summary>
public sealed record Bindings(
Func<RuntimeFellowshipSnapshot> Snapshot,
Func<IEnumerable<RuntimeFellowMemberSnapshot>> Members,
Func<uint, uint, UiElement?> TemplateResolver,
Func<string, bool, RuntimeCommandResult> Create,
Func<uint, RuntimeCommandResult> Recruit,
Func<uint, RuntimeCommandResult> Dismiss,
Func<bool, RuntimeCommandResult> Quit,
Func<uint, RuntimeCommandResult> AssignLeader,
Func<bool, RuntimeCommandResult> SetOpen,
Func<bool, RuntimeCommandResult> SetPanelOpen,
SelectionState Selection,
Func<uint> LocalPlayerGuid,
Func<CharacterOptionId, bool> CurrentCharacterOption,
Action<CharacterOptionId, bool> SetCharacterOption,
Func<uint, uint, string?> 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<RuntimeFellowshipSnapshot> _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<uint, FellowRowWidgets> _rows = new();
private uint _selectedFellowGuid;
private long _lastRosterRevision = long.MinValue;
private bool? _lastOpenState;
private bool _pageVisible;
/// <summary>Resolved ONCE at <see cref="Bind"/> (not per-tick — every
/// other <c>DatStringResolver</c> consumer in this codebase is
/// bind-time-only; <c>DatCollection</c> 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.</summary>
private readonly string? _openCaption;
private readonly string? _closeCaption;
private SocialFellowshipPageController(
UiElement notInFellowshipFrame,
UiElement inFellowshipFrame,
Func<RuntimeFellowshipSnapshot> 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<RuntimeFellowshipSnapshot> 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);
};
}
/// <summary>
/// Re-reads the live snapshot and applies retail's frame swap. Cheap (two
/// bool writes) — called every frame from
/// <see cref="SocialPanelController.Tick"/> rather than gated on
/// page-shown/revision, since <see cref="UiElement.Visible"/> has no
/// data-driven provider mechanism of its own (unlike <see cref="UiText.LinesProvider"/>,
/// 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
/// <see cref="SocialPanelController.Tick"/>, 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).
/// </summary>
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);
}
/// <summary>
/// D4 — the panel-open declaration (<c>0x00A6</c>) 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
/// <see cref="SocialPanelController"/> 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
/// <see cref="RuntimeCommandStatus.Inactive"/> without sending).
/// </summary>
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);
}
}
/// <summary>
/// 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
/// <see cref="UiTemplateListBox.FlushPreservingScroll"/> (carry-forward 1).
/// </summary>
private void RefreshRoster(RuntimeFellowshipSnapshot snapshot)
{
if (_listBox is null) return;
var members = new List<RuntimeFellowMemberSnapshot>(_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<RuntimeFellowMemberSnapshot> 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);
}
/// <summary>
/// D5 — <c>gmFellowshipUI::UpdateFellowStats @0x0048EB20</c> (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:
/// <see cref="EvenSplitPercentTable"/> when even-split, <c>0%</c> when
/// <see cref="RuntimeFellowshipSnapshot.ShareXp"/> is off (retail's own
/// literal <c>pct = 0.0f</c> 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.
/// </summary>
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}";
}
/// <summary>
/// Retail list-selection message (<c>3</c>/<c>0x42</c>,
/// <c>gmFellowshipUI::ListenToElementMessage @0x004901C0</c>, lane B
/// §2.8): reads the clicked row's identity into
/// <c>m_iidSelectedFellow</c> AND calls
/// <c>ACCWeenieObject::SetSelectedObject</c> — 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.
/// </summary>
private void SelectFellow(uint guid)
{
_selectedFellowGuid = guid;
_bindings.Selection.Select(guid, SelectionChangeSource.Social);
}
}

View file

@ -36,15 +36,20 @@ namespace AcDream.App.UI.Layout;
/// </para>
///
/// <para>
/// <b>Scrollbar (fix-round blast MF-1).</b> The ListBox <c>0x10000517</c>
/// authors a direct sibling scrollbar, <c>0x10000518</c> — wired to
/// <see cref="UiTemplateListBox.Scroll"/> the SAME way every other
/// <c>UiTemplateListBox</c> consumer wires its own scrollbar
/// (<c>CharacterOptionsPageController</c>/<c>ChatOptionsPageController</c>/
/// <c>ConfigOptionsPageController</c>/<c>KeyboardConfigController</c>).
/// Without it the list has NO wheel fallback (<see cref="UiScrollablePanel"/>
/// has no wheel handler) and is completely unreachable past the box's
/// visible extent.
/// <b>Scrollbar (fix-round blast MF-1; FA4 carry-forward 3).</b> The
/// ListBox <c>0x10000517</c> authors a direct sibling scrollbar,
/// <c>0x10000518</c> — wired to <see cref="UiTemplateListBox.Scroll"/> the
/// SAME way every other <c>UiTemplateListBox</c> consumer wires its own
/// scrollbar. Resolved via the built <see cref="UiTemplateListBox.ScrollbarElementId"/>
/// (dat property <c>0x72</c>, populated by <see cref="ElementReader"/>)
/// rather than the hardcoded literal a prior revision of this file used —
/// same "prefer the DAT field over a duplicated constant" fix
/// <c>ConfigOptionsPageController.Bind</c> already applied (OP6 review N2,
/// <c>feedback_prefer_dat_field_over_geometry</c>), 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
/// (<see cref="UiScrollablePanel"/> has no wheel handler) and is completely
/// unreachable past the box's visible extent.
/// </para>
///
/// <para>
@ -64,9 +69,6 @@ public sealed class SocialFriendsPageController
{
private const uint ListBoxId = 0x10000517u;
/// <summary>The ListBox's own linked scrollbar (see class doc).</summary>
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);

View file

@ -86,10 +86,15 @@ public sealed class SocialPanelController : IRetainedPanelController
private const uint CloseButtonId = 0x10000290u;
/// <summary>Callback delegates + live-state accessors this controller
/// wires the four pages and the close button to.</summary>
/// wires the four pages and the close button to. Campaign FA slice FA4:
/// <see cref="Fellowship"/> replaces the old bare
/// <c>FellowshipSnapshot</c> field with the page's full read/write seam
/// (roster enumeration, the seven commands, selection, character
/// options, string resolution — see
/// <see cref="SocialFellowshipPageController.Bindings"/>'s own doc).</summary>
public sealed record Callbacks(
Action Toggle,
Func<RuntimeFellowshipSnapshot> FellowshipSnapshot,
SocialFellowshipPageController.Bindings Fellowship,
Func<RuntimeAllegianceSnapshot> 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();
}
/// <summary>
@ -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 <see cref="Tick"/> naturally picks up any revision bump that
/// accumulated while hidden.</summary>
public void OnShown() => _visible = true;
/// accumulated while hidden. Also drives D4 (see the constructor's own
/// doc on <see cref="UiTabPanel.ActivePageChanged"/>).</summary>
public void OnShown()
{
_visible = true;
UpdateFellowshipPageVisibility();
}
/// <summary><see cref="IRetainedPanelController"/> hook, fired on every
/// shown-to-hidden transition (fix-round blast SF-2).</summary>
public void OnHidden() => _visible = false;
/// shown-to-hidden transition (fix-round blast SF-2). Also drives D4.</summary>
public void OnHidden()
{
_visible = false;
UpdateFellowshipPageVisibility();
}
/// <summary>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.</summary>
private void UpdateFellowshipPageVisibility() =>
_fellowship?.SetPageVisible(_visible && IsShowingFellowship);
/// <summary>
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no
@ -247,11 +278,16 @@ public sealed class SocialPanelController : IRetainedPanelController
/// <b>Fix-round blast SF-2.</b> The Friends/Squelch rebuild
/// (<see cref="SocialFriendsPageController.Tick"/>/
/// <see cref="SocialSquelchPageController.Tick"/>) takes the shared DAT
/// lock per row template resolve — it only runs while <see cref="_visible"/>
/// is true. Fellowship/Allegiance stay unconditional: their own
/// <c>Tick()</c> 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 <see cref="_visible"/> is true. Fellowship/Allegiance stay
/// unconditional: Allegiance's own <c>Tick()</c> 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.
/// </para>
/// </summary>
public void Tick()

View file

@ -31,12 +31,14 @@ namespace AcDream.App.UI.Layout;
/// </para>
///
/// <para>
/// <b>Scrollbar (fix-round blast MF-1).</b> The ListBox <c>0x1000053E</c>
/// authors a direct sibling scrollbar, <c>0x10000543</c> — wired to
/// <b>Scrollbar (fix-round blast MF-1; FA4 carry-forward 3).</b> The
/// ListBox <c>0x1000053E</c> authors a direct sibling scrollbar,
/// <c>0x10000543</c> — resolved via the built
/// <see cref="UiTemplateListBox.ScrollbarElementId"/> and wired to
/// <see cref="UiTemplateListBox.Scroll"/> the same way
/// <see cref="SocialFriendsPageController"/> 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).
/// </para>
///
/// <para>
@ -52,9 +54,6 @@ public sealed class SocialSquelchPageController
{
private const uint ListBoxId = 0x1000053Eu;
/// <summary>The ListBox's own linked scrollbar (see class doc).</summary>
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);

View file

@ -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
/// <see cref="AcDream.App.Composition.InteractionRetainedUiDependencies.Communication"/>
/// already exposes for every other Communication-owned consumer).
///
/// <para>
/// Campaign FA slice FA4 adds the Fellowship page's write surface:
/// <see cref="FellowshipMembers"/> (the roster <see cref="IRuntimeFellowshipView"/>
/// alone cannot enumerate — see that interface's own FA4 addendum) and the
/// seven generation-gated commands, all routed through
/// <see cref="AcDream.App.Composition.DeferredGameRuntimeStateCommands"/>
/// (the SAME late-bound, race-safe seam <c>AddShortcut</c>/<c>Advance</c>
/// already use) rather than a raw <c>WorldSession.SendXxx</c> call —
/// <c>FellowshipQuit</c> in particular MUST go through
/// <c>IRuntimeFellowshipCommands.Quit</c> because the retail leader
/// hand-off rule (<c>0x0290</c> before <c>0x00A3</c>) lives in
/// <c>RuntimeFellowshipState.RequiresLeaderHandoffBeforeQuit</c> +
/// <c>DirectGameRuntimeCommandAdapter.Quit</c>/
/// <c>CurrentGameRuntimeCommandAdapter.Quit</c>, not in
/// <c>WorldSession</c> itself — bypassing the command layer would silently
/// drop that behavior. <see cref="Selection"/> is the Recruit target source
/// (retail's <c>ACCWeenieObject::selectedID</c> — lane B §2.3, the
/// currently-selected WORLD object, not a panel-local selection).
/// </para>
/// </summary>
public sealed record SocialRuntimeBindings(
Func<AcDream.Runtime.RuntimeFellowshipSnapshot> FellowshipSnapshot,
Func<AcDream.Runtime.RuntimeAllegianceSnapshot> AllegianceSnapshot,
AcDream.Core.Social.FriendsState Friends,
AcDream.Core.Social.SquelchState Squelch);
AcDream.Core.Social.SquelchState Squelch,
Func<IEnumerable<AcDream.Runtime.RuntimeFellowMemberSnapshot>> FellowshipMembers,
Func<string, bool, RuntimeCommandResult> FellowshipCreate,
Func<uint, RuntimeCommandResult> FellowshipRecruit,
Func<uint, RuntimeCommandResult> FellowshipDismiss,
Func<bool, RuntimeCommandResult> FellowshipQuit,
Func<uint, RuntimeCommandResult> FellowshipAssignLeader,
Func<bool, RuntimeCommandResult> FellowshipSetOpen,
Func<bool, RuntimeCommandResult> FellowshipSetPanelOpen,
SelectionState Selection,
Func<uint> LocalPlayerGuid);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
@ -631,8 +662,35 @@ public sealed class RetailUiRuntime : IDisposable
}
}
/// <summary>
/// Campaign FA slice FA4, D6: a fellowship invite (<c>ConfirmationType.
/// Fellowship</c>, 4) is intercepted BEFORE it ever reaches the generic
/// <see cref="GameplayConfirmationController"/> dialog — retail's
/// <c>Fellowship.cs:121</c>-equivalent client-side mirror:
/// <c>IgnoreFellowshipRequests</c> auto-declines, <c>FellowshipAutoAcceptRequests</c>
/// auto-accepts, and the two are mutually exclusive
/// (<c>RuntimeCharacterState.TrySetOption</c>, 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).
/// </summary>
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.");

View file

@ -266,4 +266,35 @@ public sealed class UiTemplateListBox : UiDatElement
/// </para>
/// </summary>
public void Flush() => _viewport?.ClearContent();
/// <summary>
/// FA3 carry-forward 1 (folded into Campaign FA slice FA4): like
/// <see cref="Flush"/>, but preserves the CURRENT scroll offset instead
/// of resetting it to 0. <see cref="Flush"/>'s <c>ClearContent</c> zeroes
/// <see cref="UiScrollablePanel.Scroll"/>'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 <c>0x02C0</c> would fight the user's own
/// scrolling the instant they touch the fellowship list.
/// <see cref="SocialFellowshipPageController"/> 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;
/// <see cref="UiScrollablePanel.LayoutScrollableChildren"/> 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 <see cref="UiScrollablePanel.RecomputeContentHeight"/>
/// already relies on for every ordinary <see cref="AddItemFromTemplateList"/>
/// call. A no-op (same as <see cref="Flush"/>) while dormant.
/// </summary>
public void FlushPreservingScroll()
{
if (_viewport is null) return;
int savedScrollY = _viewport.Scroll.ScrollY;
_viewport.ClearContent();
_viewport.Scroll.SetScrollY(savedScrollY);
}
}