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/BridgeAssets.cs b/overlay/Scripts/Custom/Bridge/BridgeAssets.cs index 5cb2417..4b68f68 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. /// @@ -644,7 +803,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 +841,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..d24e868 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. @@ -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..c6e27a7 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeTree.cs @@ -0,0 +1,777 @@ +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); + + // `IsNullOrEmpty`, not `!= null`. A caller that has no fingerprint to assert sends + // the field absent OR empty depending on how its own client serialises a missing + // value, and the two must mean the same thing — an empty string compared against a + // real id refuses every fetch, with a sentence that names no catalog at all + // ("catalog is now 8159778b"). Found by a probe that passed one. + if (!String.IsNullOrEmpty(expected) && 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/