diff --git a/overlay.toml b/overlay.toml
index 034eae3..0799583 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -23,8 +23,8 @@
# manual duty: when the protocol changes, bump it here in the same PR that
# changes the emitters, exactly as link bumps PROTOCOL_VERSION.
#
-# Current: 3 — see docs/link/v3.md (world.ruleset, points.board, vendor.listing).
-protocol = 3
+# Current: 4 — see docs/link/v4.md (guild.roster, guild.leave).
+protocol = 4
# ── ServUO compatibility ─────────────────────────────────────────────────────
#
diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
index 0245269..c79076f 100644
--- a/overlay/Config/Bridge.cfg
+++ b/overlay/Config/Bridge.cfg
@@ -34,6 +34,18 @@ PageSweepSeconds=5
# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
GuildSweepSeconds=60
+# Members per guild.roster frame (Protocol 4). A roster is the only fat frame the bridge emits
+# (~69 bytes per member) and the sidecar reads a line with no length bound, so this caps it; a
+# guild over the cap is split across continuation frames carrying seq/more. 500 members is ~35 KB,
+# past any realistic guild, so the split path is an edge case rather than the norm.
+GuildRosterMembersPerLine=500
+
+# Guilds that may emit a roster in one sweep. Every guild looks changed right after a sidecar
+# reconnect, and building hundreds of fat frames in a single Core-thread pass is exactly the stall
+# the bridge exists to avoid. The sweep re-arms itself every 2s while a baseline is draining, so
+# lowering this slows the catch-up without making the site wait a full sweep interval per batch.
+GuildRosterGuildsPerTick=25
+
# Town-governor poll. Each city's Governor / election is diffed on this interval to emit
# city.update on change. Governors turn over on the order of weeks, so a slow sweep is fine.
# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).
diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
index bd1362a..ee067fe 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
@@ -38,6 +38,10 @@ namespace Server.Custom.Bridge
public static int PointsSweepSeconds { get; private set; }
public static int MarketSweepSeconds { get; private set; }
+ // ---- guild rosters (Protocol 4) ----
+ public static int GuildRosterMembersPerLine { get; private set; }
+ public static int GuildRosterGuildsPerTick { get; private set; }
+
// ---- player-vendor market index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8) ----
public static bool MarketEnabled { get; private set; }
public static int MarketSweepBatch { get; private set; }
@@ -114,6 +118,24 @@ namespace Server.Custom.Bridge
if (GuildSweepSeconds < 1)
GuildSweepSeconds = 1;
+ // A roster line is the only fat frame this plugin emits — measured at roughly 69 bytes
+ // per member — and the sidecar reads a line with no length bound. The cap turns an
+ // unbounded frame into a bounded one; a guild above it is split across continuation
+ // lines. 500 members is ~35 KB, comfortably past any real guild, so the split path is
+ // an edge case rather than the norm.
+ GuildRosterMembersPerLine = Config.Get("Bridge.GuildRosterMembersPerLine", 500);
+ if (GuildRosterMembersPerLine < 16)
+ GuildRosterMembersPerLine = 16;
+
+ // How many guilds may emit a roster in a single sweep. Every guild re-emits after a
+ // reconnect (the diff caches are cleared), and building a few hundred fat JSON frames in
+ // one Core-thread pass is exactly the stall this bridge exists to avoid. The sweep
+ // re-arms itself promptly while a baseline is still draining, so this throttles the work
+ // without making the site wait a full sweep interval per batch.
+ GuildRosterGuildsPerTick = Config.Get("Bridge.GuildRosterGuildsPerTick", 25);
+ if (GuildRosterGuildsPerTick < 1)
+ GuildRosterGuildsPerTick = 1;
+
CitySweepSeconds = Config.Get("Bridge.CitySweepSeconds", 300);
if (CitySweepSeconds < 1)
CitySweepSeconds = 1;
diff --git a/overlay/Scripts/Custom/Bridge/BridgeJson.cs b/overlay/Scripts/Custom/Bridge/BridgeJson.cs
index 9d73789..bb6bd17 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeJson.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeJson.cs
@@ -85,14 +85,153 @@ namespace Server.Custom.Bridge
public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m)
{
sb.Append(",\"").Append(name).Append("\":");
+ WriteActor(sb, m);
+ return sb;
+ }
+ ///
+ /// Writes a named array of actor objects — a guild roster (Protocol 4) being the first
+ /// caller. Every other outbound helper here emits a leading `,"name":`, so an array
+ /// element needs the bare object; that is why exists separately
+ /// rather than being reused.
+ ///
+ /// `count` bounds how many are written, because a roster frame must stay a bounded line
+ /// (Bridge.GuildRosterMembersPerLine). A null entry in the sequence is skipped rather
+ /// than written as null, so the array is always a list of real members and a caller can
+ /// trust its length.
+ ///
+ /// `withGuildRank` adds each member's guild rank to their object. It is a parameter
+ /// rather than always-on because rank is a property of a mobile's membership of THIS
+ /// guild, not of the mobile — every other actor this bridge writes is a bystander,
+ /// a killer, a governor, and guild rank is meaningless on all of them.
+ ///
+ public static StringBuilder Actors(
+ this StringBuilder sb, string name, IList mobiles, int start, int count,
+ bool withGuildRank = false)
+ {
+ sb.Append(",\"").Append(name).Append("\":[");
+
+ if (mobiles != null)
+ {
+ var end = Math.Min(start + count, mobiles.Count);
+ bool first = true;
+
+ for (int i = start; i < end; i++)
+ {
+ var m = mobiles[i];
+
+ if (m == null)
+ continue;
+
+ if (!first)
+ sb.Append(',');
+
+ if (withGuildRank)
+ WriteGuildMember(sb, m);
+ else
+ WriteActor(sb, m);
+
+ first = false;
+ }
+ }
+
+ sb.Append(']');
+ return sb;
+ }
+
+ ///
+ /// A roster member: the standard actor object plus the member's rank in their guild.
+ ///
+ /// **Only the raw rank is emitted, never a resolved label.** ServUO names the five
+ /// standard ranks with cliloc ids (1062959–1062963) and ships no text for them, so the
+ /// shard cannot produce "Warlord" without a client-file table it does not have. The
+ /// website module does have one, and resolving a game term is its job in any case.
+ ///
+ /// `rank` is the numeric rank, 0–4, with 4 being Leader (`RankDefinition.Ranks`). A
+ /// custom rank definition may carry a literal string instead of a cliloc, so `rankName`
+ /// is written when there is one and `rankCliloc` when there is not; a shard that has
+ /// replaced the rank table therefore keeps its own naming rather than being flattened
+ /// into the stock five.
+ ///
+ /// A member with no readable rank — a mobile that is not a PlayerMobile, or one whose
+ /// GuildRank is null — is written with no rank fields at all rather than a fabricated
+ /// default. Absent means "not known", and a consumer that treated a missing rank as 0
+ /// would silently demote them.
+ ///
+ /// **Staff are deliberately written with no rank, and this is not a rounding error.**
+ /// `PlayerMobile.GuildRank` returns `RankDefinition.Leader` for anyone at GameMaster or
+ /// above, whatever their actual rank — a gameplay convenience so staff can operate a
+ /// guild stone, and emphatically not a claim about who leads the guild. The true value
+ /// is in a private field with no accessor, so the only honest options are "Leader" and
+ /// "not known", and publishing a staff member as a guild leader on a public roster is
+ /// the worse of the two by a wide margin. A staff account that genuinely leads its guild
+ /// shows as an unranked member, which is a visible gap rather than a false claim.
+ ///
+ private static void WriteGuildMember(StringBuilder sb, Mobile m)
+ {
if (m == null)
{
sb.Append("null");
- return sb;
+ return;
}
- sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
+ sb.Append('{');
+ WriteActorFields(sb, m);
+
+ var pm = m as Server.Mobiles.PlayerMobile;
+ var rank = pm == null || pm.AccessLevel >= AccessLevel.GameMaster ? null : pm.GuildRank;
+
+ if (rank != null)
+ {
+ sb.Append(",\"rank\":").Append(rank.Rank);
+
+ if (!string.IsNullOrEmpty(rank.Name.String))
+ {
+ sb.Append(",\"rankName\":");
+ Escape(sb, rank.Name.String);
+ }
+ else if (rank.Name.Number > 0)
+ {
+ sb.Append(",\"rankCliloc\":").Append(rank.Name.Number);
+ }
+ }
+
+ sb.Append('}');
+ }
+
+ ///
+ /// One bare actor object, with no leading field name: serial, name, account (when there
+ /// is one), the linked webId (when the account is linked), and the player flag. A `null`
+ /// mobile writes null.
+ ///
+ /// `acct` and `webId` are the site-identity fields, and they are emitted here
+ /// unconditionally by design — the sidecar is a forwarder, and deciding who may see them
+ /// is the website's job (it projects per the shard visibility rungs). Note that `acct` is
+ /// genuinely optional: a PlayerMobile can have no Account at all.
+ ///
+ private static void WriteActor(StringBuilder sb, Mobile m)
+ {
+ if (m == null)
+ {
+ sb.Append("null");
+ return;
+ }
+
+ sb.Append('{');
+ WriteActorFields(sb, m);
+ sb.Append('}');
+ }
+
+ ///
+ /// The actor fields, with no braces, so a caller can add its own.
+ ///
+ /// Split out for , which is the same object plus guild
+ /// rank. Note the first field is written WITHOUT a leading comma and every later one
+ /// with, so this must be the first thing inside its object.
+ ///
+ private static void WriteActorFields(StringBuilder sb, Mobile m)
+ {
+ sb.Append("\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
sb.Append(",\"name\":");
Escape(sb, m.Name ?? "");
@@ -112,8 +251,6 @@ namespace Server.Custom.Bridge
}
sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
- sb.Append('}');
- return sb;
}
/// Closes the object. The trailing newline is the frame delimiter.
diff --git a/overlay/Scripts/Custom/Bridge/BridgeSocial.cs b/overlay/Scripts/Custom/Bridge/BridgeSocial.cs
index 48b756d..b15861f 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeSocial.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeSocial.cs
@@ -15,10 +15,14 @@ namespace Server.Custom.Bridge
/// A guild that vanishes (or disbands — Disbanded == leader gone) leaves via `guild.remove`.
///
/// On top of the board we emit a real-time `guild.join` from EventSink.JoinGuild, so a "so-and-
- /// so joined" feed does not wait for the next sweep. A membership change also moves the board
- /// signature (member count + serial sum), so a *leave* surfaces as the member count dropping in
- /// the next `guild.update`; per-member leave events would need a core tap and are a later
- /// refinement (§10.1).
+ /// so joined" feed does not wait for the next sweep.
+ ///
+ /// Protocol 4 adds the membership half that §10.1 deferred. The sweep holds each guild's
+ /// member serial **set** rather than a sum of it, so a change is detected by set comparison
+ /// (no hash collisions, unlike the old sum where two offsetting changes could cancel) and the
+ /// departures are recoverable by difference — which is what makes a per-member `guild.leave`
+ /// possible without a core tap. A changed set also re-emits `guild.roster`, the full member
+ /// list, so the board self-corrects and nothing downstream has to replay deltas to stay right.
///
/// "Created" is derived sidecar-side from a first-seen id (as champs derive it), rather than a
/// wire event — otherwise a sidecar reconnect, which clears the diff cache and re-emits every
@@ -32,7 +36,16 @@ namespace Server.Custom.Bridge
// was cleared on reconnect), so its next sweep counts as a change.
private static readonly Dictionary _last = new Dictionary();
- private static long _sweeps, _emitted, _removed, _joins;
+ // guild id -> last-emitted member serial set (Protocol 4). Held rather than summed so a
+ // departure can be recovered as a set difference; see the class remarks.
+ private static readonly Dictionary> _members =
+ new Dictionary>();
+
+ private static long _sweeps, _emitted, _removed, _joins, _rosters, _leaves;
+
+ // Set while a post-reconnect baseline is still draining, so the sweep re-arms promptly
+ // instead of leaving the site a sweep interval behind. See GuildSweep.
+ private static bool _draining;
public static void Initialize()
{
@@ -52,6 +65,7 @@ namespace Server.Custom.Bridge
private static void OnConnected()
{
_last.Clear();
+ _members.Clear();
}
/// Stops and recreates the timer from current config. Called by `[bridge reload`.
@@ -72,8 +86,9 @@ namespace Server.Custom.Bridge
public static string Status()
{
- return String.Format("guilds(sweeps={0} emitted={1} removed={2} joins={3} tracked={4})",
- _sweeps, _emitted, _removed, _joins, _last.Count);
+ return String.Format(
+ "guilds(sweeps={0} emitted={1} removed={2} joins={3} rosters={4} leaves={5} tracked={6} draining={7})",
+ _sweeps, _emitted, _removed, _joins, _rosters, _leaves, _last.Count, _draining);
}
/// Runs one sweep now. Wired into `[bridge sweepnow`.
@@ -93,6 +108,12 @@ namespace Server.Custom.Bridge
var seen = new HashSet();
+ // Guilds whose roster this sweep is still allowed to emit. Every guild looks changed
+ // right after a reconnect, and a roster is this plugin's only fat frame, so the
+ // baseline is spread over several passes rather than built in one Core-thread tick.
+ var rosterBudget = BridgeConfig.GuildRosterGuildsPerTick;
+ var deferred = false;
+
foreach (var bg in BaseGuild.List.Values)
{
var g = bg as Guild;
@@ -104,15 +125,64 @@ namespace Server.Custom.Bridge
seen.Add(g.Id);
+ var current = MemberSerials(g);
+
+ HashSet priorMembers;
+ var known = _members.TryGetValue(g.Id, out priorMembers);
+ var membersChanged = !known || !priorMembers.SetEquals(current);
+
var sig = Signature(g);
string prior;
- if (_last.TryGetValue(g.Id, out prior) && prior == sig)
+ var sigChanged = !_last.TryGetValue(g.Id, out prior) || prior != sig;
+
+ if (!sigChanged && !membersChanged)
continue; // unchanged since last emit
- _last[g.Id] = sig;
- BridgeLink.Emit(WriteGuild(g));
- _emitted++;
+ if (sigChanged)
+ {
+ _last[g.Id] = sig;
+ BridgeLink.Emit(WriteGuild(g));
+ _emitted++;
+ }
+
+ if (!membersChanged)
+ continue;
+
+ // Over budget: leave _members untouched so this guild is still "changed" next
+ // pass and gets its roster then. The guild.update above has already gone, so the
+ // board's counts are current either way.
+ if (rosterBudget <= 0)
+ {
+ deferred = true;
+ continue;
+ }
+
+ rosterBudget--;
+
+ // Departures, per member, before the roster that supersedes them: a consumer
+ // building a "so-and-so left" feed needs the individual events, while a consumer
+ // holding the membership table only needs the roster. On the very first sweep for
+ // a guild there is no prior set, so nothing is reported as having left — an
+ // unknown roster becoming known is not 155 people leaving.
+ if (known)
+ {
+ foreach (var serial in priorMembers)
+ {
+ if (current.Contains(serial))
+ continue;
+
+ BridgeLink.Emit(BridgeJson.Begin("guild.leave")
+ .Num("id", g.Id)
+ .Str("name", g.Name)
+ .Ser("who", (Serial)serial)
+ .End());
+ _leaves++;
+ }
+ }
+
+ EmitRoster(g);
+ _members[g.Id] = current;
}
// Anything tracked last sweep but not seen now has disbanded or been removed.
@@ -120,9 +190,19 @@ namespace Server.Custom.Bridge
foreach (var id in gone)
{
_last.Remove(id);
+ _members.Remove(id);
BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End());
_removed++;
}
+
+ // Re-arm promptly while a baseline is still draining. Without this the remaining
+ // guilds would each wait a full GuildSweepSeconds, so a 200-guild shard would take
+ // hours to publish its rosters after a reconnect instead of seconds. The sweep is
+ // idempotent, so an extra pass that finds nothing changed costs a few field reads.
+ _draining = deferred;
+
+ if (deferred)
+ Timer.DelayCall(TimeSpan.FromSeconds(2.0), GuildSweep);
}
catch (Exception ex)
{
@@ -130,14 +210,15 @@ namespace Server.Custom.Bridge
}
}
- // The volatile fields that define a meaningful change: name, abbreviation, leader, member
- // count, the member set (order-independent serial sum), and alliance.
- private static string Signature(Guild g)
+ ///
+ /// The guild's live member serials. Held per guild between sweeps so a membership change
+ /// yields both the fact that it changed and *who* left (Protocol 4).
+ ///
+ private static HashSet MemberSerials(Guild g)
{
- long memberSum = 0;
- int count = 0;
-
+ var set = new HashSet();
var members = g.Members;
+
if (members != null)
{
for (int i = 0; i < members.Count; i++)
@@ -145,8 +226,28 @@ namespace Server.Custom.Bridge
var m = members[i];
if (m == null)
continue;
- count++;
- unchecked { memberSum += (uint)m.Serial.Value; }
+ set.Add(m.Serial.Value);
+ }
+ }
+
+ return set;
+ }
+
+ // The volatile fields that define a meaningful change to the *board row*: name, abbreviation,
+ // leader, member count and alliance. Membership is no longer folded in here as a serial sum —
+ // the sweep compares the real member set instead, which cannot collide the way a sum can when
+ // one member joins and another leaves between two passes.
+ private static string Signature(Guild g)
+ {
+ int count = 0;
+
+ var members = g.Members;
+ if (members != null)
+ {
+ for (int i = 0; i < members.Count; i++)
+ {
+ if (members[i] != null)
+ count++;
}
}
@@ -157,7 +258,6 @@ namespace Server.Custom.Bridge
g.Abbreviation ?? "", "|",
leaderSerial.ToString(), "|",
count.ToString(), "|",
- memberSum.ToString(), "|",
g.Alliance == null ? "" : (g.AllianceName ?? ""));
}
@@ -191,6 +291,55 @@ namespace Server.Custom.Bridge
return sb.End();
}
+ ///
+ /// Emits the guild's full member list as one or more `guild.roster` frames (Protocol 4).
+ ///
+ /// A roster is the only fat frame this plugin produces — roughly 69 bytes per member — and
+ /// the sidecar reads a line with no length bound, so the member count per line is capped
+ /// (Bridge.GuildRosterMembersPerLine). A guild over the cap is split, and each frame
+ /// carries `seq` plus `more` so a consumer can tell a complete roster from a partial one:
+ /// `seq` 0 begins a roster and replaces whatever was held, and `more` false ends it. A
+ /// guild inside the cap — every realistic one — emits exactly one frame with `seq` 0 and
+ /// `more` false, which is the same shape as if chunking did not exist.
+ ///
+ private static void EmitRoster(Guild g)
+ {
+ var members = g.Members;
+ var total = members == null ? 0 : members.Count;
+ var perLine = BridgeConfig.GuildRosterMembersPerLine;
+
+ var seq = 0;
+ var start = 0;
+
+ // do/while, not while: a guild with no members must still emit one empty roster frame,
+ // or a consumer could never learn that a roster it holds has emptied.
+ do
+ {
+ var more = start + perLine < total;
+
+ var sb = BridgeJson.Begin("guild.roster")
+ .Num("id", g.Id)
+ .Str("name", g.Name)
+ .Str("abbr", g.Abbreviation)
+ .Num("total", total)
+ .Num("seq", seq)
+ .Bool("more", more);
+
+ // `withGuildRank` — the roster is the one place a member's rank in THIS guild is
+ // meaningful, and the only frame that carries it. Leadership is rank 4
+ // (RankDefinition.Ranks), and a guild can have several members at it, which is why
+ // the board's single `leader` field was never enough to answer "who leads this".
+ sb.Actors("members", members, start, perLine, withGuildRank: true);
+
+ BridgeLink.Emit(sb.End());
+ _rosters++;
+
+ start += perLine;
+ seq++;
+ }
+ while (start < total);
+ }
+
// ---- real-time join ----
private static void OnJoinGuild(JoinGuildEventArgs e)