using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Security.Cryptography; using System.Text; using System.Threading; using Ultima; namespace Server.Custom.Bridge { /// /// **The Asset Bridge's transport** (docs/link/v8.md §3, §6, §7 — protocol 8, phase 1). /// /// Everything else on this link answers on the Core thread, reads live world state, and /// replies in microseconds. The asset plane cannot: it reads hundreds of megabytes of the /// operator's client files and decodes pictures out of them, and doing either on the Core /// thread would stop the world for every player on the shard. So this class is the one /// genuinely new shape in protocol 8 — a handler that accepts on the Core thread, hands /// the work to a **dedicated asset worker**, and returns immediately. /// /// Three rules hold it together, and each of them is answering a specific way this could /// go wrong. /// /// **1. Replies, never events.** Every asset frame carries the caller's `reqId`, so /// `rpc.rs`'s `try_route` consumes it before `app.rs` can persist it to SQLite and /// broadcast it to every WebSocket subscriber. An asset stream on the event path would /// grow the sidecar's store without bound and fan megabytes at every connected client, /// forever. Nothing here emits an unsolicited frame — if a request has no `reqId` it is /// refused rather than answered. /// /// **2. One request outstanding, always.** 's queue is bounded /// drop-oldest in **lines, not bytes** — a design that is right for live events and /// dangerous for bulk transfer, because 10,000 queued 200 KB replies is 2 GB of shard /// memory. The bound that actually holds is flow control, not a bigger queue: this plane /// has **one slot**, and a second asset request arriving while one is in flight is /// answered `bridge.busy` (which the sidecar already maps to 425) rather than queued. /// Queue depth therefore stays at approximately one by construction. A dropped or lost /// reply just times out and is re-requested, which is safe because reading a client file /// is idempotent and touches no world state. /// /// Note what that costs, deliberately: a status poll shares the slot with a batch, so /// polling during a long import gets 425 until the batch lands. That is honest — this /// plane really is doing one thing at a time — and the admin surface (phase 8) is where a /// separate status lane would have to argue for itself. /// /// **3. Byte budgets, not counts.** Batches are cut by encoded size /// (, 512 KB), not by item count, because the /// ceilings this has to live inside are byte ceilings: the sidecar refuses an inbound line /// over 1 MiB, and base64 costs 33% on top of whatever the payload measures. /// is that budget, and every asset family shares it so the /// envelope cannot drift apart between them. /// /// **Emitting from off the Core thread is safe here, and it is worth saying why.** /// BridgeLink.Emit enqueues onto a ConcurrentQueue and never touches the /// socket, so the enqueue itself is fine. The subtle part is /// BridgeIdempotency.Observe, which Emit calls while a keyed command is in /// flight: it captures a line only when that line's correlation field **exactly equals** /// the open command's correlation value, and correlation values come from one monotonic /// counter in the sidecar. An asset reply therefore cannot be mistaken for a keyed /// command's reply, whatever the interleaving. /// public static class BridgeAssets { /// /// What version of *our derivation* produced these bytes (§7). /// /// The source gate hashes the operator's client files, which answers "did the inputs /// change". It cannot answer "did the way we read them change" — and that is the case /// that bites, because a corrected frame offset or a fixed hue application changes /// every derived byte while every source file stays identical. So this is folded into /// stage 1 alongside the hashes, and bumping it makes the whole working set drift, /// which is the intended and correct blast radius. /// /// Bump it whenever extraction changes what it produces from unchanged input. It is /// the same rule spawnAtlasSource.js's `PARSER_VERSION` follows, and it applies /// here more rather than less: this pipeline derives far more from far less. /// public const int EXTRACTOR_VERSION = 1; // ── the one slot (§3.2) ────────────────────────────────────────────────────────────── private static readonly object _sync = new object(); private static Thread _worker; private static readonly AutoResetEvent _wake = new AutoResetEvent(false); private static Action _job; private static string _inFlight; private static DateTime _inFlightSince; private static bool _running; private static long _served, _busied, _failed; // ── the hash cache (§6) ────────────────────────────────────────────────────────────── private static readonly Dictionary _hashes = new Dictionary(StringComparer.OrdinalIgnoreCase); private static Thread _hasher; private static volatile bool _hashing; private sealed class CachedHash { public long Size; public long MTime; public string Sha256; } // ── imaging (§4.4) ─────────────────────────────────────────────────────────────────── private static bool _imagingChecked; private static bool _imagingOk; private static string _imagingReason; public static void Initialize() { if (!BridgeConfig.Enabled) return; BridgeBoot.RegisterHandler("assets.sources", OnSources); } public static string Status() { int cached; lock (_hashes) { cached = _hashes.Count; } lock (_sync) { return String.Format( "assets(served={0} busied={1} failed={2} inFlight={3} hashing={4} cached={5})", _served, _busied, _failed, _inFlight ?? "-", _hashing, cached); } } // ── the request plane ──────────────────────────────────────────────────────────────── /// /// Stage 1 of §6: what the shard's client files currently are. No pixels, no assets — /// just the gate that lets the website decide whether anything needs importing at all, /// because the normal case is a restart that changed nothing and it must cost nothing. /// private static void OnSources(Dictionary o) { var reqId = BridgeJson.GetString(o, "reqId"); if (reqId == null) { // Rule 1. Without a correlation id this would land on the event path, be persisted // to the sidecar's store and broadcast to every subscriber. Refuse instead. Fail(null, "BAD_REQUEST", "assets.sources requires a reqId"); return; } if (!BridgeConfig.AssetsEnabled) { Fail(reqId, "DISABLED", "asset extraction is disabled on this shard"); return; } Accept(reqId, "assets.sources", () => ReplySources(reqId)); } /// /// Claims the single slot and hands the work to the worker, or answers `bridge.busy`. /// Runs on the Core thread and does nothing expensive; runs on /// the worker and must touch no world state. /// internal static void Accept(string reqId, string kind, Action job) { lock (_sync) { if (_inFlight != null) { _busied++; Busy(reqId, kind); return; } _inFlight = kind; _inFlightSince = DateTime.UtcNow; _job = job; try { EnsureWorker(); } catch (Exception e) { // The slot is claimed and there is now nothing that will ever free it. Give it // back here or this plane answers `bridge.busy` for the life of the process. _inFlight = null; _job = null; Console.WriteLine("[Bridge] cannot start the asset worker: {0}", e.Message); Fail(reqId, "UNAVAILABLE", "the shard could not start its asset worker"); return; } } _wake.Set(); } private static void Busy(string reqId, string kind) { var held = (DateTime.UtcNow - _inFlightSince).TotalSeconds; var sb = BridgeJson.Begin("bridge.busy"); sb.Str("reqId", reqId) // `busyKind`, never a second `kind` — `Begin` has already written this frame's own, // and a JSON object carrying two makes every parser take the last. Protocol 6 shipped // that bug once and it made the sidecar answer 200 for a refusal. .Str("busyKind", kind) .Num("heldForSec", (long)held) .Str("reason", "the asset plane serves one request at a time"); BridgeLink.Emit(sb.End()); } /// /// The asset plane's one refusal frame, shared by every family on it. /// /// is what the sidecar maps to a status, and it exists because /// the alternative it replaced — matching on the words in — /// makes an operator-facing sentence load-bearing. Rewording "disabled" would silently /// turn a 403 into a 400. The codes are `DISABLED` (the operator switched this plane /// off), `NOT_FOUND` (the shard has no such file), `UNREADABLE` (it has it and cannot /// decode it), `UNAVAILABLE` (the shard cannot do this right now) and `BAD_REQUEST` /// (the default, and the caller's fault). /// internal static void Fail(string reqId, string code, string reason) { var sb = BridgeJson.Begin("assets.error"); if (reqId != null) sb.Str("reqId", reqId); sb.Str("code", code) .Str("reason", reason); BridgeLink.Emit(sb.End()); } // ── the worker ─────────────────────────────────────────────────────────────────────── /// /// Started on first use rather than at boot, so a shard that never imports an asset /// never carries the thread. Caller must hold . /// private static void EnsureWorker() { if (_worker != null) return; _running = true; _worker = new Thread(WorkLoop) { Name = "BridgeAssets", IsBackground = true }; _worker.Start(); } private static void WorkLoop() { while (_running) { _wake.WaitOne(1000); Action job; lock (_sync) { job = _job; _job = null; } if (job == null) continue; try { job(); Interlocked.Increment(ref _served); } catch (Exception e) { // A handler that throws must still free the slot, or this plane is wedged for // the life of the process and every later request answers `bridge.busy`. Interlocked.Increment(ref _failed); Console.WriteLine("[Bridge] asset worker: {0}: {1}", e.GetType().Name, e.Message); } finally { lock (_sync) { _inFlight = null; } } } } // ── assets.sources ─────────────────────────────────────────────────────────────────── /// /// The client files whose bytes decide everything downstream. /// /// Resolved through Ultima.Files rather than by joining a configured directory, /// because that is what the decoders themselves do — a file this reports is a file /// they would actually open. /// private static readonly string[] SourceFiles = { "cliloc.enu", "artlegacymul.uop", "art.mul", "artidx.mul", "anim.idx", "anim.mul", "anim2.idx", "anim2.mul", "anim3.idx", "anim3.mul", "anim4.idx", "anim4.mul", "anim5.idx", "anim5.mul", "body.def", "bodyconv.def", "hues.mul", "verdata.mul" }; private static void ReplySources(string reqId) { var sb = BridgeJson.Begin("assets.sources.ok"); sb.Str("reqId", reqId) .Num("extractorVersion", EXTRACTOR_VERSION); WriteImaging(sb); // §4.6: whichever of art.mul / artLegacyMUL.uop `FileIndex` would actually open. An // operator who added custom graphics to art.mul while the UOP is present is getting // nothing, silently, and this is the only place that can tell them so. string artData = BridgeAssetValidator.ArtDataPath(); sb.Str("artDataFile", artData == null ? null : Path.GetFileName(artData)); var page = new PageBuilder(sb, "files", BridgeConfig.AssetBatchBytes); bool anyMissingHash = false; for (int i = 0; i < SourceFiles.Length; i++) { string name = SourceFiles[i]; string path = ResolvePath(name); if (path == null) continue; var item = new StringBuilder(256); item.Append("{\"name\":"); BridgeJson.Text(item, name); item.Append(",\"path\":"); BridgeJson.Text(item, path); long size = 0, mtime = 0; try { var info = new FileInfo(path); size = info.Length; mtime = ToUnixMs(info.LastWriteTimeUtc); } catch (Exception e) { item.Append(",\"unreadable\":"); BridgeJson.Text(item, e.GetType().Name); } item.Append(",\"size\":").Append(size.ToString(CultureInfo.InvariantCulture)); item.Append(",\"mtime\":").Append(mtime.ToString(CultureInfo.InvariantCulture)); string hash = CachedHashFor(path, size, mtime); if (hash == null) anyMissingHash = true; item.Append(",\"sha256\":"); BridgeJson.Text(item, hash); // The one diagnostic §4.6 asks for: art.mul is present, and unread. if (artData != null && (name == "art.mul" || name == "artidx.mul") && !artData.EndsWith(".mul", StringComparison.OrdinalIgnoreCase)) { item.Append(",\"shadowedBy\":"); BridgeJson.Text(item, Path.GetFileName(artData)); } item.Append('}'); if (!page.TryAdd(item.ToString(), name)) break; } page.Close(); // §6's gate is (size, mtime) first and a content hash only when those differ, because // anim.mul and art.mul are 195 MB and 148 MB and a full hash on every status poll // would make the admin panel feel broken. It would also blow the sidecar's 10 s reply // timeout outright on the first call. So a hash that is not cached is reported `null` // and computed in the background: this reply is always fast, and the next poll — after // `hashing` goes false — carries the answer. if (anyMissingHash) StartHashing(); sb.Bool("hashing", _hashing); sb.Bool("complete", !anyMissingHash); BridgeLink.Emit(sb.End()); } private static string ResolvePath(string name) { try { return Files.GetFilePath(name); } catch { return null; } } private static long ToUnixMs(DateTime utc) { return (long)(utc - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; } // ── imaging (§4.4) ─────────────────────────────────────────────────────────────────── /// /// Whether this host can turn a record into a picture at all. /// /// ServUO targets net48, so a Linux shard runs it under Mono, where /// System.Drawing is a thin layer over **libgdiplus** — and §4.2 put /// System.Drawing in the *decode* path, not merely the encode: Frame /// writes ARGB1555 through a LockBits pointer. Without that library a Linux /// shard cannot read a sprite at all, while clilocs and the atlas are unaffected /// because neither touches pixels. /// /// It must never present as a stack trace or a 500. It is a named, actionable outcome /// in the same family as the cliloc reader's `COMPRESSED`, and it is reported here — /// on the *source gate*, the first call any import makes — so an operator learns it /// while setting the shard up rather than from an empty bestiary weeks later. /// private static void WriteImaging(StringBuilder sb) { CheckImaging(); sb.Append(",\"imaging\":{\"ok\":").Append(_imagingOk ? "true" : "false"); if (!_imagingOk) { sb.Append(",\"code\":\"NO_IMAGING\",\"reason\":"); BridgeJson.Text(sb, "This shard host cannot render images — Mono's System.Drawing needs libgdiplus. " + "Install it (apt-get install libgdiplus) and re-run the import. Cliloc and atlas " + "import are unaffected. (" + _imagingReason + ")"); } sb.Append('}'); } /// /// Whether this host can produce a picture, for the families that produce pictures. /// /// reports this on the source gate so an operator learns it /// while setting the shard up. The catalogue needs the same answer as a *decision* — /// it must refuse rather than throw a DllNotFoundException out of the middle of /// a decode loop — so the check itself is shared and this is its one accessor. /// internal static bool ImagingOk(out string reason) { CheckImaging(); reason = _imagingReason; return _imagingOk; } private static void CheckImaging() { if (_imagingChecked) return; _imagingChecked = true; try { TouchImaging(); _imagingOk = true; } catch (Exception e) { // On a host with no libgdiplus this is a TypeInitializationException wrapping a // DllNotFoundException, and it can surface as the method failing to JIT rather // than as a throw from inside it — which is why the construction lives in its own // method, so the failure is contained here instead of taking this class's // static initialisation with it. _imagingOk = false; _imagingReason = e.GetType().Name + ": " + e.Message; } } private static void TouchImaging() { using (var bmp = new System.Drawing.Bitmap(1, 1)) { bmp.SetPixel(0, 0, System.Drawing.Color.Black); } } // ── the hash cache (§6) ────────────────────────────────────────────────────────────── private static string CachedHashFor(string path, long size, long mtime) { lock (_hashes) { CachedHash cached; if (_hashes.TryGetValue(path, out cached) && cached.Size == size && cached.MTime == mtime) { return cached.Sha256; } } return null; } /// /// Rehashes whatever the cache is missing, on its own thread. /// /// Deliberately **not** a job on the asset worker: hashing 343 MB takes seconds to /// tens of seconds, and holding the single slot for that long would answer every /// status poll `bridge.busy` for the whole pass — which is exactly the moment an /// operator is watching the panel. It emits nothing and correlates with nothing; it /// only fills the cache that the next `assets.sources` reads. /// private static void StartHashing() { lock (_sync) { if (_hashing) return; _hashing = true; _hasher = new Thread(HashLoop) { Name = "BridgeAssetHash", IsBackground = true }; _hasher.Start(); } } private static void HashLoop() { try { for (int i = 0; i < SourceFiles.Length; i++) { string path = ResolvePath(SourceFiles[i]); if (path == null) continue; long size, mtime; try { var info = new FileInfo(path); size = info.Length; mtime = ToUnixMs(info.LastWriteTimeUtc); } catch { continue; } if (CachedHashFor(path, size, mtime) != null) continue; string hash = HashFile(path); if (hash == null) continue; lock (_hashes) { _hashes[path] = new CachedHash { Size = size, MTime = mtime, Sha256 = hash }; } } } catch (Exception e) { Console.WriteLine("[Bridge] asset hash pass: {0}: {1}", e.GetType().Name, e.Message); } finally { // Under _sync, matching StartHashing: cleared outside it, two passes could both // pass the guard and hash the same 343 MB twice. lock (_sync) { _hashing = false; } } } private static string HashFile(string path) { try { using (var sha = SHA256.Create()) using (var stream = new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 1 << 20)) { var buffer = new byte[1 << 20]; int read; while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) sha.TransformBlock(buffer, 0, read, null, 0); sha.TransformFinalBlock(buffer, 0, 0); return Hex(sha.Hash); } } catch (Exception e) { Console.WriteLine("[Bridge] cannot hash {0}: {1}", path, e.Message); return null; } } private static string Hex(byte[] bytes) { var sb = new StringBuilder(bytes.Length * 2); for (int i = 0; i < bytes.Length; i++) sb.Append(bytes[i].ToString("x2", CultureInfo.InvariantCulture)); return sb.ToString(); } // ── the paging envelope (§3.3) ─────────────────────────────────────────────────────── /// /// **One envelope for every asset family**, defined here in phase 1 so that clilocs /// (phase 2), the body catalogue (3), statics and land (5), deep animation keys (6) /// and the ServUO tree files (7) all page the same way. They are otherwise five /// chances to invent five slightly different shapes, and the website would have to /// learn each one. /// /// The envelope a reply closes with: /// /// /// "items": [ … ], /// "more": true, // ask again with this cursor /// "cursor": "s:4104", // opaque to everyone but the shard; absent when more:false /// "cut": "budget" // budget | end | limit — WHY this page stopped /// /// /// **The budget is bytes, and it is UTF-8 bytes.** Not item count, because the ceiling /// this lives inside is the sidecar's inbound line cap; and not chars, because a /// cliloc row is real text and a `StringBuilder`'s Length would undercount every /// non-ASCII character in it. /// /// `cut` exists because "the page is short" has three different meanings and the /// website must not have to guess which: the source ran out (`end`), the byte budget /// was spent (`budget`), or the family stopped at its own limit (`limit`). Only the /// first means the import is finished. /// /// **The first item is always admitted**, even if it alone exceeds the budget. /// Otherwise an oversized item would make its family unable to make any progress at /// all — it would be skipped for the budget on every page, forever. That is safe /// precisely because the budget is set to half the sidecar's line cap /// (), so one such item still fits the wire. /// public sealed class PageBuilder { private readonly StringBuilder _sb; private readonly int _budget; private int _bytes; private int _count; private string _cursor; private string _cut = "end"; /// /// Room kept back for the fields the envelope must still be able to write after /// the last item — `more`, `cursor`, `cut` and the closing brace. Without it a /// page could fill the budget exactly and then overrun it closing itself. /// private const int Reserve = 256; public PageBuilder(StringBuilder sb, string arrayName, int budget) { _sb = sb; _budget = budget; sb.Append(",\"").Append(arrayName).Append("\":["); // The prefix is already written, and it counts: the cap the sidecar enforces is // on the whole line, not on the array. _bytes = Encoding.UTF8.GetByteCount(sb.ToString()); } public int Count { get { return _count; } } /// /// Adds one already-serialised item. is where the /// family should resume if this turns out to be the last item on the page. /// Returns false when the budget is spent — the caller stops, and `more` is true. /// public bool TryAdd(string item, string cursorAfter) { if (item == null) return true; int cost = Encoding.UTF8.GetByteCount(item) + (_count > 0 ? 1 : 0); if (_count > 0 && _bytes + cost + Reserve > _budget) { _cut = "budget"; return false; } if (_count > 0) _sb.Append(','); _sb.Append(item); _bytes += cost; _count++; _cursor = cursorAfter; return true; } /// /// Stops the page for a reason of the family's own — a per-request limit, say — /// rather than because the budget ran out. /// public void Cut(string why) { _cut = why; } public void Close() { bool more = _cut != "end"; _sb.Append(']'); _sb.Bool("more", more); _sb.Str("cut", _cut); if (more && _cursor != null) _sb.Str("cursor", _cursor); } } } }