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/