diff --git a/README.md b/README.md
index d712a5c..ad36b7a 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree
|------:|-------|
| 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** |
| 1 — transport (`BridgeLink`) | **done, acceptance in `docs/PLAN.md` §11** |
-| 2 — event streams | not started |
+| 2 — event streams (`BridgeEvents`) | **done, acceptance in `docs/PLAN.md` §12** |
| 3 — sweeps | not started |
| 4 — request/response | not started |
| 5 — `[link` account linking | not started |
@@ -65,6 +65,7 @@ Runtime script compilation therefore had no effect, silently. `overlay/Scripts/S
| `BridgeJson.cs` | Outbound JSON by hand (Core thread, so no reflection serializer). Inbound via `JavaScriptSerializer`. |
| `BridgeLink.cs` | The socket. Link thread owns it; a bounded drop-oldest queue fronts it; a reader thread marshals inbound lines to the Core thread. |
| `BridgeBoot.cs` | Lifecycle, inbound dispatch, `[bridge status\|reload\|ping]`. |
+| `BridgeEvents.cs` | EventSink subscriptions (Phase 2). Read-only, player-filtered, never emits secrets. |
`Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on.
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 37edf26..3cb8d60 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -295,6 +295,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val
| §IV.4 | Resolve clilocs against `Data/Cliloc.enu`. | No such file. Cliloc data is in the client install via `DataPath`. Resolve website-side. |
| §0 | "117 mobiles / 2469 items per the last crash report." | The world holds **203,386 items and 42,591 mobiles** before seeding. |
| §II.2 | Stat sweep is the thing to budget for. | Measured free (0.0015 ms/char). The real cost is bulk **profiles** (69 ms/1000). |
+| §2 | `SkillGain` is a "medium" player-activity signal. | Fires for NPCs — 115 events in 4 s on a quiet shard, all mob training. Player-filter it or it is a firehose. |
| §II.4 | Player-vendor sales are the only gap needing a core edit. | Still true, and confirmed at `PlayerVendorGumps.cs:96`. |
---
@@ -303,7 +304,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val
0. ~~**Fix the build** (§3).~~ **Done.** Verified: a plain boot now logs `Core: Compiling scripts... / Build succeeded.`
1. ~~**Transport.**~~ **Done.** `BridgeLink`: `TcpClient`, link thread + bounded drop-oldest queue, reader thread → `Timer.DelayCall`, reconnect with backoff capped at 5 s. Emits `server.hello` / `server.shutdown` / `server.crashed`, answers `ping` with `pong`. `[bridge status|reload|ping]`. Acceptance evidence in §11.
-2. **Cheap event streams.** `Login`, `Logout`, `AccountGoldChange`, `ValidVendorPurchase`, `ValidVendorSell`, `PlayerDeath`, `PlayerMurdered`, `SkillGain`, `QuestComplete`.
+2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12.
3. **Sweeps.** Vitals (30 s), decay-on-transition (60 s, with silent `ServerStarted` baseline), economy supply (5 min). All config-tunable; `[bridge reload` re-arms the timers.
4. **Request/response.** `char.profile`, `account.roster`, `vendor.snapshot`. Sidecar caches profiles; rate-limit requests sidecar-side.
5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed.
@@ -347,6 +348,35 @@ Two defects were found this way and fixed:
---
+## 12. Phase 2 acceptance
+
+The selected streams (`Login`, `Logout`, `AccountLogin`, `AccountGoldChange`, `ValidVendorPurchase`/`Sell`, `PlacePlayerVendor`, `SkillGain`, `FameChange`, `KarmaChange`, `QuestComplete`, `PlayerDeath`, `PlayerMurdered`, `OnKilledBy`, `FastWalk`, `OnPropertyChanged`, `Command`, `Before`/`AfterWorldSave`) are in `BridgeEvents.cs`. Gold, fame, karma, and the save boundaries were fired through their real code paths (`DepositGold`, the `Fame`/`Karma` setters, `World.Save()`) and observed at the stub sidecar:
+
+```
+{"kind":"gold.change","acct":"seed_000","old":3836893,"new":3849238,"delta":12345}
+{"kind":"fame.change","who":{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","player":true},"old":4504,"new":4604}
+{"kind":"karma.change",...,"old":7903,"new":7853}
+{"kind":"world.save.before"}
+{"kind":"world.save.after","items":206312,"mobiles":42826}
+```
+
+`gold.change` reads `old:3836893`, exactly the previous boot's `new` (the probe adds 12,345 each run), which confirms both the platinum→gold conversion and persistence across restarts.
+
+### The finding: `SkillGain` fires for NPCs, hard
+
+The first run emitted **115 `skill.gain` events in four seconds — every one an NPC** grinding Meditation, zero players. Spawned creatures train constantly. The catalog rated this "Med"; unfiltered it is a firehose of noise on the socket. `OnSkillGain` now drops anything where `!From.Player`. After the filter the same boot produced zero stray skill events.
+
+This is the general rule for this codebase, and the reason each handler filters at the top: **most "player" events also fire for NPCs.** `FameChange`, `KarmaChange`, and `OnKilledBy` are all filtered to players/player-involving for the same reason. Filter on the Core thread, before the socket, not in the sidecar.
+
+### Safety facts baked into the handlers
+
+- **`AccountLoginEventArgs` carries a plaintext `Password`** and is a veto hook (`Accepted`, `RejectReason`). We read the username and IP only; the password never leaves the process.
+- **`FastWalkEventArgs.Blocked`** and **`AccountLogin.Accepted`** gate game logic. Handlers are read-only; they never set these.
+- **`OnPropertyChanged` passes a null `Mobile`** from one of its three raise sites, so `audit.set` tolerates an unknown staffer.
+- The property is `FastWalkEventArgs.NetState`, not `.State`.
+
+---
+
## 10. Operational notes
- **Commands and timers do not run during a world save.** `TimerMain` early-continues while `World.Saving || World.Loading` (`Server/Timer.cs:322`), and the main loop is inside `World.Save` anyway. A `link.confirm` arriving mid-save is delayed seconds. The website should show "confirming…", not fail.
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