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/