acdream/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs
Erik fc62cb6397 fix: social gate round 2, part 1 - border-only move cursor, literal-\n
empty state, retail amber row selection

- Move cursor (user-directed, ALL windows): HoverWindowMove now
  advertises only where the window frame element itself wins the
  hit-test - its border pixels; interior points resolve to content
  children. Matches retail's Dragbar-chrome-only move cursor.
  Whole-surface dragging still works, it just does not advertise.
- Empty-state text (round 2): the DAT stores the LITERAL two-character
  escape backslash-n (probe-verified - the dump printed the escape, not
  line breaks), so the round-1 newline split never matched. Escapes are
  normalized before splitting in DatWidgetFactory authored text.
- Selected fellow amber (user: "check retail"): probe-verified - the
  row name band 0x10000282 AUTHORS the retail selected-row art
  (DirectState 0x06001450 + Highlight 0x06001451, the amber). Selection
  flips the band's ActiveState to Highlight; no invented tint.

App suite 4,976/3 skips. Confirmation-text + refused-drop-notification
research (the round's items 4-5) lands as part 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 20:36:24 +02:00

908 lines
44 KiB
C#

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 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>
/// <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. 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
/// (<see cref="MemberNameColor"/>), and selection feedback is the in-game
/// selection itself — register row AD-82 updated accordingly.
/// </para>
///
/// <para>
/// <b>World&#8594;panel selection (fix-round MUST-FIX 4).</b>
/// <c>gmFellowshipUI::UpdateFellowSelection @0x0048F0F0</c> is retail's
/// REVERSE arm of <see cref="SelectFellow"/>'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
/// (<c>SetAttribute_InstanceID(row, 0x1000000D, fellowIid)</c> +
/// <c>UIElement_ListBox::SetSelectedItem</c>) that acdream's
/// <see cref="UiTemplateListBox"/> 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). <see cref="Tick"/>
/// reproduces the OBSERVABLE behavior — button-enable + a visual row
/// highlight — against this controller's OWN guid-keyed row dictionary
/// (<see cref="_rows"/>) via <see cref="SyncSelectionFromWorld"/> instead of
/// a generic ListBox selection model; see AD-82 for what's deferred.
/// </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) ──
/// <summary>Gate round 2 (2026-08-13): the row's name BAND — probe-verified
/// to author the retail selected-row art: DirectState media
/// <c>0x06001450</c> + a <c>'Highlight'</c> state with the amber
/// <c>0x06001451</c> (ProbeSocialClickRouting's ROWSTATE dump). Selection
/// flips its <see cref="UiDatElement.ActiveState"/> — the authored
/// mechanism, replacing the retired invented tints.</summary>
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_<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;
/// <summary>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.</summary>
private static readonly Vector4 MemberNameColor = Vector4.One;
/// <summary>Cached per <see cref="RefreshFellowshipName"/> — only
/// reassigned when the fellowship name actually changes (fix-round SF-1,
/// the same zero-allocation-while-idle discipline
/// <c>SocialAllegiancePageController</c>'s <c>BlankLineProvider</c>/
/// <c>NoLinesProvider</c> statics established for the sibling page).</summary>
private string? _lastFellowshipName;
private Func<IReadOnlyList<UiText.Line>>? _fellowshipNameLinesProvider;
/// <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(
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<uint, FellowRowWidgets> _rows = new();
/// <summary>Fix-round SF-2/SF-3: the TRUE member-guid set as of the last
/// rebuild attempt, independent of which rows actually finished
/// building. <see cref="_rows"/> alone under-counts when a row template
/// permanently fails to build (<see cref="RebuildRoster"/>'s own
/// <c>continue</c>) — comparing against <see cref="_rows"/>.Count would
/// make <see cref="RefreshRoster"/>'s membership-changed check true
/// FOREVER for that guid (every vitals tick re-attempts a full
/// DAT-locked <see cref="UiTemplateListBox.FlushPreservingScroll"/>
/// rebuild instead of only on a real join/leave/disband), and would let
/// Recruit's "already a fellow" check
/// (<see cref="RefreshButtonStates"/>) 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.</summary>
private readonly HashSet<uint> _memberGuids = 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,
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);
};
}
/// <summary>
/// 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()
{
// 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);
}
/// <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;
// 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;
}
/// <summary>
/// MUST-FIX 3 (FA4 fix round) — clears the edge-trigger latch WITHOUT
/// sending anything, so the next <see cref="SetPageVisible"/> call
/// re-declares <c>0x00A6</c> even if the caller's own visibility
/// hasn't changed from ITS perspective. Called by
/// <see cref="SocialPanelController.ResetSessionDeclaration"/> 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 <c>0x00A6</c> declaration.
/// </summary>
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);
}
/// <summary>Fix-round SF-1: reassigns <see cref="UiText.LinesProvider"/>
/// (a display-class + delegate allocation) only when the name actually
/// changed, instead of on every <see cref="Tick"/> — this method used to
/// allocate once per frame even while the panel is hidden, exactly the
/// bug <c>35c40a9b</c> fixed on the sibling
/// <c>SocialAllegiancePageController</c>.</summary>
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);
}
}
/// <summary>
/// Diffs the live member GUID set (<see cref="_memberGuids"/> — fix-round
/// SF-2, NOT <see cref="_rows"/>.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 <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 != _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<RuntimeFellowMemberSnapshot> 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);
}
/// <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.
///
/// <para>
/// <b>MUST-FIX 1 (fix round) — retail TRUNCATES, it does not round.</b>
/// <c>gmFellowshipUI::UpdateFellowStats @0x0048ECC9</c> forms
/// <c>pct * 100.0f</c> on the x87 stack (exact — both operands are
/// floats) then calls <c>_ftol2 @0x005DE394</c> (MSVC's helper that
/// turns the FPU's round-to-nearest <c>fistp</c> into C's
/// truncate-toward-zero), never <c>MathF.Round</c>. This matters ONLY
/// at 6 and 8 fellows: the stored constants are
/// <c>0.44999998807907104</c> and <c>0.3499999940395355</c> (byte-read
/// from the PDB-paired binary), so retail's own products are
/// <c>44.999998…</c> / <c>34.999999…</c> and truncate to <b>44</b> /
/// <b>34</b> — a naive <c>(int)MathF.Round(pct * 100f)</c> (or even
/// <c>(int)(pct * 100f)</c>, since <c>0.45f * 100f</c> already rounds UP
/// to exactly <c>45.0f</c> in SINGLE precision) yields the wrong 45/35.
/// Forming the product in <c>double</c> before truncating reproduces
/// retail's wider-than-single-precision x87 product exactly.
/// </para>
/// </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)((double)pct * 100.0)}%";
}
return member.Level.ToString();
}
private static float EvenSplitPercent(int memberCount) =>
memberCount is >= 1 and <= 10 ? EvenSplitPercentTable[memberCount - 1] : 0f;
/// <summary>
/// N-1 (fix-round NIT): retail's <c>UpdateFellowVitals @0x0048ED60</c>
/// sets meter attribute <c>0x69</c> to <c>cur/max</c> AND writes the
/// cur/max ints into the meter's authored CHILD text elements
/// (<c>0x10000286</c> under health, <c>0x10000288</c> under stamina) —
/// acdream instead uses <see cref="UiMeter.Fill"/>/
/// <see cref="UiMeter.Label"/> because <see cref="UiMeter.ConsumesDatChildren"/>
/// is true (a pre-existing widget decision, not this slice's). Noted
/// here so a future reader does not go hunting for the unbound
/// <c>0x10000286</c>/<c>0x10000288</c>/<c>0x1000028A</c> children.
/// N-2: the <c>max &gt; 0</c> 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.
/// </summary>
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 (register row AD-82 — retail selects on the whole
/// row, not just its name text).
/// </summary>
private void SelectFellow(uint guid)
{
SetSelectedFellow(guid);
_bindings.Selection.Select(guid, SelectionChangeSource.Social);
}
/// <summary>
/// MUST-FIX 4 (fix round) — the world&#8594;panel arm of retail's
/// two-directional selection coupling (see the class doc). Retail's
/// <c>UpdateFellowSelection</c> 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 <see cref="_selectedFellowGuid"/>
/// untouched), and <see cref="RefreshRoster"/> already clears the
/// selection when its fellow leaves the roster (retail's
/// fallback-is-null case).
/// </summary>
private void SyncSelectionFromWorld()
{
if (_bindings.Selection.SelectedObjectId is { } id && _memberGuids.Contains(id))
SetSelectedFellow(id);
}
/// <summary>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
/// <see cref="UpdateRow"/> for the bounded (&#8804;9) roster on a change
/// is cheap and keeps the highlight in sync without waiting for the
/// next vitals-driven revision bump.</summary>
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);
}
}