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 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).
///
///
/// 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. The earlier gold leader tint and blue
/// selection tint this controller invented for that gap were RETIRED at the
/// 2026-08-13 gate by user direction: names render white, always
/// (), and selection feedback is the in-game
/// selection itself — register row AD-82 updated accordingly.
///
///
///
/// World→panel selection (fix-round MUST-FIX 4).
/// gmFellowshipUI::UpdateFellowSelection @0x0048F0F0 is retail's
/// REVERSE arm of 's panel-to-world direction:
/// selecting a fellow in the 3D world (or by any other selection origin)
/// selects their panel row too, enabling Dismiss/Assign-Leader. Retail
/// keys row identity via a generic ListBox primitive
/// (SetAttribute_InstanceID(row, 0x1000000D, fellowIid) +
/// UIElement_ListBox::SetSelectedItem) that acdream's
/// does not port (register row AD-82 —
/// "no Flush, no selection model, no per-row instance-id",
/// docs/research/2026-08-11-fa-panel-structure.md §6.6).
/// reproduces the OBSERVABLE behavior — button-enable + a visual row
/// highlight — against this controller's OWN guid-keyed row dictionary
/// () via instead of
/// a generic ListBox selection model; see AD-82 for what's deferred.
///
///
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) ──
/// Gate round 2 (2026-08-13): the row's name BAND — probe-verified
/// to author the retail selected-row art: DirectState media
/// 0x06001450 + a 'Highlight' state with the amber
/// 0x06001451 (ProbeSocialClickRouting's ROWSTATE dump). Selection
/// flips its — the authored
/// mechanism, replacing the retired invented tints.
private const uint RowNameBandId = 0x10000282u;
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;
/// 2026-08-13 gate (user-directed): fellow names are WHITE,
/// always — the earlier AD-82 leader-gold and selection-blue tints are
/// retired (no decompiled anchor ever existed for either); the in-game
/// selection ring is the selection feedback.
private static readonly Vector4 MemberNameColor = Vector4.One;
/// Cached per — only
/// reassigned when the fellowship name actually changes (fix-round SF-1,
/// the same zero-allocation-while-idle discipline
/// SocialAllegiancePageController's BlankLineProvider/
/// NoLinesProvider statics established for the sibling page).
private string? _lastFellowshipName;
private Func>? _fellowshipNameLinesProvider;
/// 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(
UiDatElement? NameBand,
UiText? Name,
UiText? Stats,
UiMeter? Health,
UiMeter? Stamina,
UiMeter? Mana);
private readonly UiElement _notInFellowshipFrame;
private readonly UiElement _inFellowshipFrame;
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();
/// Fix-round SF-2/SF-3: the TRUE member-guid set as of the last
/// rebuild attempt, independent of which rows actually finished
/// building. alone under-counts when a row template
/// permanently fails to build ('s own
/// continue) — comparing against .Count would
/// make 's membership-changed check true
/// FOREVER for that guid (every vitals tick re-attempts a full
/// DAT-locked
/// rebuild instead of only on a real join/leave/disband), and would let
/// Recruit's "already a fellow" check
/// () light up for someone who IS
/// already a fellow but whose row failed to build. This set is the
/// single source of truth for both.
private readonly HashSet _memberGuids = 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,
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;
_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, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(pageRoot);
ArgumentNullException.ThrowIfNull(bindings);
if (UiElement.FindDescendant(pageRoot, 0x1000026Bu) is not { } notIn
|| UiElement.FindDescendant(pageRoot, 0x10000275u) is not { } inFellowship)
{
Console.WriteLine(
"[D.2b] SocialFellowshipPageController: empty/full frame pair "
+ "(0x1000026B/0x10000275) not found — fellowship page will not "
+ "swap its empty state.");
return null;
}
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();
bool newOpenState = !snapshot.IsOpen;
_bindings.SetOpen(newOpenState);
// N-0 (fix-round NIT): lane B feature 11 — retail's Open
// button handler pre-toggles its own _open_fellow BEFORE
// Event_ChangeFellowOpeness, so the caption flips
// immediately rather than waiting for the 0x02BE echo.
// RefreshOpenCaption's own _lastOpenState latch means this
// optimistic set is a no-op (already matches) once the real
// snapshot catches up, and is silently corrected on the
// next Tick if the server ever disagrees.
_lastOpenState = newOpenState;
string? label = newOpenState ? _closeCaption : _openCaption;
if (label is not null)
_openButton.Label = label;
};
}
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;
// 2026-08-13 gate fix ("I can't change any options"): these authored
// checkboxes carry DAT ToggleBehavior, so the button SELF-FLIPS
// Selected at MouseUp — the old `!checkbox.Selected` here then read
// the ALREADY-FLIPPED value and wrote the ORIGINAL back, visually
// snapping every click to where it started (probe-proven:
// ProbeSocialClickRouting recorded (id, oldValue)). Same CH6a/b
// mirror discipline as the chat indicators: suppress the blind
// self-flip, derive the next value from the STORE, and let the
// per-tick seeding mirror the outcome back onto Selected.
checkbox.SuppressSelfToggle = true;
checkbox.OnClick = () =>
{
bool next = !_bindings.CurrentCharacterOption(id);
_bindings.SetCharacterOption(id, next);
};
}
///
/// 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()
{
// N-3 (fix-round NIT): Snapshot() and Members() below each take
// RuntimeFellowshipState._gate separately, so Revision/MemberCount
// can momentarily disagree with the roster RefreshRoster reads a
// few lines later. Self-correcting on the next revision bump
// (single-threaded tick today) — not assumed atomic.
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();
}
_memberGuids.Clear();
_selectedFellowGuid = 0u;
_lastRosterRevision = long.MinValue;
_lastOpenState = null;
_lastFellowshipName = null;
_fellowshipNameLinesProvider = null;
return;
}
RefreshFellowshipName(snapshot.Name);
if (snapshot.Revision != _lastRosterRevision)
{
_lastRosterRevision = snapshot.Revision;
RefreshRoster(snapshot);
}
// MUST-FIX 4 (fix round): retail's UpdateFellowSelection runs from
// Update EVERY frame (not just on a selection-changed notice) —
// this call site mirrors that placement. See the class doc's
// world->panel selection note.
SyncSelectionFromWorld();
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;
// MUST-FIX 3 re-fix (FA4 re-review REOPEN, 2026-08-12): advance the
// edge-trigger latch ONLY when the 0x00A6 declaration was actually
// published (Accepted). A generation reset runs BEFORE the
// reconnected session is in world, so this command is world-gated and
// returns Inactive there — publishing nothing. The original fix
// latched unconditionally, leaving `_pageVisible = true` while the
// fresh server was never told, so the intended post-world
// re-declaration became a no-op (already latched) and fellow vitals
// stayed frozen. Leaving the latch untouched on a dropped publish
// lets RedeclareAfterWorldEntry retry exactly once, in world.
if (_bindings.SetPanelOpen(visible).Status == RuntimeCommandStatus.Accepted)
_pageVisible = visible;
}
///
/// MUST-FIX 3 (FA4 fix round) — clears the edge-trigger latch WITHOUT
/// sending anything, so the next call
/// re-declares 0x00A6 even if the caller's own visibility
/// hasn't changed from ITS perspective. Called by
/// on every
/// generation reset — the page itself is process-lifetime and survives
/// a reconnect, but the fresh session's server has no memory of the
/// previous session's 0x00A6 declaration.
///
public void ResetPageVisibleLatch() => _pageVisible = false;
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);
}
/// Fix-round SF-1: reassigns
/// (a display-class + delegate allocation) only when the name actually
/// changed, instead of on every — this method used to
/// allocate once per frame even while the panel is hidden, exactly the
/// bug 35c40a9b fixed on the sibling
/// SocialAllegiancePageController.
private void RefreshFellowshipName(string name)
{
if (_fellowshipNameText is null) return;
if (_fellowshipNameLinesProvider is not null && _lastFellowshipName == name) return;
_lastFellowshipName = name;
_fellowshipNameLinesProvider = () => [new UiText.Line(name, MemberNameColor)];
_fellowshipNameText.LinesProvider = _fellowshipNameLinesProvider;
}
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;
// Fix-round SF-3: reads TRUE membership (_memberGuids), not
// which rows happen to have finished building (_rows) — a row
// that failed to build for a permanently-unresolvable template
// must not let Recruit re-invite someone who IS already a
// fellow.
bool targetValid = targetGuid is { } id && id != selfGuid && !_memberGuids.Contains(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 ( — fix-round
/// SF-2, NOT .Count, see that field's own doc) against
/// the current roster. 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 != _memberGuids.Count;
if (!membershipChanged)
{
foreach (RuntimeFellowMemberSnapshot member in members)
{
if (_memberGuids.Contains(member.Guid)) continue;
membershipChanged = true;
break;
}
}
if (membershipChanged)
RebuildRoster(members, snapshot);
else
foreach (RuntimeFellowMemberSnapshot member in members)
UpdateRow(member, snapshot);
if (_selectedFellowGuid != 0u && !_memberGuids.Contains(_selectedFellowGuid))
_selectedFellowGuid = 0u;
}
private void RebuildRoster(List members, RuntimeFellowshipSnapshot snapshot)
{
_listBox!.FlushPreservingScroll();
_rows.Clear();
// Fix-round SF-2: record the FULL attempted member set up front,
// regardless of which rows below actually finish building — a
// permanently-unbuildable template is attempted once per real
// membership change, not once per vitals tick forever.
_memberGuids.Clear();
foreach (RuntimeFellowMemberSnapshot member in members)
_memberGuids.Add(member.Guid);
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, RowNameBandId) as UiDatElement,
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);
}
// 2026-08-13 gate fix ("I only get the move window cursor"):
// give the STATS text the same click target so most of the row's
// width selects the fellow (UiText.OnClick now clears the
// display-text ClickThrough default — see UiText.OnClick's doc).
if (widgets.Stats is { } statsText)
{
uint guid = member.Guid;
statsText.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;
// 2026-08-13 gate (user-directed): fellow names render WHITE,
// always — the AD-82 invented leader/selection tints are retired;
// selection feedback is the in-game selection itself
// (SelectFellow drives the world selection ring).
nameText.LinesProvider = () => [new UiText.Line(name, MemberNameColor)];
}
// Gate round 2: the AUTHORED selected-row cue — the name band's
// 'Highlight' state is retail's amber (see RowNameBandId's doc).
if (widgets.NameBand is { } nameBand)
nameBand.ActiveState = _selectedFellowGuid == member.Guid ? "Highlight" : "";
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.
///
///
/// MUST-FIX 1 (fix round) — retail TRUNCATES, it does not round.
/// gmFellowshipUI::UpdateFellowStats @0x0048ECC9 forms
/// pct * 100.0f on the x87 stack (exact — both operands are
/// floats) then calls _ftol2 @0x005DE394 (MSVC's helper that
/// turns the FPU's round-to-nearest fistp into C's
/// truncate-toward-zero), never MathF.Round. This matters ONLY
/// at 6 and 8 fellows: the stored constants are
/// 0.44999998807907104 and 0.3499999940395355 (byte-read
/// from the PDB-paired binary), so retail's own products are
/// 44.999998… / 34.999999… and truncate to 44 /
/// 34 — a naive (int)MathF.Round(pct * 100f) (or even
/// (int)(pct * 100f), since 0.45f * 100f already rounds UP
/// to exactly 45.0f in SINGLE precision) yields the wrong 45/35.
/// Forming the product in double before truncating reproduces
/// retail's wider-than-single-precision x87 product exactly.
///
///
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)((double)pct * 100.0)}%";
}
return member.Level.ToString();
}
private static float EvenSplitPercent(int memberCount) =>
memberCount is >= 1 and <= 10 ? EvenSplitPercentTable[memberCount - 1] : 0f;
///
/// N-1 (fix-round NIT): retail's UpdateFellowVitals @0x0048ED60
/// sets meter attribute 0x69 to cur/max AND writes the
/// cur/max ints into the meter's authored CHILD text elements
/// (0x10000286 under health, 0x10000288 under stamina) —
/// acdream instead uses /
/// because
/// is true (a pre-existing widget decision, not this slice's). Noted
/// here so a future reader does not go hunting for the unbound
/// 0x10000286/0x10000288/0x1000028A children.
/// N-2: the max > 0 guard below is an acdream-side engineering
/// call (retail divides unconditionally) — the right one, since a
/// pre-population zero-max member should not render a divide-by-zero
/// fill, but not itself a "verbatim port" claim.
///
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 (register row AD-82 — retail selects on the whole
/// row, not just its name text).
///
private void SelectFellow(uint guid)
{
SetSelectedFellow(guid);
_bindings.Selection.Select(guid, SelectionChangeSource.Social);
}
///
/// MUST-FIX 4 (fix round) — the world→panel arm of retail's
/// two-directional selection coupling (see the class doc). Retail's
/// UpdateFellowSelection loop has two arms: a world selection
/// that matches a fellow row becomes the panel selection (this
/// method's job — the "found" arm); otherwise the CURRENT panel
/// selection is kept as a fallback as long as that fellow is still on
/// the roster (retail's "remember row as the fallback" arm — no code
/// needed here, since this method simply does nothing when the world
/// selection doesn't match a member, leaving
/// untouched), and already clears the
/// selection when its fellow leaves the roster (retail's
/// fallback-is-null case).
///
private void SyncSelectionFromWorld()
{
if (_bindings.Selection.SelectedObjectId is { } id && _memberGuids.Contains(id))
SetSelectedFellow(id);
}
/// Sets the panel's own selection (idempotent) and immediately
/// refreshes every row's visual selection cue — selection changes are
/// rare (a click, or a world-selection change), so re-running
/// for the bounded (≤9) roster on a change
/// is cheap and keeps the highlight in sync without waiting for the
/// next vitals-driven revision bump.
private void SetSelectedFellow(uint guid)
{
if (_selectedFellowGuid == guid) return;
_selectedFellowGuid = guid;
if (_rows.Count == 0) return;
RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot();
foreach (RuntimeFellowMemberSnapshot member in _bindings.Members())
UpdateRow(member, snapshot);
}
}