diff --git a/src/AcDream.Core/Selection/SelectionState.cs b/src/AcDream.Core/Selection/SelectionState.cs
index d1abebc1..8879a3b8 100644
--- a/src/AcDream.Core/Selection/SelectionState.cs
+++ b/src/AcDream.Core/Selection/SelectionState.cs
@@ -18,6 +18,12 @@ public enum SelectionChangeSource
// ACCWeenieObject::SetSelectedObject primitive as every other origin
// (VendorSellUI::AddItemToSell, pc:203558), never a vendor-local one.
Vendor,
+ // Campaign FA slice FA4: a fellowship-roster row click. Retail's
+ // gmFellowshipUI::ListenToElementMessage list-selection arm
+ // (docs/research/2026-08-11-fa-panel-structure.md §6.2/§2.8) calls
+ // ACCWeenieObject::SetSelectedObject on the SAME global primitive —
+ // clicking a fellow in the panel selects them in the world too.
+ Social,
}
public enum SelectionChangeReason
diff --git a/src/AcDream.Runtime/GameRuntimeGameplayViews.cs b/src/AcDream.Runtime/GameRuntimeGameplayViews.cs
index 1695b7d2..ade50b95 100644
--- a/src/AcDream.Runtime/GameRuntimeGameplayViews.cs
+++ b/src/AcDream.Runtime/GameRuntimeGameplayViews.cs
@@ -154,6 +154,18 @@ public interface IRuntimeFellowshipView
RuntimeFellowshipSnapshot Snapshot { get; }
bool TryGetMember(uint guid, out RuntimeFellowMemberSnapshot member);
+
+ ///
+ /// Campaign FA slice FA4: the complete member roster, for the
+ /// fellowship page's row-per-fellow list.
+ /// alone cannot build a roster — it needs the guid first. Order is
+ /// insertion order (the last 0x02BE FellowshipFullUpdate's
+ /// wire order, then whatever order incremental 0x02C0 upserts
+ /// added new members) — retail's own hash-table iteration order is not
+ /// a stable contract either (lane B §3.9), so no ordering guarantee
+ /// beyond "stable across repeated calls between mutations" is made.
+ ///
+ IEnumerable GetMembers();
}
public readonly record struct RuntimeAllegianceMemberSnapshot(
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs b/src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs
index 9e800647..b0d9e962 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs
@@ -406,6 +406,24 @@ public sealed class RuntimeFellowshipState : IDisposable
}
}
+ ///
+ /// Campaign FA slice FA4: snapshots the whole roster under the same
+ /// lock every other read here uses. Returns a materialized array
+ /// (not a lazy iterator over live state) so the caller can walk it
+ /// after the lock releases without racing a concurrent mutation.
+ ///
+ public IEnumerable GetMembers()
+ {
+ lock (owner._gate)
+ {
+ var result = new RuntimeFellowMemberSnapshot[owner._members.Count];
+ int i = 0;
+ foreach (GameEvents.FellowMember raw in owner._members.Values)
+ result[i++] = ToSnapshot(raw);
+ return result;
+ }
+ }
+
private static RuntimeFellowMemberSnapshot ToSnapshot(
GameEvents.FellowMember raw) =>
new(
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFellowshipStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFellowshipStateTests.cs
index 2ef2bd64..562b9ed1 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFellowshipStateTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFellowshipStateTests.cs
@@ -482,6 +482,58 @@ public sealed class RuntimeFellowshipStateTests
Assert.Equal(42u, self.CurrentHealth);
}
+ // ── Campaign FA slice FA4: GetMembers (the fellowship panel's roster
+ // enumeration — TryGetMember alone cannot build a roster without the
+ // guid first). ────────────────────────────────────────────────────────
+
+ [Fact]
+ public void GetMembers_ReturnsEveryCurrentMember()
+ {
+ var state = new RuntimeFellowshipState();
+ state.ApplyFullUpdate(FullUpdate(
+ Member(SelfGuid, "Self"), Member(LeaderGuid, "Leader"), Member(OtherGuid, "Other")));
+
+ var guids = state.View.GetMembers().Select(m => m.Guid).ToHashSet();
+
+ Assert.Equal(3, guids.Count);
+ Assert.Contains(SelfGuid, guids);
+ Assert.Contains(LeaderGuid, guids);
+ Assert.Contains(OtherGuid, guids);
+ }
+
+ [Fact]
+ public void GetMembers_EmptyWhenNotInAFellowship()
+ {
+ var state = new RuntimeFellowshipState();
+
+ Assert.Empty(state.View.GetMembers());
+ }
+
+ [Fact]
+ public void GetMembers_ReflectsAnIncrementalUpsert()
+ {
+ var state = new RuntimeFellowshipState();
+ state.ApplyFullUpdate(FullUpdate(Member(SelfGuid), Member(LeaderGuid)));
+
+ state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow(
+ OtherGuid, Member(OtherGuid, "Other"), UpdateType: 1u));
+
+ Assert.Equal(3, state.View.GetMembers().Count());
+ Assert.Contains(state.View.GetMembers(), m => m.Guid == OtherGuid && m.Name == "Other");
+ }
+
+ [Fact]
+ public void GetMembers_ReflectsRemovalAfterDismiss()
+ {
+ var state = new RuntimeFellowshipState();
+ state.ApplyFullUpdate(FullUpdate(Member(SelfGuid), Member(LeaderGuid), Member(OtherGuid)));
+
+ state.ApplyDismiss(OtherGuid, SelfGuid);
+
+ Assert.DoesNotContain(state.View.GetMembers(), m => m.Guid == OtherGuid);
+ Assert.Equal(2, state.View.GetMembers().Count());
+ }
+
private sealed class ManualTimeProvider : TimeProvider
{
private DateTimeOffset _now = new(2026, 8, 12, 0, 0, 0, TimeSpan.Zero);