diff --git a/overlay/Scripts/Custom/Bridge/BridgeEvents.cs b/overlay/Scripts/Custom/Bridge/BridgeEvents.cs
new file mode 100644
index 0000000..9c373ce
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgeEvents.cs
@@ -0,0 +1,432 @@
+using System;
+using System.Text;
+
+using Server.Accounting;
+using Server.Commands;
+using Server.Mobiles;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// EventSink subscriptions. Every handler runs on the Core thread, synchronously, inside
+ /// the code path that raised it. Three rules, all load-bearing:
+ ///
+ /// 1. Never block. Emit() enqueues and returns; that is the only I/O allowed here.
+ /// 2. Never throw. A bridge exception escaping into a game code path is a shard bug,
+ /// so every handler body is wrapped.
+ /// 3. Never mutate the args. Several of these are veto hooks — AccountLogin has
+ /// Accepted/RejectReason, FastWalk has Blocked — and we are an observer, not a
+ /// participant.
+ ///
+ /// Copy primitives out synchronously. Some args objects are pooled and freed immediately
+ /// after the event returns.
+ ///
+ public static class BridgeEvents
+ {
+ public static void Initialize()
+ {
+ if (!BridgeConfig.Enabled)
+ return;
+
+ // Session
+ EventSink.Login += OnLogin;
+ EventSink.Logout += OnLogout;
+ EventSink.AccountLogin += OnAccountLogin;
+
+ // Economy
+ EventSink.AccountGoldChange += OnGoldChange;
+ EventSink.ValidVendorPurchase += OnVendorPurchase;
+ EventSink.ValidVendorSell += OnVendorSell;
+ EventSink.PlacePlayerVendor += OnVendorPlaced;
+
+ // Progression
+ EventSink.SkillGain += OnSkillGain;
+ EventSink.FameChange += OnFameChange;
+ EventSink.KarmaChange += OnKarmaChange;
+ EventSink.QuestComplete += OnQuestComplete;
+
+ // Death
+ EventSink.PlayerDeath += OnPlayerDeath;
+ EventSink.PlayerMurdered += OnPlayerMurdered;
+ EventSink.OnKilledBy += OnKilledBy;
+
+ // Cheat detection and staff audit
+ EventSink.FastWalk += OnFastWalk;
+ EventSink.OnPropertyChanged += OnStaffPropertySet;
+ EventSink.Command += OnStaffCommand;
+
+ // Save boundaries
+ EventSink.BeforeWorldSave += OnBeforeWorldSave;
+ EventSink.AfterWorldSave += OnAfterWorldSave;
+
+ Console.WriteLine("[Bridge] event streams attached");
+ }
+
+ // ---- helpers ----
+
+ /// Writes a nested actor object: serial, name, and account when there is one.
+ private static StringBuilder Mob(this StringBuilder sb, string field, Mobile m)
+ {
+ sb.Append(",\"").Append(field).Append("\":");
+
+ if (m == null)
+ {
+ sb.Append("null");
+ return sb;
+ }
+
+ sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
+
+ sb.Append(",\"name\":");
+ BridgeJson.Escape(sb, m.Name ?? "");
+
+ var acct = m.Account as Account;
+
+ if (acct != null)
+ {
+ sb.Append(",\"acct\":");
+ BridgeJson.Escape(sb, acct.Username);
+ }
+
+ sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
+ sb.Append('}');
+
+ return sb;
+ }
+
+ private static long ToGold(double currency)
+ {
+ return (long)(currency * Account.CurrencyThreshold);
+ }
+
+ private static void Guard(string kind, Action body)
+ {
+ try
+ {
+ body();
+ }
+ catch (Exception ex)
+ {
+ // Swallow: we are inside a game code path and must not disturb it.
+ Console.WriteLine("[Bridge] handler '{0}' threw: {1}", kind, ex.Message);
+ }
+ }
+
+ // ---- session ----
+
+ private static void OnLogin(LoginEventArgs e)
+ {
+ Guard("mob.login", () =>
+ {
+ var m = e.Mobile;
+
+ if (m == null)
+ return;
+
+ BridgeLink.Emit(BridgeJson.Begin("mob.login")
+ .Mob("who", m)
+ .Str("map", m.Map == null ? null : m.Map.Name)
+ .Num("x", m.X).Num("y", m.Y).Num("z", m.Z)
+ .End());
+ });
+ }
+
+ private static void OnLogout(LogoutEventArgs e)
+ {
+ Guard("mob.logout", () =>
+ {
+ var m = e.Mobile;
+
+ if (m == null)
+ return;
+
+ BridgeLink.Emit(BridgeJson.Begin("mob.logout").Mob("who", m).End());
+ });
+ }
+
+ ///
+ /// Veto hook: AccountLoginEventArgs carries Accepted and RejectReason, and a plaintext
+ /// Password. We read the username only. The password must never leave the process.
+ /// Fires before the auth decision, so this is an attempt, not a result.
+ ///
+ private static void OnAccountLogin(AccountLoginEventArgs e)
+ {
+ Guard("account.login.attempt", () =>
+ {
+ string address = null;
+
+ if (e.State != null && e.State.Address != null)
+ address = e.State.Address.ToString();
+
+ BridgeLink.Emit(BridgeJson.Begin("account.login.attempt")
+ .Str("acct", e.Username)
+ .Str("ip", address)
+ .End());
+ });
+ }
+
+ // ---- economy ----
+
+ private static void OnGoldChange(AccountGoldChangeEventArgs e)
+ {
+ Guard("gold.change", () =>
+ {
+ var acct = e.Account as Account;
+
+ if (acct == null)
+ return;
+
+ long oldGold = ToGold(e.OldAmount);
+ long newGold = ToGold(e.NewAmount);
+
+ BridgeLink.Emit(BridgeJson.Begin("gold.change")
+ .Str("acct", acct.Username)
+ .Num("old", oldGold)
+ .Num("new", newGold)
+ .Num("delta", newGold - oldGold)
+ .End());
+ });
+ }
+
+ ///
+ /// ValidVendorPurchase is a validation-stage hook, not a committed sale. Treat as
+ /// "attempted". Total is AmountPerUnit times the stack size, not AmountPerUnit.
+ ///
+ private static void OnVendorPurchase(ValidVendorPurchaseEventArgs e)
+ {
+ Guard("vendor.buy", () => EmitVendorTrade("vendor.buy", e.Mobile, e.Vendor, e.Bought, e.AmountPerUnit));
+ }
+
+ private static void OnVendorSell(ValidVendorSellEventArgs e)
+ {
+ Guard("vendor.sell", () => EmitVendorTrade("vendor.sell", e.Mobile, e.Vendor, e.Sold, e.AmountPerUnit));
+ }
+
+ private static void EmitVendorTrade(string kind, Mobile who, Mobile vendor, IEntity entity, int perUnit)
+ {
+ int amount = 1;
+ var item = entity as Item;
+
+ if (item != null)
+ amount = Math.Max(1, item.Amount);
+
+ var sb = BridgeJson.Begin(kind)
+ .Mob("who", who)
+ .Mob("vendor", vendor)
+ .Str("item", entity == null ? null : entity.GetType().Name)
+ .Num("amount", amount)
+ .Num("perUnit", perUnit)
+ .Num("total", (long)perUnit * amount)
+ .Bool("committed", false); // validation stage; reconcile against gold.change
+
+ if (entity != null)
+ sb.Ser("itemSerial", entity.Serial);
+
+ BridgeLink.Emit(sb.End());
+ }
+
+ private static void OnVendorPlaced(PlacePlayerVendorEventArgs e)
+ {
+ Guard("vendor.placed", () =>
+ BridgeLink.Emit(BridgeJson.Begin("vendor.placed")
+ .Mob("owner", e.Mobile)
+ .Mob("vendor", e.Vendor)
+ .End()));
+ }
+
+ // ---- progression ----
+
+ ///
+ /// Player-only. SkillGain fires for creatures too, and they train constantly: on this
+ /// shard a single boot produced 115 gains in four seconds, every one of them an NPC
+ /// grinding Meditation. Unfiltered this is a firehose of noise.
+ ///
+ private static void OnSkillGain(SkillGainEventArgs e)
+ {
+ Guard("skill.gain", () =>
+ {
+ if (e.Skill == null || e.From == null || !e.From.Player)
+ return;
+
+ BridgeLink.Emit(BridgeJson.Begin("skill.gain")
+ .Mob("who", e.From)
+ .Str("skill", e.Skill.SkillName.ToString())
+ .Num("gained", e.Gained)
+ .Num("base", e.Skill.Base)
+ .Num("cap", e.Skill.Cap)
+ .End());
+ });
+ }
+
+ private static void OnFameChange(FameChangeEventArgs e)
+ {
+ Guard("fame.change", () =>
+ {
+ if (e.Mobile == null || !e.Mobile.Player)
+ return;
+
+ BridgeLink.Emit(BridgeJson.Begin("fame.change")
+ .Mob("who", e.Mobile)
+ .Num("old", e.OldValue)
+ .Num("new", e.NewValue)
+ .End());
+ });
+ }
+
+ private static void OnKarmaChange(KarmaChangeEventArgs e)
+ {
+ Guard("karma.change", () =>
+ {
+ if (e.Mobile == null || !e.Mobile.Player)
+ return;
+
+ BridgeLink.Emit(BridgeJson.Begin("karma.change")
+ .Mob("who", e.Mobile)
+ .Num("old", e.OldValue)
+ .Num("new", e.NewValue)
+ .End());
+ });
+ }
+
+ private static void OnQuestComplete(QuestCompleteEventArgs e)
+ {
+ Guard("quest.complete", () =>
+ BridgeLink.Emit(BridgeJson.Begin("quest.complete")
+ .Mob("who", e.Mobile)
+ .Str("quest", e.QuestType == null ? null : e.QuestType.Name)
+ .End()));
+ }
+
+ // ---- death ----
+
+ private static void OnPlayerDeath(PlayerDeathEventArgs e)
+ {
+ Guard("player.death", () =>
+ BridgeLink.Emit(BridgeJson.Begin("player.death")
+ .Mob("who", e.Mobile)
+ .Mob("killer", e.Killer)
+ .End()));
+ }
+
+ private static void OnPlayerMurdered(PlayerMurderedEventArgs e)
+ {
+ Guard("player.murdered", () =>
+ BridgeLink.Emit(BridgeJson.Begin("player.murdered")
+ .Mob("victim", e.Victim)
+ .Mob("murderer", e.Murderer)
+ .End()));
+ }
+
+ ///
+ /// Fires for creatures too. Only a kill involving a player is interesting, and filtering
+ /// here rather than in the sidecar keeps the mob-grinding firehose off the socket.
+ ///
+ private static void OnKilledBy(OnKilledByEventArgs e)
+ {
+ Guard("mob.killed", () =>
+ {
+ var killed = e.Killed;
+ var killer = e.KilledBy;
+
+ bool involvesPlayer = (killed != null && killed.Player) || (killer != null && killer.Player);
+
+ if (!involvesPlayer)
+ return;
+
+ BridgeLink.Emit(BridgeJson.Begin("mob.killed")
+ .Mob("killed", killed)
+ .Mob("killer", killer)
+ .End());
+ });
+ }
+
+ // ---- cheat detection and staff audit ----
+
+ ///
+ /// Veto hook: FastWalkEventArgs.Blocked gates the move. Read only. The args carry only
+ /// a NetState, and NetState.Mobile can be null mid-handshake.
+ ///
+ private static void OnFastWalk(FastWalkEventArgs e)
+ {
+ Guard("cheat.fastwalk", () =>
+ {
+ var state = e.NetState;
+
+ if (state == null)
+ return;
+
+ var sb = BridgeJson.Begin("cheat.fastwalk")
+ .Mob("who", state.Mobile);
+
+ if (state.Address != null)
+ sb.Str("ip", state.Address.ToString());
+
+ BridgeLink.Emit(sb.End());
+ });
+ }
+
+ ///
+ /// Raised only from Scripts/Commands/Properties.cs, i.e. staff `[set`. This is a
+ /// GM-abuse audit trail, not a stat-change stream. One of its three raise sites passes
+ /// a null Mobile, so the staffer is not always known.
+ ///
+ private static void OnStaffPropertySet(OnPropertyChangedEventArgs e)
+ {
+ Guard("audit.set", () =>
+ {
+ if (e.Property == null)
+ return;
+
+ var sb = BridgeJson.Begin("audit.set")
+ .Mob("staff", e.Mobile)
+ .Str("prop", e.Property.Name)
+ .Str("target", e.Instance == null ? null : e.Instance.GetType().Name)
+ .Str("old", e.OldValue == null ? null : e.OldValue.ToString())
+ .Str("new", e.NewValue == null ? null : e.NewValue.ToString());
+
+ var ent = e.Instance as IEntity;
+
+ if (ent != null)
+ sb.Ser("targetSerial", ent.Serial);
+
+ BridgeLink.Emit(sb.End());
+ });
+ }
+
+ private static void OnStaffCommand(CommandEventArgs e)
+ {
+ Guard("audit.command", () =>
+ {
+ if (e.Mobile == null || e.Mobile.AccessLevel <= AccessLevel.Player)
+ return; // player commands are noise; staff commands are the audit trail
+
+ BridgeLink.Emit(BridgeJson.Begin("audit.command")
+ .Mob("staff", e.Mobile)
+ .Str("command", e.Command)
+ .Str("args", e.ArgString)
+ .End());
+ });
+ }
+
+ // ---- save boundaries ----
+
+ private static void OnBeforeWorldSave(BeforeWorldSaveEventArgs e)
+ {
+ Guard("world.save.before", () =>
+ BridgeLink.Emit(BridgeJson.Begin("world.save.before").End()));
+ }
+
+ ///
+ /// A natural checkpoint: the sidecar can treat this as a consistency boundary. Note that
+ /// timers and inbound commands do not run during the save itself.
+ ///
+ private static void OnAfterWorldSave(AfterWorldSaveEventArgs e)
+ {
+ Guard("world.save.after", () =>
+ BridgeLink.Emit(BridgeJson.Begin("world.save.after")
+ .Num("items", World.Items.Count)
+ .Num("mobiles", World.Mobiles.Count)
+ .End()));
+ }
+ }
+}
diff --git a/tools/scaffolding/BridgeEventProbe.cs b/tools/scaffolding/BridgeEventProbe.cs
new file mode 100644
index 0000000..cf38859
--- /dev/null
+++ b/tools/scaffolding/BridgeEventProbe.cs
@@ -0,0 +1,68 @@
+using System;
+
+using Server.Accounting;
+using Server.Mobiles;
+
+namespace Server.Custom
+{
+ ///
+ /// Fires a handful of the bridge's event streams by doing real things to the world, so the
+ /// emit path and JSON shape can be verified without a game client attached.
+ ///
+ /// These are genuine triggers, not synthetic EventSink.Invoke calls: DepositGold raises
+ /// AccountGoldChange from Account.cs:1635, the Fame/Karma setters raise theirs from
+ /// Mobile.cs:7121,7141, and World.Save raises the save boundaries from World.cs:1151,1202.
+ /// Calling Invoke directly would prove only that the handler compiles.
+ ///
+ /// Test scaffolding. Never deployed. Mutates the world (gold, fame, karma) and saves.
+ /// Run only against a seeded throwaway world with a backup.
+ ///
+ public static class BridgeEventProbe
+ {
+ public static void Initialize()
+ {
+ if (Config.Get("Bridge.EventProbeOnStart", false))
+ EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
+ }
+
+ private static void Run()
+ {
+ try
+ {
+ var acct = Accounting.Accounts.GetAccount("seed_000") as Account;
+
+ if (acct == null)
+ {
+ Console.WriteLine("[EventProbe] no seed_000 account; seed the world first");
+ return;
+ }
+
+ var pm = acct[0] as PlayerMobile;
+
+ if (pm == null)
+ {
+ Console.WriteLine("[EventProbe] seed_000 has no character in slot 0");
+ return;
+ }
+
+ Console.WriteLine("[EventProbe] firing gold.change ...");
+ acct.DepositGold(12345);
+
+ Console.WriteLine("[EventProbe] firing fame.change ...");
+ pm.Fame = pm.Fame + 100;
+
+ Console.WriteLine("[EventProbe] firing karma.change ...");
+ pm.Karma = pm.Karma - 50;
+
+ Console.WriteLine("[EventProbe] firing world.save.before / world.save.after ...");
+ World.Save();
+
+ Console.WriteLine("[EventProbe] done");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[EventProbe] FAILED: " + ex);
+ }
+ }
+ }
+}
diff --git a/tools/scaffolding/README.md b/tools/scaffolding/README.md
index 08ae85d..bd24b0a 100644
--- a/tools/scaffolding/README.md
+++ b/tools/scaffolding/README.md
@@ -8,6 +8,11 @@ These two scripts produced the measured budget in `docs/PLAN.md` §1. They are k
|------|--------------------------|------|
| `BridgeSeeder.cs` | `Scripts/Custom/BridgeSeeder.cs` | Populates a synthetic world: 50 accounts, 150 characters, 30 houses, 30 player vendors with 40 listings each. |
| `BridgeProbe.cs` | `Scripts/Custom/BridgeProbe.cs` | Times every read the plugin performs, on the Core thread. Read-only. |
+| `BridgeEventProbe.cs` | `Scripts/Custom/BridgeEventProbe.cs` | Fires gold/fame/karma/save events through their real code paths so the emit path can be verified without a game client. **Mutates the world and saves.** Flag: `EventProbeOnStart`. |
+
+## Deploy overwrites Bridge.cfg
+
+`deploy.ps1` copies `overlay/Config/Bridge.cfg`, which deliberately omits the scaffolding flags. So **every deploy strips `SeedOnStart` / `EventProbeOnStart` / etc.** Re-append the flag you need after deploying, or the probe silently does nothing on the next boot. (This bit once during Phase 2 testing.)
## Using them