diff --git a/tools/scaffolding/BridgeDemoDress.cs b/tools/scaffolding/BridgeDemoDress.cs
new file mode 100644
index 0000000..d991752
--- /dev/null
+++ b/tools/scaffolding/BridgeDemoDress.cs
@@ -0,0 +1,402 @@
+using System;
+using System.Collections.Generic;
+
+using Server.Accounting;
+using Server.Commands;
+using Server.Mobiles;
+using Server.Multis;
+
+namespace Server.Custom
+{
+ ///
+ /// Gives a BridgeSeeder world presentable names, so a shard standing behind a public
+ /// screenshot does not read as test data.
+ ///
+ /// Test scaffolding. Not part of the bridge. Never deployed — see tools/README.md.
+ ///
+ /// WHY THIS EXISTS
+ /// ---------------
+ /// BridgeSeeder builds a world at realistic SCALE, which is what the bridge needed:
+ /// 50 accounts, 150 characters, 30 houses, 30 vendors, 1,200 listings. It never needed
+ /// the world to look like anything, so a vendor is "seed vendor" trading as
+ /// "Seed Shop 810" and a character is "Seed004A". Every one of those names travels the
+ /// whole bridge — plugin, sidecar, website — and lands on the marketplace, the guild
+ /// roster and the housing pages, which are exactly the pages a screenshot wants.
+ ///
+ /// This pass renames what is already there rather than seeding anything new. That
+ /// matters: the data keeps its provenance. The prices, the listing counts, the decay
+ /// stages, the fame and the skill sheets are all still whatever BridgeSeeder produced
+ /// and whatever the shard has done to them since — only the strings a human reads are
+ /// replaced. Nothing here invents shard state that the game did not produce.
+ ///
+ /// IDEMPOTENT, AND DETERMINISTIC
+ /// -----------------------------
+ /// Names come from fixed tables indexed by the object's own serial, so the same vendor
+ /// draws the same shop name on every run against the same save — screenshots retaken
+ /// later still match. A second run is therefore a no-op, and a world half-dressed by an
+ /// interrupted run finishes cleanly.
+ ///
+ /// Shop and house names are also re-dressed when they are names THIS pass produced, so
+ /// a change to the tables or to the hash can be applied to a world that has already been
+ /// through here once. Character names are not: a person's name is an ordinary string
+ /// with no closed set to recognise it by, so once dressed it is left alone.
+ ///
+ /// WHAT IT ALSO DOES, AND WHY EACH IS HERE
+ /// ---------------------------------------
+ /// - Stages houses back into IDOC. The seeder condemned eighteen of them, but decay is
+ /// a live process: by the time anybody looks, the stages have moved on and the
+ /// "Houses in danger" page is empty. Empty is a true state and a poor screenshot, so
+ /// this puts a handful back.
+ /// - Sets a known password on one seeded account. Logging a character in is the only
+ /// way to make the online roster non-empty, and it needs a client, and a client needs
+ /// a password. The seeder gives every account a random GUID nobody kept.
+ ///
+ /// Flag: `DemoDressOnStart=True` in Config/Bridge.cfg. In game: `[demodress`.
+ ///
+ public static class BridgeDemoDress
+ {
+ private const string Prefix = "seed_";
+
+ /// The account whose password is set, so a character can be logged in.
+ private const string LoginAccount = "seed_000";
+
+ ///
+ /// Read from Config/Bridge.cfg (`DemoDressPassword`) so a password never lands in
+ /// source control. Absent means the account is left alone.
+ ///
+ private static string LoginPassword
+ {
+ get { return Config.Get("Bridge.DemoDressPassword", default(string)); }
+ }
+
+ /// How many condemned houses to put back into the last two decay stages.
+ private const int IdocHouses = 4;
+
+ // ── Name tables ────────────────────────────────────────────────────────────────
+ //
+ // Ordinary fantasy given names and English trade-sign nouns. Deliberately dull: the
+ // point is that a reader's eye passes over them, which is what a real roster does.
+
+ private static readonly string[] Given =
+ {
+ "Alaric", "Bess", "Corwin", "Dagna", "Edric", "Fenna", "Garrick", "Halle",
+ "Ivo", "Jessa", "Kellen", "Lira", "Marek", "Nessa", "Orrin", "Perrin",
+ "Quill", "Rowan", "Sera", "Tamsin", "Ulric", "Vera", "Wendel", "Xanthe",
+ "Yorick", "Zara", "Bram", "Caitrin", "Doran", "Elspeth"
+ };
+
+ private static readonly string[] Family =
+ {
+ "Ashdown", "Bellweather", "Crowe", "Dunmore", "Eastgate", "Fairbourne",
+ "Grimsby", "Hollowell", "Ironwood", "Larkspur", "Mosswick", "Thornbury"
+ };
+
+ private static readonly string[] ShopFirst =
+ {
+ "The Copper", "The Silver", "The Gilded", "The Iron", "The Rusted", "The Amber",
+ "The Quiet", "The Crooked", "The Old", "The Wandering", "The Salted", "The Ember"
+ };
+
+ private static readonly string[] ShopSecond =
+ {
+ "Anvil", "Kettle", "Lantern", "Compass", "Bellows", "Flask", "Ledger",
+ "Wagon", "Tankard", "Whetstone", "Sextant", "Coffer"
+ };
+
+ private static readonly string[] HouseNames =
+ {
+ "Ashwood Cottage", "Bramblegate", "Candlewick House", "Dovecote",
+ "Eastmarch", "Fernhollow", "Greywater", "Hearthstone",
+ "Ivyfall", "Kestrel Lodge", "Longmeadow", "Millrace",
+ "Northrest", "Oakenshaw", "Pinefall", "Quarrystone",
+ "Riverwatch", "Stonebrook", "Thistledown", "Umberley",
+ "Vinesend", "Westbarrow", "Yewcross", "Almsgate",
+ "Brightmoor", "Coldspring", "Duskvale", "Elmshade",
+ "Foxhollow", "Gravensward"
+ };
+
+ public static void Initialize()
+ {
+ CommandSystem.Register("demodress", AccessLevel.Administrator, Dress_OnCommand);
+
+ if (Config.Get("Bridge.DemoDressOnStart", false))
+ EventSink.ServerStarted += () => Run(null, save: true);
+ }
+
+ [Usage("demodress")]
+ [Description("Renames BridgeSeeder's synthetic world so it is presentable in screenshots.")]
+ private static void Dress_OnCommand(CommandEventArgs e)
+ {
+ Run(e.Mobile, save: false);
+ }
+
+ private static void Report(Mobile to, string text)
+ {
+ Console.WriteLine("[BridgeDemoDress] " + text);
+
+ if (to != null)
+ to.SendMessage(text);
+ }
+
+ private static void Run(Mobile to, bool save)
+ {
+ try
+ {
+ var start = DateTime.UtcNow;
+
+ int chars = DressCharacters();
+ int vendors = DressVendors();
+ int houses = DressHouses();
+ int idoc = StageIdoc();
+ bool password = SetLoginPassword(to);
+
+ Report(to, String.Format(
+ "Dressed {0} characters, {1} vendors, {2} house signs; staged {3} IDOC; " +
+ "login password {4}. ({5:F1}s)",
+ chars, vendors, houses, idoc, password ? "set" : "skipped",
+ (DateTime.UtcNow - start).TotalSeconds));
+
+ if (save)
+ {
+ Report(to, "Saving world...");
+ World.Save();
+ Report(to, "Save complete.");
+ }
+ }
+ catch (Exception ex)
+ {
+ Report(to, "FAILED: " + ex);
+ }
+ }
+
+ ///
+ /// A stable index for a world object, salted so that two names drawn for the SAME
+ /// object land in unrelated places in their tables.
+ ///
+ /// Serial is the only identifier that survives a save and is identical on every
+ /// load, which is what makes the naming reproducible. But serials are dense and
+ /// sequential, so a weak mix hands neighbouring objects neighbouring names. The
+ /// first attempt derived the second word from `serial / 5`, which is constant
+ /// across five consecutive serials — twenty-seven vendors came out as four
+ /// Flasks, four Lanterns and three Anvils in a row. Salting and re-mixing per
+ /// draw is what fixes that: each word is an independent hash of the pair.
+ ///
+ private static int Pick(int serial, int salt, int modulus)
+ {
+ unchecked
+ {
+ uint h = (uint)serial ^ ((uint)salt * 0x9E3779B1u);
+ h ^= h >> 15;
+ h *= 2246822519u;
+ h ^= h >> 13;
+ h *= 3266489917u;
+ h ^= h >> 16;
+ return (int)(h % (uint)modulus);
+ }
+ }
+
+ private static string PersonName(int serial)
+ {
+ return Given[Pick(serial, 1, Given.Length)] + " " + Family[Pick(serial, 2, Family.Length)];
+ }
+
+ private static string ShopSign(int serial)
+ {
+ return ShopFirst[Pick(serial, 3, ShopFirst.Length)] + " " +
+ ShopSecond[Pick(serial, 4, ShopSecond.Length)];
+ }
+
+ private static bool LooksSeeded(string name, string marker)
+ {
+ return name != null && name.StartsWith(marker, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// True when a name is one this pass could have produced.
+ ///
+ /// Dressing has to be re-runnable in both directions: a first pass renames what the
+ /// seeder left, and a later pass — after the tables or the hash change — has to be
+ /// able to rename its own earlier output. A name is recognised by MEMBERSHIP of the
+ /// closed tables rather than by a marker on the object, because the object is a
+ /// PlayerVendor whose name is a plain string with nowhere to hide a flag, and a
+ /// name that is not in the tables was set by a person and is left alone.
+ ///
+ private static bool IsOurs(string name, string[] first, string[] second)
+ {
+ if (String.IsNullOrEmpty(name))
+ return false;
+
+ foreach (var a in first)
+ {
+ if (!name.StartsWith(a + " ", StringComparison.Ordinal))
+ continue;
+
+ var rest = name.Substring(a.Length + 1);
+
+ foreach (var b in second)
+ {
+ if (rest == b)
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool IsOurHouseName(string name)
+ {
+ foreach (var h in HouseNames)
+ {
+ if (h == name)
+ return true;
+ }
+
+ return false;
+ }
+
+ private static int DressCharacters()
+ {
+ int n = 0;
+
+ foreach (Account acct in Accounts.GetAccounts())
+ {
+ if (!acct.Username.StartsWith(Prefix, StringComparison.Ordinal))
+ continue;
+
+ for (int i = 0; i < acct.Length; i++)
+ {
+ var m = acct[i];
+
+ if (m == null || !LooksSeeded(m.Name, "Seed"))
+ continue;
+
+ // Offset by the slot so an account's three characters are three people
+ // rather than three spellings of one.
+ m.Name = PersonName(m.Serial.Value + i * 101);
+ n++;
+ }
+ }
+
+ return n;
+ }
+
+ private static int DressVendors()
+ {
+ int n = 0;
+
+ if (PlayerVendor.PlayerVendors == null)
+ return 0;
+
+ // PlayerVendors is a live collection; the rename does not add or remove members,
+ // but copy anyway so an unrelated vendor placement mid-pass cannot invalidate it.
+ var vendors = new List(PlayerVendor.PlayerVendors);
+
+ foreach (var vendor in vendors)
+ {
+ bool touched = false;
+
+ if (LooksSeeded(vendor.ShopName, "Seed Shop") ||
+ IsOurs(vendor.ShopName, ShopFirst, ShopSecond))
+ {
+ var sign = ShopSign(vendor.Serial.Value);
+
+ if (sign != vendor.ShopName)
+ {
+ vendor.ShopName = sign;
+ touched = true;
+ }
+ }
+
+ if (LooksSeeded(vendor.Name, "seed vendor"))
+ {
+ vendor.Name = PersonName(vendor.Serial.Value + 7919);
+ touched = true;
+ }
+
+ if (touched)
+ n++;
+ }
+
+ return n;
+ }
+
+ private static int DressHouses()
+ {
+ int n = 0;
+
+ foreach (var house in BaseHouse.AllHouses)
+ {
+ if (house.Sign == null)
+ continue;
+
+ if (!LooksSeeded(house.Sign.Name, "Seed House") && !IsOurHouseName(house.Sign.Name))
+ continue;
+
+ var name = HouseNames[Pick(house.Serial.Value, 5, HouseNames.Length)];
+
+ if (name == house.Sign.Name)
+ continue;
+
+ house.Sign.Name = name;
+ n++;
+ }
+
+ return n;
+ }
+
+ ///
+ /// Puts a few condemned houses back into the final decay stages.
+ ///
+ /// Only houses that CAN decay are touched — an active owner's AutoRefresh house is
+ /// left alone, because forcing one into IDOC would be inventing a state the game
+ /// would never produce and the next refresh would undo it anyway.
+ ///
+ private static int StageIdoc()
+ {
+ int n = 0;
+
+ foreach (var house in BaseHouse.AllHouses)
+ {
+ if (n >= IdocHouses)
+ break;
+
+ if (!house.CanDecay || house.DecayLevel == DecayLevel.IDOC)
+ continue;
+
+ house.SetDynamicDecay(n % 2 == 0 ? DecayLevel.IDOC : DecayLevel.Greatly);
+ house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
+ n++;
+ }
+
+ return n;
+ }
+
+ ///
+ /// Sets a known password on one seeded account so a character can be logged in with
+ /// a real client. The seeder assigns a random GUID, which nobody kept.
+ ///
+ private static bool SetLoginPassword(Mobile to)
+ {
+ var password = LoginPassword;
+
+ if (String.IsNullOrEmpty(password))
+ return false;
+
+ var acct = Accounts.GetAccount(LoginAccount) as Account;
+
+ if (acct == null)
+ {
+ Report(to, "No account " + LoginAccount + " — password not set.");
+ return false;
+ }
+
+ acct.SetPassword(password);
+
+ // The seeder backdates some accounts past InactiveDuration to condemn their
+ // houses. This one has to be able to log in, so bring it back to the present.
+ acct.LastLogin = DateTime.UtcNow;
+
+ return true;
+ }
+ }
+}
diff --git a/tools/scaffolding/README.md b/tools/scaffolding/README.md
index e5d0688..7eb50ff 100644
--- a/tools/scaffolding/README.md
+++ b/tools/scaffolding/README.md
@@ -13,6 +13,7 @@ These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlo
| `BridgeLinkProbe.cs` | `Scripts/Custom/BridgeLinkProbe.cs` | Triggers `[link` for seed_001 without a client, then saves so the `WebsiteUserId` tag reaches `accounts.xml`. Flag: `LinkProbeOnStart`. Pair with a sidecar that reads the code and sends `link.confirm`. |
| `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. |
| `BridgeVendorSaleProbe.cs` | `Scripts/Custom/BridgeVendorSaleProbe.cs` | Fires `PlayerVendorSale` (Phase 7) with real seeded-vendor data so `vendor.sale` can be verified without a live buy. Requires the Phase 7 patches applied. Flag: `VendorSaleProbeOnStart`. |
+| `BridgeDemoDress.cs` | `Scripts/Custom/BridgeDemoDress.cs` | Renames a seeded world so it is presentable in a screenshot: shop signs, vendor and character names, house signs. Also stages a few condemned houses back into IDOC, and sets a known password on `seed_000` so a character can be logged in. Flags: `DemoDressOnStart`, `DemoDressPassword`. In game: `[demodress`. |
## Deploy overwrites Bridge.cfg
@@ -34,6 +35,41 @@ Because `Config.Get` returns `false` for a missing key, a server whose `Bridge.c
In-game, `[seedworld` and `[unseedworld` (Administrator) do the same work on a live shard.
+## Dressing a seeded world for screenshots
+
+`BridgeSeeder` builds a world at realistic **scale**, which is all the bridge ever needed. It does not
+build one that looks like anything: a vendor is `seed vendor` trading as `Seed Shop 810`, a character
+is `Seed004A`, a house sign says `Seed House 12`. Those strings travel the whole bridge and land on
+the marketplace, the guild roster and the housing pages of the website — fine for a protocol test,
+wrong for a screenshot.
+
+`BridgeDemoDress.cs` renames them in place. It seeds nothing: prices, listing counts, decay stages,
+fame and skills stay exactly as the seeder left them and as the shard has moved them since, so the
+data keeps its provenance and only the strings a human reads change. Names are drawn from fixed
+tables by a hash of each object's serial, so a re-run reproduces the same world, and shop and house
+names are re-dressed when they are names the pass itself produced — so a change to the tables can be
+applied to a world that has already been through here.
+
+```ini
+DemoDressOnStart=True
+DemoDressPassword=
+```
+
+Boot once, then set `DemoDressOnStart=False`. The password is written to `seed_000` so a real client
+can log a character in — the only way to make the website's online roster non-empty — and it is read
+from the config rather than compiled in, so it never lands in source control.
+
+**It dresses seeded objects only, which means your own characters keep their names.** That is the
+right behaviour for a test shard and a thing to remember before pointing a camera at one: a dev
+world usually also holds the accounts, characters, guilds and houses of whoever built it, and those
+are real identifiers on a page that may end up public.
+
+**The sidecar's board is cached, so the website lags a rename.** A shop name reaches the site on the
+next market sweep, and a sweep advances `MarketSweepBatch` vendors per tick — 27 vendors at the
+defaults is two ticks. Allow a couple of minutes before concluding that a rename failed. This cost a
+debugging detour once: the shard had the new names all along and the sidecar was still serving the
+previous ones.
+
## Back up `Saves/` first
`[seedworld` and `SeedOnStart` **write to the live world**. Copy `Saves/` somewhere outside the repo before running either. `Backups/Automatic` is rotated by `AutoSave.cs` and `Backups/Temp` is deleted outright, so neither is a safe destination.