diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
index 9fc1141..5e662d3 100644
--- a/overlay/Config/Bridge.cfg
+++ b/overlay/Config/Bridge.cfg
@@ -340,6 +340,30 @@ AssetScanMs=3000
AssetPlayerDirection=0
AssetCreatureDirection=1
+# The tree plane (docs/link/v8.md §10, phase 7). A THIRD switch, for a third consent:
+# the asset switch above is about this host's UO client, which came from EA. This one is
+# about the shard's own configuration -- Spawns/*.xml, Data/Regions.xml,
+# Data/Locations/*.xml, Config/ChampionSpawns.xml and Data/Decoration/**.cfg -- which is
+# the operator's own work and is what the website's spawn atlas is built from. Before
+# protocol 8 the website read those files off a shared filesystem; that was the one place
+# the platform's own rule (only the sidecar bridges the shard) was broken, and broken by
+# the component that faces the internet. Turning this off closes the bridge route and
+# leaves that shared-filesystem path as the only way an atlas can be built.
+#
+# Reads only, and only those five groups. Nothing here joins a path the website sent: a
+# request names a label this shard itself enumerated, or it is refused.
+TreeEnabled=true
+
+# How much of a tree file one chunk carries, BEFORE compression. Chunking is not an
+# optimisation here, it is what makes a spawn file transferable: a stock trammel.xml is
+# 4.03 MB, the sidecar discards any inbound line over 1 MiB, and the whole file as one
+# base64 row would time out and be re-requested forever with no error anywhere. Each
+# chunk is gzipped (a spawn file compresses ~18x, so a chunk is typically 40 KB on the
+# wire), but the BOUND comes from the chunk rather than the compression, because nothing
+# guarantees input compresses at all. Clamped to [64 KiB, 512 KiB]: at the ceiling a
+# worst-case incompressible chunk is ~683 KiB of base64, which still fits the wire.
+TreeChunkBytes=524288
+
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
# Config.Get returns the default of false when a key is missing, so a deployed
diff --git a/overlay/Scripts/Custom/Bridge/BridgeArt.cs b/overlay/Scripts/Custom/Bridge/BridgeArt.cs
index 3cdbfe1..c00ef8c 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeArt.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeArt.cs
@@ -175,7 +175,7 @@ namespace Server.Custom.Bridge
string id = SourceId();
- if (expected != null && expected != id)
+ if (BridgeAssets.CatalogMismatch(expected, id))
{
BridgeAssets.Fail(reqId, "UNREADABLE",
"the shard's client files changed since that catalogue was read (catalog "
diff --git a/overlay/Scripts/Custom/Bridge/BridgeAssets.cs b/overlay/Scripts/Custom/Bridge/BridgeAssets.cs
index 5cb2417..1490de0 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeAssets.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeAssets.cs
@@ -135,6 +135,10 @@ namespace Server.Custom.Bridge
BridgeBoot.RegisterHandler("assets.sources", OnSources);
BridgeBoot.RegisterHandler("assets.fetch", OnFetch);
+
+ // Owned here since phase 7, for the same reason `assets.fetch` moved here in phase 5:
+ // it is the transport, and more than one family has something to enumerate.
+ BridgeBoot.RegisterHandler("assets.manifest", OnManifest);
}
///
@@ -221,7 +225,11 @@ namespace Server.Custom.Bridge
return;
}
- if (!BridgeConfig.AssetsEnabled)
+ // Stage 1 answers for the whole plane, not for the client files alone: since phase 7
+ // an operator can serve the shard's own configuration tree while declining to serve
+ // their UO client, and `families` is where a website discovers which. Refused only
+ // when there is nothing at all to report.
+ if (Families().Count == 0)
{
Fail(reqId, "DISABLED", "asset extraction is disabled on this shard");
return;
@@ -294,8 +302,33 @@ namespace Server.Custom.Bridge
///
internal delegate void FamilyFetch(string reqId, List keys, string catalog, string cursor);
- private static readonly Dictionary _families =
- new Dictionary(StringComparer.Ordinal);
+ ///
+ /// One family's answer to a manifest walk — everything it can serve, no payload.
+ /// Runs on the asset worker, never the Core thread. A family with nothing to
+ /// enumerate (statics and land are addressed, not listed) registers none.
+ ///
+ internal delegate void FamilyManifest(string reqId, string cursor);
+
+ ///
+ /// What one §5 key family registered: how to serve it, how to list it, and — since
+ /// phase 7 — which operator consent it answers to.
+ ///
+ /// The gate is per family rather than per plane because the planes are not one
+ /// consent. `body`, `static` and `land` are the operator's UO CLIENT, licensed from
+ /// EA and read off their disk; `tree` is the shard's OWN configuration, which they
+ /// wrote. An operator can reasonably want the second published and not the first, and
+ /// before this the atlas would have been what silently disappeared when they said so.
+ ///
+ private sealed class FamilyReader
+ {
+ public FamilyFetch Fetch;
+ public FamilyManifest Manifest;
+ public Func Enabled;
+ public string DisabledReason;
+ }
+
+ private static readonly Dictionary _families =
+ new Dictionary(StringComparer.Ordinal);
///
/// Claims one §5 key family for a reader.
@@ -312,33 +345,114 @@ namespace Server.Custom.Bridge
/// handler does not read until a request arrives.
///
internal static void RegisterFamily(string name, FamilyFetch fetch)
+ {
+ RegisterFamily(name, fetch, null, null, null);
+ }
+
+ ///
+ /// The full registration: a fetch reader, an optional manifest reader, and the
+ /// consent this family answers to.
+ ///
+ /// null means the asset plane's own gate
+ /// (Bridge.AssetsEnabled), which is what every client-file family wants.
+ /// A family that reads something else entirely passes its own.
+ ///
+ internal static void RegisterFamily(string name, FamilyFetch fetch, FamilyManifest manifest,
+ Func enabled, string disabledReason)
{
lock (_families)
{
- _families[name] = fetch;
+ _families[name] = new FamilyReader
+ {
+ Fetch = fetch,
+ Manifest = manifest,
+ Enabled = enabled,
+ DisabledReason = disabledReason
+ };
}
}
- /// The families this shard can serve, for §6's stage 1 and for diagnostics.
+ ///
+ /// The families this shard can serve **right now**, for §6's stage 1 and for
+ /// diagnostics.
+ ///
+ /// Filtered by consent rather than by registration, because that is the question the
+ /// website is actually asking: a family it can see in this list is one it can fetch.
+ /// Listing a family the operator has switched off would turn one clear refusal at
+ /// import time into a per-key refusal on every pass, forever — which is exactly the
+ /// failure `families` was added in phase 5 to prevent.
+ ///
internal static List Families()
{
+ var names = new List();
+
lock (_families)
{
- var names = new List(_families.Keys);
- names.Sort(StringComparer.Ordinal);
- return names;
+ foreach (var pair in _families)
+ {
+ if (EnabledFor(pair.Value))
+ names.Add(pair.Key);
+ }
+ }
+
+ names.Sort(StringComparer.Ordinal);
+ return names;
+ }
+
+ private static bool EnabledFor(FamilyReader reader)
+ {
+ if (reader == null)
+ return false;
+
+ try
+ {
+ return reader.Enabled == null ? BridgeConfig.AssetsEnabled : reader.Enabled();
+ }
+ catch
+ {
+ // A gate that throws is a gate that has not consented.
+ return false;
}
}
- private static FamilyFetch FamilyFor(string name)
+ private static FamilyReader FamilyFor(string name)
{
lock (_families)
{
- FamilyFetch fetch;
- return _families.TryGetValue(name, out fetch) ? fetch : null;
+ FamilyReader reader;
+ return _families.TryGetValue(name, out reader) ? reader : null;
}
}
+ ///
+ /// Resolves a named family and answers the request itself when it cannot.
+ ///
+ /// Shared by assets.fetch and assets.manifest so the two cannot drift
+ /// apart about what "this shard does not serve that" means — and so the consent check
+ /// happens in exactly one place for both.
+ ///
+ private static bool Resolve(string reqId, string family, out FamilyReader reader)
+ {
+ reader = FamilyFor(family);
+
+ if (reader == null)
+ {
+ Fail(reqId, "BAD_REQUEST",
+ "this shard serves no '" + family + "' asset family (it serves "
+ + String.Join(", ", Families().ToArray()) + ")");
+ return false;
+ }
+
+ if (!EnabledFor(reader))
+ {
+ Fail(reqId, "DISABLED", reader.DisabledReason
+ ?? "asset extraction is disabled on this shard");
+ return false;
+ }
+
+ return true;
+ }
+
///
/// The family segment of a §5 key: everything before the first `/`.
///
@@ -373,12 +487,9 @@ namespace Server.Custom.Bridge
return;
}
- if (!BridgeConfig.AssetsEnabled)
- {
- Fail(reqId, "DISABLED", "asset extraction is disabled on this shard");
- return;
- }
-
+ // The consent check is NOT here any more (phase 7). It cannot be: which consent this
+ // request needs is a property of the keys, and the keys have not been read yet. So the
+ // shape checks come first and the gate happens in `Resolve`, once the family is known.
var keys = BridgeJson.GetStringList(o, "keys");
if (keys.Count == 0)
@@ -408,22 +519,70 @@ namespace Server.Custom.Bridge
return;
}
- FamilyFetch fetch = FamilyFor(family);
+ FamilyReader reader;
- if (fetch == null)
+ if (!Resolve(reqId, family, out reader))
+ return;
+
+ if (reader.Fetch == null)
{
Fail(reqId, "BAD_REQUEST",
- "this shard serves no '" + family + "' asset family (it serves "
- + String.Join(", ", Families().ToArray()) + ")");
+ "the '" + family + "' family cannot be fetched by key on this shard");
return;
}
var catalog = BridgeJson.GetString(o, "catalog");
var cursor = BridgeJson.GetString(o, "cursor");
+ FamilyFetch fetch = reader.Fetch;
Accept(reqId, "assets.fetch", () => fetch(reqId, keys, catalog, cursor));
}
+ ///
+ /// §14's `assets.manifest`, for every family that has one.
+ ///
+ /// Phase 3 gave this command to the body catalogue outright and phase 5 learned, for
+ /// `assets.fetch`, that the command is the transport and the family is a property of
+ /// the key. Phase 7 is where the same lesson lands one level up: the tree family
+ /// enumerates its files exactly the way the catalogue enumerates its bodies, and
+ /// nothing about the envelope, the cursor or the consent differs between them.
+ ///
+ /// **`family` still defaults to `body`.** A phase-3 website asks without naming one
+ /// and must keep getting the catalogue it asked for.
+ ///
+ private static void OnManifest(Dictionary o)
+ {
+ var reqId = BridgeJson.GetString(o, "reqId");
+
+ if (reqId == null)
+ {
+ Fail(null, "BAD_REQUEST", "assets.manifest requires a reqId");
+ return;
+ }
+
+ var family = BridgeJson.GetString(o, "family") ?? "body";
+
+ FamilyReader reader;
+
+ if (!Resolve(reqId, family, out reader))
+ return;
+
+ if (reader.Manifest == null)
+ {
+ // Named rather than defaulted: statics and land are ADDRESSED (§11.1) rather than
+ // listed, and a website that asked for a list of 49,152 item graphics has made a
+ // mistake it needs told about rather than an empty page it will read as "none".
+ Fail(reqId, "BAD_REQUEST",
+ "the '" + family + "' family is fetched by key and has no manifest");
+ return;
+ }
+
+ var cursor = BridgeJson.GetString(o, "cursor");
+ FamilyManifest manifest = reader.Manifest;
+
+ Accept(reqId, "assets.manifest", () => manifest(reqId, cursor));
+ }
+
///
/// ARGB1555 to a PNG with a transparent background.
///
@@ -511,6 +670,30 @@ namespace Server.Custom.Bridge
}
}
+ ///
+ /// Does a caller's asserted catalog id disagree with what this shard holds?
+ ///
+ /// **An absent fingerprint and an empty one mean the same thing**, and that is the
+ /// whole reason this is a function rather than an inline `expected != null`. A caller
+ /// with nothing to assert sends the field absent or empty depending on how its own
+ /// client serialises a missing value, and treating `""` as a real id refuses **every**
+ /// fetch it makes — with a sentence naming no catalog at all ("catalog is now
+ /// 8159778b"), which reads as a shard fault rather than a caller one.
+ ///
+ /// Phase 7 found this on the tree family, where a probe passed an empty string by
+ /// accident. It was inline in three places by then; it is one function now, because
+ /// three copies of a comparison are three chances for the next family to get it wrong
+ /// in a way only a differently-written client would ever reveal.
+ ///
+ /// Note this is deliberately NOT the shape `BridgeLeases` uses for its own `expected`:
+ /// there the value is a world property, where an empty string is a legitimate thing to
+ /// expect and `!= null` is correct.
+ ///
+ internal static bool CatalogMismatch(string expected, string actual)
+ {
+ return !String.IsNullOrEmpty(expected) && !String.Equals(expected, actual, StringComparison.Ordinal);
+ }
+
///
/// SHA-256, lowercase hex. Shared because the hash in a manifest row, the hash in a
/// fetch row and the hash the website stores must be one function.
@@ -644,7 +827,13 @@ namespace Server.Custom.Bridge
var sb = BridgeJson.Begin("assets.sources.ok");
sb.Str("reqId", reqId)
- .Num("extractorVersion", EXTRACTOR_VERSION);
+ .Num("extractorVersion", EXTRACTOR_VERSION)
+ // Which of the two consents this shard has given (phase 7). Without it a website
+ // whose operator switched client-file extraction off would read an empty `files`
+ // array as "your client has no cliloc.enu" — a sentence that sends them looking at
+ // their client install for a setting that lives on their shard.
+ .Bool("assetsEnabled", BridgeConfig.AssetsEnabled)
+ .Bool("treeEnabled", BridgeConfig.TreeEnabled);
WriteImaging(sb);
@@ -676,7 +865,11 @@ namespace Server.Custom.Bridge
var page = new PageBuilder(sb, "files", BridgeConfig.AssetBatchBytes);
bool anyMissingHash = false;
- for (int i = 0; i < SourceFiles.Length; i++)
+ // The client files are the asset plane's own subject, so they are listed under the
+ // asset plane's own consent. A tree-only shard answers this call — that is how its
+ // website learns the `tree` family exists — and reports no client files at all,
+ // which is the truthful answer to "what may I read here".
+ for (int i = 0; BridgeConfig.AssetsEnabled && i < SourceFiles.Length; i++)
{
string name = SourceFiles[i];
string path = ResolvePath(name);
diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
index 48cb9ff..8ce6170 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
@@ -263,6 +263,7 @@ namespace Server.Custom.Bridge
e.Mobile.SendMessage("Bridge: {0}", BridgeAssets.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeCatalog.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeArt.Status());
+ e.Mobile.SendMessage("Bridge: {0}", BridgeTree.Status());
break;
}
}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs b/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs
index 6cd82c6..859bc89 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs
@@ -120,11 +120,12 @@ namespace Server.Custom.Bridge
if (!BridgeConfig.Enabled)
return;
- BridgeBoot.RegisterHandler("assets.manifest", OnManifest);
-
- // `assets.fetch` is shared plumbing as of phase 5 (§5): BridgeAssets owns the command,
- // decides which family a batch of keys belongs to, and calls the reader that owns it.
- BridgeAssets.RegisterFamily(Family, ReplyFetch);
+ // Both commands are shared plumbing: `assets.fetch` since phase 5 and
+ // `assets.manifest` since phase 7 (§5, §10). BridgeAssets owns the correlation id, the
+ // operator's consent, the key ceiling and the family decision; what is registered here
+ // is only this family's two readers, and each is called on the asset worker with work
+ // it owns.
+ BridgeAssets.RegisterFamily(Family, ReplyFetch, ReplyManifest, null, null);
}
// ── the cache ────────────────────────────────────────────────────────────────────────
@@ -186,32 +187,6 @@ namespace Server.Custom.Bridge
// ── assets.manifest ──────────────────────────────────────────────────────────────────
- private static void OnManifest(Dictionary o)
- {
- string reqId;
-
- if (!Admit(o, "assets.manifest", out reqId))
- return;
-
- var family = BridgeJson.GetString(o, "family") ?? Family;
-
- if (!String.Equals(family, Family, StringComparison.Ordinal))
- {
- // Named rather than ignored: `family` exists so §5's statics and land can join
- // this envelope in phase 5 without a second request kind, and a website that
- // asked for one of those against a phase-3 overlay must be told it asked too
- // early rather than handed a body catalogue it did not request.
- BridgeAssets.Fail(reqId, "BAD_REQUEST",
- "this shard serves the '" + Family + "' asset family only (asked for '"
- + family + "')");
- return;
- }
-
- var cursor = BridgeJson.GetString(o, "cursor");
-
- BridgeAssets.Accept(reqId, "assets.manifest", () => ReplyManifest(reqId, cursor));
- }
-
///
/// Worker thread. Scans forward from the cursor until the byte budget or the time
/// budget is spent, hashing what it decodes and keeping the bytes for the fetch.
@@ -382,7 +357,7 @@ namespace Server.Custom.Bridge
string id = SourceId();
- if (expected != null && expected != id)
+ if (BridgeAssets.CatalogMismatch(expected, id))
{
// The client files moved between the manifest and this fetch. Refusing is the only
// honest answer: the keys were chosen against a catalogue that no longer describes
@@ -996,29 +971,6 @@ namespace Server.Custom.Bridge
// ── shared plumbing ──────────────────────────────────────────────────────────────────
- ///
- /// The two gates every request on this plane passes: a correlation id, and the
- /// operator's consent. Both refuse rather than answer.
- ///
- private static bool Admit(Dictionary o, string kind, out string reqId)
- {
- reqId = BridgeJson.GetString(o, "reqId");
-
- if (reqId == null)
- {
- BridgeAssets.Fail(null, "BAD_REQUEST", kind + " requires a reqId");
- return false;
- }
-
- if (!BridgeConfig.AssetsEnabled)
- {
- BridgeAssets.Fail(reqId, "DISABLED", "asset extraction is disabled on this shard");
- return false;
- }
-
- return true;
- }
-
///
/// The five anim files' index and record readers — and, since phase 4, the five UOP
/// packages beside them — opened for one reply and closed with it. Holding them across
diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
index 60adaa7..74988c0 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
@@ -124,6 +124,36 @@ namespace Server.Custom.Bridge
public static int AssetPlayerDirection { get; private set; }
public static int AssetCreatureDirection { get; private set; }
+ // ---- the tree plane (docs/link/v8.md §10, phase 7) ----
+ //
+ // Its OWN gate, and the third one on this link for the third kind of consent. The asset
+ // gate above is the operator agreeing that the website may read THEIR UO CLIENT -- art
+ // and animations and a string table that came from EA. This one is the operator agreeing
+ // that it may read THE SHARD'S OWN CONFIGURATION: the spawn files, the region and
+ // location definitions, the champion table, the decoration lists. Those are the
+ // operator's own work rather than a licensed client, and they are what the spawn atlas is
+ // built out of -- so a shard that declines to serve client art must still be able to
+ // publish where its creatures live. One switch could not have expressed both, and the
+ // atlas would have been the thing that silently disappeared.
+ //
+ // Reads only, and only the five labelled groups SPAWN_ATLAS.md already names. Nothing
+ // here joins a path the website sent: a request names a label this shard enumerated, or
+ // it is refused.
+ public static bool TreeEnabled { get; private set; }
+
+ // How much of a tree file one chunk carries, BEFORE compression (§10). The chunk is the
+ // thing that makes this transferable at all: a stock Spawns/trammel.xml is 4.03 MB and
+ // the sidecar discards any inbound line over 1 MiB, so the file as a single base64 row
+ // could never arrive -- it would time out and be re-requested forever, which is a failure
+ // with no error in it anywhere.
+ //
+ // Compression is what makes it cheap (a spawn file gzips ~18x, so a chunk is typically
+ // 40 KB on the wire) and the chunk is what makes it BOUNDED: gzip cannot be relied on to
+ // shrink anything, so the ceiling has to hold for input that does not compress at all.
+ // At 512 KiB a worst-case incompressible chunk is ~683 KiB of base64, which still fits
+ // the wire under AssetBatchBytes' deliberate factor of two.
+ public static int TreeChunkBytes { get; private set; }
+
// How many bytes of rendered item and land art the shard holds between requests (§11,
// phase 5). This is a convenience, not a store: the website keeps every picture it fetches
// and does not ask twice, so what this actually buys is the second page of a batch, a
@@ -239,6 +269,19 @@ namespace Server.Custom.Bridge
if (AssetArtCacheBytes > 64 * 1024 * 1024)
AssetArtCacheBytes = 64 * 1024 * 1024;
+ TreeEnabled = Config.Get("Bridge.TreeEnabled", true);
+
+ // Floor and ceiling both matter. Below 64 KiB a stock tree is thousands of chunks and
+ // the per-row overhead starts to dominate the payload; above 512 KiB an incompressible
+ // chunk stops fitting inside the sidecar's inbound line cap, which is the one bound
+ // this number exists to respect. Kept equal to AssetBatchBytes' own ceiling so the two
+ // budgets cannot drift into disagreeing about the same wire.
+ TreeChunkBytes = Config.Get("Bridge.TreeChunkBytes", 512 * 1024);
+ if (TreeChunkBytes < 64 * 1024)
+ TreeChunkBytes = 64 * 1024;
+ if (TreeChunkBytes > 512 * 1024)
+ TreeChunkBytes = 512 * 1024;
+
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
diff --git a/overlay/Scripts/Custom/Bridge/BridgeTree.cs b/overlay/Scripts/Custom/Bridge/BridgeTree.cs
new file mode 100644
index 0000000..0727cdd
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgeTree.cs
@@ -0,0 +1,775 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.IO.Compression;
+using System.Text;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// **The shard's own configuration, over the bridge** (docs/link/v8.md §10 — protocol 8,
+ /// phase 7).
+ ///
+ /// Everything else on the asset plane reads the operator's UO CLIENT. This family reads
+ /// the shard's own files: the spawn tables, the region and location definitions, the
+ /// champion list and the decoration lists. The website parses those into its spawn atlas —
+ /// where every creature lives, which regions exist, what this shard calls scenery — and
+ /// until protocol 8 it did so by **reading the ServUO tree off a shared filesystem**:
+ /// same host, a bind mount, or a shared volume.
+ ///
+ /// That was the one place the platform's own rule was broken, and broken by the component
+ /// that faces the internet. This closes it. The parsers do not move — `spawnAtlasParse.js`
+ /// is pure, fs-free and covered by CI without a ServUO tree anywhere near it, and every
+ /// quirk it handles stays exactly where it is. The shard sends bytes; the website still
+ /// decides what they mean.
+ ///
+ /// ── What phase 7 measured, and the shape it forced ────────────────────────────────
+ ///
+ /// §10 said "the shard serves `tree/<label>` → bytes". Measured against a stock 57.4
+ /// tree, it cannot: `Spawns/trammel.xml` is **4.03 MB**, the sidecar discards any inbound
+ /// line over **1 MiB** (`shard.rs` `MAX_INBOUND_LINE_BYTES`), and that file as a single
+ /// base64 row is 5.4 MiB. It would never arrive — the reply would be discarded, the
+ /// request would time out, and the import would retry forever with no error anywhere in
+ /// it. Two files on a *stock* tree are in that state; a shard with hand-built spawn tables
+ /// has more.
+ ///
+ /// So a file crosses as **chunks, each gzipped**:
+ ///
+ ///
+ /// tree/Spawns/trammel.xml the manifest row — size, hash, chunk count
+ /// tree/Spawns/trammel.xml/c0 the first 512 KiB of it, gzipped
+ /// tree/Spawns/trammel.xml/c1 the next
+ ///
+ ///
+ /// which is §5's depth scheme at work a second time, exactly as `body/400/a0/f0` is —
+ /// and, as there, nothing about it needed a protocol change.
+ ///
+ /// **The chunk is the bound and the compression is the saving**, and it matters which is
+ /// which. Compression is what makes this cheap: the stock tree is 11.34 MB and gzips to
+ /// 927 KB, so the whole atlas source arrives in about three pages instead of thirty-one.
+ /// But nothing guarantees that an operator's files compress at all, so the ceiling has to
+ /// hold when they do not — and it does, because a 512 KiB chunk that refuses to compress
+ /// is still only ~683 KiB of base64, inside the wire cap that
+ /// ' deliberate factor of two leaves room for.
+ /// A design that leaned on the ratio would work on every tree anyone tested and fail on
+ /// the first one nobody did.
+ ///
+ /// ── Two rules that are not negotiable here ────────────────────────────────────────
+ ///
+ /// **1. The label set is this shard's, never the caller's.** This is the only family on
+ /// this link whose keys look like paths, and the website is the internet-facing component.
+ /// So nothing here joins a path that arrived on the wire: a fetch resolves its label
+ /// against the set itself produced, and a label that is not in it
+ /// is refused — before any file is opened, and whatever it spells. The five groups are
+ /// fixed in code, the extensions are fixed in code, and the resolved path is checked to be
+ /// under the tree root even after all of that.
+ ///
+ /// **2. A row re-declares its own address.** Each chunk carries its label, its index, its
+ /// byte offset and the hash of its own (uncompressed) bytes, and the manifest carries the
+ /// hash of the whole file. That is the §4.10 lesson on a fourth axis: a reassembly that
+ /// silently put chunk 3 where chunk 4 belongs would produce a file that parses — XML is
+ /// forgiving about what it skips — and a spawn atlas subtly missing a facet. Per-chunk
+ /// hashes make it a named error instead.
+ ///
+ public static class BridgeTree
+ {
+ /// The §5 key family this serves.
+ private const string Family = "tree";
+
+ ///
+ /// The five labelled groups `spawnAtlasSource.js` reads, and nothing else.
+ ///
+ /// Fixed in code rather than configured, because a configurable list is a way for the
+ /// website to ask for a file this shard never meant to publish. An operator who wants
+ /// a different tree served wants a different feature.
+ ///
+ private static readonly string[] SingleFiles =
+ {
+ "Data/Regions.xml",
+ "Config/ChampionSpawns.xml"
+ };
+
+ private const string LocationsDir = "Data/Locations";
+ private const string SpawnsDir = "Spawns";
+ private const string DecorationDir = "Data/Decoration";
+
+ public static void Initialize()
+ {
+ if (!BridgeConfig.Enabled)
+ return;
+
+ // Its own consent, not the asset plane's (§10, phase 7). An operator who declines to
+ // serve their UO client still gets a spawn atlas, because these are their own files.
+ BridgeAssets.RegisterFamily(Family, ReplyFetch, ReplyManifest,
+ () => BridgeConfig.TreeEnabled,
+ "the shard's configuration tree is not served (Bridge.TreeEnabled is off)");
+ }
+
+ // ── the file set ─────────────────────────────────────────────────────────────────────
+
+ private sealed class TreeFile
+ {
+ public string Label;
+ public string Path;
+ public long Bytes;
+ public long MTime;
+ }
+
+ ///
+ /// Every atlas source file this shard has, tree-relative and forward-slashed.
+ ///
+ /// The labels are `spawnAtlasSource.js`'s own, character for character, because they
+ /// are what the website keys its stored fingerprint on: the same tree read here and
+ /// read there has to produce the same label or every import looks like a change.
+ /// Forward slashes for the same reason — a Windows shard and a Linux one must agree.
+ ///
+ private static List Enumerate()
+ {
+ string root = Core.BaseDirectory;
+ var files = new List();
+
+ foreach (string label in SingleFiles)
+ Add(files, root, label);
+
+ foreach (string label in ListByExtension(root, LocationsDir, ".xml"))
+ Add(files, root, label);
+
+ foreach (string label in ListByExtension(root, SpawnsDir, ".xml"))
+ Add(files, root, label);
+
+ foreach (string label in ListTree(root, DecorationDir, ".cfg"))
+ Add(files, root, label);
+
+ return files;
+ }
+
+ private static void Add(List files, string root, string label)
+ {
+ string path = Resolve(root, label);
+
+ if (path == null)
+ return;
+
+ try
+ {
+ var info = new FileInfo(path);
+
+ if (!info.Exists)
+ return;
+
+ files.Add(new TreeFile
+ {
+ Label = label,
+ Path = path,
+ Bytes = info.Length,
+ MTime = ToUnixMs(info.LastWriteTimeUtc)
+ });
+ }
+ catch (Exception e)
+ {
+ // A file the shard cannot stat is a file it cannot serve. Say so once, here,
+ // rather than as a refused row on every import pass forever.
+ Console.WriteLine("[Bridge] tree: cannot read {0}: {1}", label, e.Message);
+ }
+ }
+
+ /// One directory's files with the given extension, sorted, as labels.
+ private static List ListByExtension(string root, string dir, string extension)
+ {
+ var labels = new List();
+ string full = Path.Combine(root, dir.Replace('/', Path.DirectorySeparatorChar));
+
+ try
+ {
+ if (!Directory.Exists(full))
+ return labels;
+
+ foreach (string path in Directory.GetFiles(full))
+ {
+ string name = Path.GetFileName(path);
+
+ if (name.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
+ labels.Add(dir + "/" + name);
+ }
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine("[Bridge] tree: cannot list {0}: {1}", dir, e.Message);
+ }
+
+ labels.Sort(StringComparer.Ordinal);
+ return labels;
+ }
+
+ ///
+ /// One directory tree's files with the given extension, recursively.
+ ///
+ /// Recursive because `Data/Decoration` nests two deep in places (`Magincia/Trammel`,
+ /// `Stygian Abyss/Ter Mur`, `Old/Britannia`), and the website's own reader says why
+ /// that matters: a flat read indexes a third of what the shard has, and the failure is
+ /// an authoring dropdown quietly missing whole expansions rather than an error anyone
+ /// would notice.
+ ///
+ private static List ListTree(string root, string dir, string extension)
+ {
+ var labels = new List();
+ string full = Path.Combine(root, dir.Replace('/', Path.DirectorySeparatorChar));
+
+ try
+ {
+ if (!Directory.Exists(full))
+ return labels;
+
+ foreach (string path in Directory.GetFiles(full, "*", SearchOption.AllDirectories))
+ {
+ if (!path.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ string rel = path.Substring(full.Length).Replace('\\', '/').TrimStart('/');
+
+ if (rel.Length > 0)
+ labels.Add(dir + "/" + rel);
+ }
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine("[Bridge] tree: cannot walk {0}: {1}", dir, e.Message);
+ }
+
+ labels.Sort(StringComparer.Ordinal);
+ return labels;
+ }
+
+ ///
+ /// A label to a path on this host, or null if it is not one this shard serves.
+ ///
+ /// Rule 1 of the class doc lives here. The label has already been matched against the
+ /// enumerated set by the time a fetch calls this, and this still refuses anything with
+ /// a traversal segment, a drive or a root in it, and still checks that what
+ /// Path.GetFullPath produced is under the tree root. Three checks for one rule
+ /// because the cost of being wrong once is the website reading an arbitrary file off a
+ /// game server's disk.
+ ///
+ private static string Resolve(string root, string label)
+ {
+ if (String.IsNullOrEmpty(label) || label.IndexOf('\\') >= 0)
+ return null;
+
+ string[] segments = label.Split('/');
+
+ foreach (string segment in segments)
+ {
+ if (segment.Length == 0 || segment == "." || segment == "..")
+ return null;
+ }
+
+ if (Path.IsPathRooted(label))
+ return null;
+
+ try
+ {
+ string rootFull = Path.GetFullPath(root);
+ string full = Path.GetFullPath(Path.Combine(rootFull,
+ label.Replace('/', Path.DirectorySeparatorChar)));
+
+ if (!rootFull.EndsWith(Path.DirectorySeparatorChar.ToString(CultureInfo.InvariantCulture),
+ StringComparison.Ordinal))
+ {
+ rootFull += Path.DirectorySeparatorChar;
+ }
+
+ return full.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase) ? full : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ // ── the fingerprint ──────────────────────────────────────────────────────────────────
+
+ ///
+ /// What the whole tree currently is, in sixteen hex characters.
+ ///
+ /// The same job BridgeCatalog.SourceId does for client files, and the same
+ /// reason: it goes on every page of a walk, and a page whose id differs from the
+ /// first's means the operator edited a spawn file while it was being read. Half of
+ /// what arrived then describes a tree that no longer exists and nothing later can tell
+ /// which half, so the website refuses the import outright rather than stitching one.
+ ///
+ /// Built from (label, size, mtime) rather than from content hashes, because it is
+ /// computed on every page and hashing the tree's contents each time would spend a
+ /// tenth of a second per page to answer a question (size, mtime) answers for free.
+ /// The CONTENT hashes are still sent — once, per file, on the manifest — which is
+ /// where the website's own drift gate reads them from.
+ ///
+ private static string FingerprintOf(List files)
+ {
+ var sb = new StringBuilder(256);
+
+ sb.Append(files.Count);
+
+ foreach (TreeFile file in files)
+ {
+ sb.Append('|').Append(file.Label)
+ .Append(':').Append(file.Bytes.ToString(CultureInfo.InvariantCulture))
+ .Append(':').Append(file.MTime.ToString(CultureInfo.InvariantCulture));
+ }
+
+ return BridgeAssets.Sha256Hex(Encoding.UTF8.GetBytes(sb.ToString())).Substring(0, 16);
+ }
+
+ // ── assets.manifest, for this family ─────────────────────────────────────────────────
+
+ ///
+ /// Worker thread. Every file this shard would serve, with its size, its content hash
+ /// and how many chunks it takes — and no bytes.
+ ///
+ /// That separation is what makes the normal case free. The website stores these
+ /// hashes; on the next import it asks for this list again, compares, and fetches
+ /// nothing at all when nothing moved — which on a shard whose maps are not being
+ /// edited is every import.
+ ///
+ /// A stock tree is 141 rows and fits in one page comfortably. It pages anyway, by the
+ /// same envelope as every other family, because the day a shard has three thousand
+ /// decoration files is not the day to discover this was the one walk that could not
+ /// end.
+ ///
+ private static void ReplyManifest(string reqId, string cursor)
+ {
+ List files = Enumerate();
+ string fingerprint = FingerprintOf(files);
+
+ int from = ParseCursor(cursor);
+
+ if (from < 0 || from > files.Count)
+ from = 0;
+
+ var sb = BridgeJson.Begin("assets.manifest.ok");
+
+ sb.Str("reqId", reqId)
+ .Str("family", Family)
+ .Str("catalog", fingerprint)
+ .Num("chunkBytes", BridgeConfig.TreeChunkBytes)
+ .Num("total", files.Count)
+ .Num("from", from);
+
+ var page = new BridgeAssets.PageBuilder(sb, "rows", BridgeConfig.AssetBatchBytes);
+
+ int i = from;
+
+ for (; i < files.Count; i++)
+ {
+ TreeFile file = files[i];
+ string hash = HashFile(file.Path);
+
+ var item = new StringBuilder(256);
+
+ item.Append("{\"key\":");
+ BridgeJson.Text(item, Family + "/" + file.Label);
+ item.Append(",\"label\":");
+ BridgeJson.Text(item, file.Label);
+ item.Append(",\"bytes\":").Append(file.Bytes.ToString(CultureInfo.InvariantCulture));
+ item.Append(",\"mtime\":").Append(file.MTime.ToString(CultureInfo.InvariantCulture));
+ item.Append(",\"chunks\":").Append(
+ ChunkCount(file.Bytes).ToString(CultureInfo.InvariantCulture));
+ item.Append(",\"sha256\":");
+ BridgeJson.Text(item, hash);
+ item.Append('}');
+
+ if (!page.TryAdd(item.ToString(), "t:" + (i + 1).ToString(CultureInfo.InvariantCulture)))
+ break;
+ }
+
+ page.Close();
+
+ sb.Num("sent", page.Count);
+
+ BridgeLink.Emit(sb.End());
+ }
+
+ ///
+ /// How many chunks a file of this size takes.
+ ///
+ /// **An empty file is one chunk, not none.** `Data/Locations` can legitimately hold an
+ /// empty file, and zero chunks would make it a manifest row the website could never
+ /// fetch: it would wait for content that has no address, and report the import
+ /// incomplete forever.
+ ///
+ private static int ChunkCount(long bytes)
+ {
+ long chunk = BridgeConfig.TreeChunkBytes;
+ long count = (bytes + chunk - 1) / chunk;
+
+ return count < 1 ? 1 : (int)count;
+ }
+
+ // ── assets.fetch, for this family ────────────────────────────────────────────────────
+
+ ///
+ /// Worker thread. The bytes for an explicit list of chunk keys.
+ ///
+ /// Chunks are read with a seek rather than by holding the file, so the memory this
+ /// costs a running game server is one chunk regardless of how large an operator's
+ /// spawn tables are. A 4 MB file served eight times over is eight seeks and eight
+ /// 512 KiB reads — cheaper than caching it would be, and with no cache to invalidate
+ /// when the operator edits it mid-pass.
+ ///
+ private static void ReplyFetch(string reqId, List keys, string expected, string cursor)
+ {
+ List files = Enumerate();
+ string fingerprint = FingerprintOf(files);
+
+ // Shared with every other family on this plane, because an absent fingerprint and an
+ // empty one have to mean the same thing here and there — see
+ // `BridgeAssets.CatalogMismatch` for what treating them differently costs.
+ if (BridgeAssets.CatalogMismatch(expected, fingerprint))
+ {
+ // The tree moved between the manifest and this fetch. The same refusal the
+ // catalogue makes for a patched client, and for the same reason: these keys were
+ // chosen against a listing that no longer describes what is on disk.
+ BridgeAssets.Fail(reqId, "UNREADABLE",
+ "the shard's configuration tree changed since that manifest was read (catalog "
+ + expected + " is now " + fingerprint + "); start the import again");
+ return;
+ }
+
+ var byLabel = new Dictionary(StringComparer.Ordinal);
+
+ foreach (TreeFile file in files)
+ byLabel[file.Label] = file;
+
+ int from = ParseCursor(cursor);
+
+ if (from < 0 || from > keys.Count)
+ from = 0;
+
+ var sb = BridgeJson.Begin("assets.fetch.ok");
+
+ sb.Str("reqId", reqId)
+ .Str("family", Family)
+ .Str("catalog", fingerprint)
+ .Num("chunkBytes", BridgeConfig.TreeChunkBytes)
+ .Num("asked", keys.Count)
+ .Num("from", from);
+
+ var page = new BridgeAssets.PageBuilder(sb, "rows", BridgeConfig.AssetBatchBytes);
+
+ int i = from;
+
+ for (; i < keys.Count; i++)
+ {
+ string item = Render(byLabel, keys[i]);
+
+ if (!page.TryAdd(item, "t:" + (i + 1).ToString(CultureInfo.InvariantCulture)))
+ break;
+ }
+
+ page.Close();
+
+ sb.Num("sent", page.Count);
+
+ BridgeLink.Emit(sb.End());
+ }
+
+ ///
+ /// One key to one row.
+ ///
+ /// A key this shard cannot serve is a row rather than a failed request, exactly as in
+ /// every other family, and `status` keeps the two kinds apart: `absent` is a file this
+ /// shard does not have (a tree with no `ChampionSpawns.xml` is a normal tree), and
+ /// `unsupported` is a key shape this family does not serve — which is a website bug,
+ /// and is counted separately so it cannot hide inside the expected gaps.
+ ///
+ private static string Render(Dictionary byLabel, string key)
+ {
+ string label;
+ int chunk;
+
+ if (!ParseKey(key, out label, out chunk))
+ return Refusal(key, "unsupported", "not a tree chunk key (tree/