using System; using System.Collections.Generic; using System.Drawing; using System.Globalization; using System.IO; using System.Text; using Ultima; namespace Server.Custom.Bridge { /// /// **Item and land art, on demand** (docs/link/v8.md §5, §11 — protocol 8, phase 5). /// /// The body catalogue is a *set*: 1,022 sprites, enumerated, hashed and imported in one /// pass because a bestiary needs all of them. This is the opposite shape. This client /// addresses **49,152 static ids** and has real art for **39,189** of them, plus 4,244 land /// tiles of 16,384 — and then there are hues, which multiply the statics by three thousand. /// Nothing enumerates that. So there is no manifest here and no scan: the website asks for /// the handful of keys its own data actually names, and this answers them. /// /// (49,152 rather than the 81,884 entries `artidx.mul` declares: FileIndex sizes its /// table from the **length argument it is constructed with**, `0x10000`, not from the idx /// file — so the addressable range is `0x10000 - 0x4000`. Reading the ceiling off the file /// instead would invent 16,348 ids, every one of them answered out of an array nobody /// bounded.) /// /// ── **The keys** (§5) ── /// /// /// static/3922 one item graphic, as the client files hold it /// static/3922/h33 the same graphic with hue 33 applied /// land/3 one land tile /// /// /// ── **Why the hue is applied HERE and not on the website** ── /// /// Because it cannot be applied correctly anywhere else, and the incorrect version looks /// fine. /// /// A hue is not a tint. It is a 32-entry colour ramp out of `hues.mul` indexed by a /// pixel's own red channel — and whether it replaces *every* pixel or only the grey ones /// is decided by the PartialHue flag in tiledata.mul, per item id. On this /// client **13,259 of 65,536 item ids carry that flag**. Get it wrong on one of them and /// you do not get an error: item 597 is a wooden screen with painted flowers, and hued red /// the right way the flowers turn red, the wrong way the whole screen turns red. Both /// decode. Both are the right size. One is wrong. /// /// The website has neither file and never will — shipping `Hues.mul` semantics and a /// 65,536-row flag table into Node to answer a question the shard can answer for free is /// the same trade §2.1 already refused. So hue is part of the key, and the key is resolved /// where the files are. /// /// ── **The trap this phase existed to find** ── /// /// Art.GetStatic memoises into a static Bitmap[0xFFFF] and returns **the same /// instance** every time; Hue.ApplyTo repaints a bitmap **in place**. Hue a static /// once and the library's own copy is hued from then on — the plain key comes back hued, /// and the next hue stacks on the last. It is §4.5's failure mode (a confident, plausible, /// wrong picture that every success count agrees with) reached through a door §4.5 never /// looked at, because phase 0 was auditing *records* and this is the library's *cache*. /// /// turns Files.CacheData off for the life of /// the process, which makes every bitmap this file receives its own. That invariant is /// load-bearing enough that **re-checks it** before applying a hue and /// refuses rather than risk it: an invariant nothing verifies is a comment. /// /// ── **What is validated, and against what** ── /// /// Everything §4.5 built, reused as-is. An index entry is judged before the id is handed to /// Ultima (), a static's record header /// and row table are walked bounded (StaticSane), a land record is checked against /// the 2,024 bytes LoadLand reads whatever the length says (LandLengthSane), /// and the bound is taken against **whichever file FileIndex actually opened** — /// artLegacyMUL.uop on every current client, never art.mul (§4.6). /// /// Two of §4.5's measurements are this family's, not the catalogue's, and they are the /// reason all of it is here: on a **stock** client **9,963 static ids and 12,140 land ids** /// have an index entry reading `lookup 0, length 0`, which FileIndex.Seek treats as /// a hit and the decoder answers with whatever was decoded last. Measured through this /// reader over the whole range, those are the ONLY refusals — every one of the 39,189 /// statics and 4,244 land tiles that carries art is served, which is the half of the /// measurement that says the boundary is in the right place (§4.5). /// public static class BridgeArt { /// Item graphics. static/<id>, optionally /h<hue>. private const string StaticFamily = "static"; /// Land tiles. land/<id>, and no hue segment — see . private const string LandFamily = "land"; /// The art index addresses land at its own id and statics at 0x4000 + id. private const int StaticBase = 0x4000; /// Land is addressed with index & 0x3FFF by the library itself. private const int LandCount = 0x4000; /// hues.mul holds 3,000 slots; the wire's hue 1 is slot 0. private const int MaxHue = 3000; public static void Initialize() { if (!BridgeConfig.Enabled) return; BridgeAssets.RegisterFamily(StaticFamily, ReplyFetch); BridgeAssets.RegisterFamily(LandFamily, ReplyFetch); } // ── the cache (§11) ────────────────────────────────────────────────────────────────── private sealed class Rendered { public string Key; public string Status; public string Reason; public string Sha256; public byte[] Png; public int Width; public int Height; public int Hue; public bool PartialHue; public string Source; public int Weight { get { return Png == null ? 128 : Png.Length + 128; } } } private sealed class Cache { public string Id; public readonly Dictionary ByKey = new Dictionary(StringComparer.Ordinal); /// Insertion order, for eviction. See . public readonly Queue Order = new Queue(); public long Bytes; public DateTime LastUsed; } private static readonly object _sync = new object(); private static Cache _cache; private static readonly TimeSpan IdleFor = TimeSpan.FromMinutes(5); // ── assets.fetch, the static and land half ─────────────────────────────────────────── /// /// Both families' answer to assets.fetch. The correlation id, the operator's /// consent, the key ceiling and the family decision were made by /// ; every key here belongs to this reader. /// /// The paging envelope, the byte budget and the `catalog` guard are §3.4's and /// phase 3's, unchanged — a caller that already walks the body catalogue walks this /// with the same loop. /// private static void ReplyFetch(string reqId, List keys, string expected, string cursor) { string imagingReason; if (!BridgeAssets.ImagingOk(out imagingReason)) { // §17.9: a flat refusal, not a partial answer. Every picture in this family needs // a decoder that goes through GDI+, so there is no half of it to serve. BridgeAssets.Fail(reqId, "UNAVAILABLE", "this shard host cannot render images - Mono's System.Drawing needs " + "libgdiplus. (" + imagingReason + ")"); return; } string id = SourceId(); if (BridgeAssets.CatalogMismatch(expected, id)) { BridgeAssets.Fail(reqId, "UNREADABLE", "the shard's client files changed since that catalogue was read (catalog " + expected + " is now " + id + "); ask again"); return; } Cache cache; lock (_sync) { if (_cache == null || _cache.Id != id) _cache = new Cache { Id = id }; cache = _cache; cache.LastUsed = DateTime.UtcNow; } int from = ParseKeyCursor(cursor); var sb = BridgeJson.Begin("assets.fetch.ok"); sb.Str("reqId", reqId) .Str("family", BridgeAssets.FamilyOfKey(keys[0])) .Str("catalog", cache.Id) .Num("extractorVersion", BridgeAssets.EXTRACTOR_VERSION) .Num("asked", keys.Count) .Num("from", from); var page = new BridgeAssets.PageBuilder(sb, "rows", BridgeConfig.AssetBatchBytes); using (var readers = new Readers()) { for (int i = from; i < keys.Count; i++) { string row = Row(cache, readers, keys[i]); if (!page.TryAdd(row, "k:" + (i + 1).ToString(CultureInfo.InvariantCulture))) break; } } page.Close(); sb.Num("sent", page.Count); BridgeLink.Emit(sb.End()); Sweep(); } /// /// One key to one JSON row. /// /// A key this shard cannot serve is a **row**, never a failed request: an item id with /// no art must not cost the other three hundred on the page. The three outcomes are the /// ones phase 3 defined, and this family adds a `reason` beside them — additive, and /// the only way an operator learns that eight of their records are damaged rather than /// simply absent, which is a difference §4.5 spent a whole phase establishing. /// private static string Row(Cache cache, Readers readers, string key) { Rendered item = Resolve(cache, readers, key); var sb = new StringBuilder(2048); sb.Append("{\"key\":"); BridgeJson.Text(sb, key); sb.Append(",\"status\":\"").Append(item.Status).Append('"'); if (item.Reason != null) { sb.Append(",\"reason\":"); BridgeJson.Text(sb, item.Reason); } if (item.Status != "ok") { sb.Append('}'); return sb.ToString(); } sb.Append(",\"sha256\":\"").Append(item.Sha256).Append('"'); sb.Append(",\"bytes\":").Append(item.Png.Length.ToString(CultureInfo.InvariantCulture)); sb.Append(",\"width\":").Append(item.Width.ToString(CultureInfo.InvariantCulture)); sb.Append(",\"height\":").Append(item.Height.ToString(CultureInfo.InvariantCulture)); if (item.Hue > 0) { sb.Append(",\"hue\":").Append(item.Hue.ToString(CultureInfo.InvariantCulture)); sb.Append(",\"partialHue\":").Append(item.PartialHue ? "true" : "false"); } sb.Append(",\"source\":\"").Append(item.Source).Append('"'); sb.Append(",\"png\":\"").Append(Convert.ToBase64String(item.Png)).Append("\"}"); return sb.ToString(); } private static Rendered Resolve(Cache cache, Readers readers, string key) { lock (_sync) { Rendered cached; if (cache.ByKey.TryGetValue(key, out cached)) return cached; } Rendered item; try { item = Render(readers, key); } catch (Exception e) { Console.WriteLine("[Bridge] art: {0}: {1}: {2}", key, e.GetType().Name, e.Message); item = new Rendered { Key = key, Status = "absent", Reason = e.GetType().Name }; } if (item.Status == "ok") Remember(cache, item); return item; } /// /// Holds the encoded bytes against a byte budget, evicting oldest-first. /// /// **Oldest-first rather than least-recently-used, deliberately.** The access pattern /// this serves is a warm pass: the website asks for the keys it has never held, stores /// them permanently, and does not ask again. What this cache is actually for is the /// second page of a batch, a retry after a 425, and the same picture appearing in two /// of a page's rows — all of which insertion order serves exactly as well as recency, /// and with no bookkeeping on the hot path. A cache whose hit pattern has no recency in /// it should not pretend to rank by it. /// /// Only successes are held. An absent key costs one index lookup, which is cheaper than /// the dictionary entry that would remember it. /// private static void Remember(Cache cache, Rendered item) { lock (_sync) { if (cache.ByKey.ContainsKey(item.Key)) return; cache.ByKey[item.Key] = item; cache.Order.Enqueue(item.Key); cache.Bytes += item.Weight; while (cache.Bytes > BridgeConfig.AssetArtCacheBytes && cache.Order.Count > 0) { string oldest = cache.Order.Dequeue(); Rendered evicted; if (!cache.ByKey.TryGetValue(oldest, out evicted)) continue; cache.ByKey.Remove(oldest); cache.Bytes -= evicted.Weight; } } } // ── decode ─────────────────────────────────────────────────────────────────────────── /// /// Validate, decode, hue, encode. In that order, and the order is the point. /// private static Rendered Render(Readers readers, string key) { bool land; int id, hue; if (!TryParseKey(key, out land, out id, out hue)) return Unsupported(key, "not a key this shard serves"); FileIndex index = readers.Index; if (index == null || index.Index == null) return Absent(key, "this shard has no art file"); int at = land ? id : StaticBase + id; if (at < 0 || at >= index.Index.Length) return Unsupported(key, "id " + id + " is past the end of this client's art index"); string reason; BridgeAssetValidator.Verdict verdict = BridgeAssetValidator.CheckEntry(index, at, readers.DataLength, readers.VerdataLength, out reason); if (verdict == BridgeAssetValidator.Verdict.Absent) { // The 9,962 statics and 12,140 land tiles of §4.5: an index entry that reads // `lookup 0, length 0`, which the library treats as a hit and answers with the // previous asset's pixels. Absent is the true answer and the only safe one. return Absent(key, reason); } if (verdict != BridgeAssetValidator.Verdict.Ok) { // A damaged record rather than a missing one. Still absent to the website — there // is no picture either way — but the reason is worth carrying, because this one an // operator can act on. Console.WriteLine("[Bridge] art: {0} refused: {1}", key, reason); return Absent(key, reason); } if (land) { if (!BridgeAssetValidator.LandLengthSane(index, at, out reason)) { Console.WriteLine("[Bridge] art: {0} refused: {1}", key, reason); return Absent(key, reason); } } else if (readers.Reader == null || !readers.Reader.StaticSane(index, at, out reason)) { Console.WriteLine("[Bridge] art: {0} refused: {1}", key, reason ?? "the art record could not be read"); return Absent(key, reason ?? "the art record could not be read"); } // A hue is resolved BEFORE anything is decoded, so a bad one costs no pixels and, more // to the point, cannot half-apply to a picture that then gets cached and served. Ultima.Hue applied = null; bool partial = false; if (hue > 0) { if (!TryHue(id, hue, out applied, out partial, out reason)) return Unsupported(key, reason); } Bitmap bitmap = land ? Art.GetLand(id) // `checkmaxid: false` on purpose (§4.5): the default maps an out-of-range id to 0 // and returns ITEM ZERO'S PICTURE. The id is already bounded against the index // that was actually opened, so this can only be loud. : Art.GetStatic(id, false); // **Whether this bitmap is ours to dispose is the same question as whether it is ours // to hue**, and it has the same answer. With the library's cache off — which // `BridgeAssets.Initialize` guarantees and `TryHue` re-checks — every call decodes a // fresh instance that nothing else holds, so not disposing it would leak one bitmap per // fetched key. With the cache on, that instance is the library's own copy and disposing // it would leave a disposed `Bitmap` in a static array for the next caller to fault on. // Both mistakes are silent; the flag decides, once, here. bool owned = !Files.CacheData; try { if (bitmap == null || bitmap.Width <= 0 || bitmap.Height <= 0) return Absent(key, "the decoder returned no picture"); if (applied != null) applied.ApplyTo(bitmap, partial); byte[] png = BridgeAssets.BitmapToPng(bitmap); if (png == null) return Absent(key, "the picture could not be encoded"); return new Rendered { Key = key, Status = "ok", Sha256 = BridgeAssets.Sha256Hex(png), Png = png, Width = bitmap.Width, Height = bitmap.Height, Hue = hue, PartialHue = partial, Source = readers.Source }; } finally { if (owned && bitmap != null) bitmap.Dispose(); } } /// /// Resolves one wire hue onto a ramp, and decides whether it repaints the whole sprite /// or only its grey pixels. /// /// Four things have to hold, and every one of them has a way of not holding that /// produces a picture rather than an error: /// /// **The library's cache is off.** Re-checked here because ApplyTo repaints in /// place: with the cache on, this would edit the copy Art hands to everyone /// else. turns it off at boot and this refuses /// if it somehow did not, because the failure is invisible and permanent. /// /// **`hues.mul` is present.** When it is missing Hues.Initialize does not throw /// — it fills all 3,000 slots with a new Hue(index) whose ramp is **all zeroes**, /// and applying one of those paints the sprite black. An all-zero ramp is therefore /// refused whatever the reason for it; on this client there are none. /// /// **The index is in range.** The wire's hue is 1-based — Ultima.Map does the /// same GetHue(hue - 1) at line 450 — and GetHue itself masks with /// `0x3FFF` and falls back to slot 0 rather than failing, so an out-of-range hue would /// silently become a different colour. Bound it here instead. /// /// **The PartialHue flag decides the mode**, per item id, out of /// tiledata.mul. This is the one that is invisible: both modes decode, both are /// the right size, and 13,259 of this client's item ids need the grey-only one. /// **Land has no such flag**, which is why does not accept a /// hue on a land key at all rather than guessing a mode for it. /// private static bool TryHue(int id, int hue, out Ultima.Hue applied, out bool partial, out string reason) { applied = null; partial = false; reason = null; if (Files.CacheData) { reason = "this shard's art cache is on, so a hue cannot be applied safely"; Console.WriteLine("[Bridge] art: refusing hue {0}: {1}", hue, reason); return false; } if (hue < 1 || hue > MaxHue) { reason = "hue " + hue + " is outside 1-" + MaxHue; return false; } Ultima.Hue[] list = Ultima.Hues.List; if (list == null || hue - 1 >= list.Length || list[hue - 1] == null) { reason = "this client has no hue table"; return false; } Ultima.Hue candidate = list[hue - 1]; if (candidate.Colors == null || AllZero(candidate.Colors)) { reason = "hue " + hue + " has no colours in this client's hues.mul"; return false; } if (!TryPartialHue(id, out partial, out reason)) return false; applied = candidate; return true; } private static bool AllZero(short[] colors) { for (int i = 0; i < colors.Length; i++) { if (colors[i] != 0) return false; } return true; } /// /// The PartialHue flag for one item id. /// /// Refuses rather than defaults when tiledata.mul cannot be read. Defaulting /// either way would be a coin flip on 13,259 ids, and the losing side of it is a /// picture that looks deliberate. /// /// **Every type here is spelled Ultima. on purpose, and it is not style.** /// ServUO declares its own Server.TileData, Server.ItemData and /// Server.TileFlag — with a PartialHue member — in /// Server/TileData.cs. This file lives in Server.Custom.Bridge, so the /// enclosing namespace beats the using Ultima; and the unqualified spelling /// silently binds to the *server's* table: it compiles, the flag exists, and the answer /// comes from a file resolved through Core.DataDirectories rather than through /// Ultima.Files, which is the one thing §4.6 says never to do — decide a picture /// with a file other than the one the pixels came out of. The first run of this reader /// did exactly that and refused every hued key with a TypeInitializationException /// from a class this code never meant to name. /// private static bool TryPartialHue(int id, out bool partial, out string reason) { partial = false; reason = null; Ultima.ItemData[] table; try { table = Ultima.TileData.ItemTable; } catch (Exception e) { reason = "this client's tiledata could not be read (" + e.GetType().Name + ")"; return false; } if (table == null || id < 0 || id >= table.Length) { reason = "this client's tiledata does not describe item " + id; return false; } partial = (table[id].Flags & Ultima.TileFlag.PartialHue) != 0; return true; } private static Rendered Absent(string key, string reason) { return new Rendered { Key = key, Status = "absent", Reason = reason }; } private static Rendered Unsupported(string key, string reason) { return new Rendered { Key = key, Status = "unsupported", Reason = reason }; } // ── keys, cursors and the source id ────────────────────────────────────────────────── /// /// static/<id>, static/<id>/h<hue> and /// land/<id>. /// /// **A land key takes no hue segment.** The client can hue a land tile, but the mode /// that decides how is an *item* flag and land has no equivalent — so the honest answer /// to `land/3/h33` is that this shard does not serve it, rather than a picture produced /// by guessing. Nothing on the wire carries a hued land tile today; if something ever /// does, it arrives with a reason to choose. /// private static bool TryParseKey(string key, out bool land, out int id, out int hue) { land = false; id = 0; hue = 0; if (key == null) return false; string[] parts = key.Split('/'); if (parts.Length < 2 || parts.Length > 3) return false; if (parts[0] == LandFamily) land = true; else if (parts[0] != StaticFamily) return false; if (!Int32.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out id)) return false; if (id < 0) return false; if (land && id >= LandCount) return false; if (parts.Length == 2) return true; if (land) return false; string segment = parts[2]; if (segment.Length < 2 || segment[0] != 'h') return false; if (!Int32.TryParse(segment.Substring(1), NumberStyles.None, CultureInfo.InvariantCulture, out hue)) return false; // **`h0` is not a key.** Hue 0 on the wire means "this item is not hued", so the plain // key already names its picture. Accepting `static/3922/h0` as a synonym would have // the website store the identical PNG twice under two names, diff them separately on // every Update, and show whichever row it happened to join against -- for a distinction // that does not exist. The caller drops the segment instead. return hue > 0; } private static int ParseKeyCursor(string cursor) { if (cursor == null) return 0; int value; if (cursor.StartsWith("k:", StringComparison.Ordinal) && Int32.TryParse(cursor.Substring(2), NumberStyles.None, CultureInfo.InvariantCulture, out value)) return Math.Max(0, value); return 0; } /// /// Everything that decides these bytes, hashed into one short id — the same guard /// phase 3 built, over this family's inputs. /// /// Four files, and each earns its place: the art data file holds the pixels, /// `hues.mul` holds the ramps, `tiledata.mul` decides which of the two hue modes an /// item gets, and `verdata.mul` can patch any record in any of them. Leaving /// `tiledata.mul` out would be the subtle one — a client patch that only flipped /// PartialHue flags changes no pixel in any source file and every hued picture /// derived from them. /// private static string SourceId() { var sb = new StringBuilder(256); sb.Append(BridgeAssets.EXTRACTOR_VERSION); foreach (string path in new[] { BridgeAssetValidator.ArtDataPath(), FilePath("hues.mul"), FilePath("tiledata.mul"), FilePath("verdata.mul") }) { sb.Append('|'); if (path == null) continue; try { var info = new FileInfo(path); if (!info.Exists) continue; sb.Append(info.Length).Append(',').Append(info.LastWriteTimeUtc.Ticks); } catch { // An unreadable file is itself a state, and one that must not change from page // to page without being noticed. Leaving the slot empty does that. } } return BridgeAssets.Sha256Hex(Encoding.UTF8.GetBytes(sb.ToString())).Substring(0, 16); } private static string FilePath(string name) { try { return Files.GetFilePath(name); } catch { return null; } } // ── shared plumbing ────────────────────────────────────────────────────────────────── /// /// The art index and its record reader, opened for one reply and closed with it — the /// same lifetime rule phase 3's Readers follows, and for the same reason: a page /// decodes hundreds of sprites through them and opening them is microseconds, so /// holding handles on the operator's client files for the life of a cache buys nothing. /// private sealed class Readers : IDisposable { public readonly FileIndex Index; public readonly BridgeAssetValidator.RecordReader Reader; public readonly long DataLength; public readonly long VerdataLength; /// /// Which file the pixels came out of — `uop` or `legacy` — carried on every row /// beside the body catalogue's own `source` (§4.9). On this plane it answers §4.6's /// operator question: art added to `art.mul` while `artLegacyMUL.uop` is present is /// never read, and a row that says `uop` is what says so. /// public readonly string Source; public Readers() { string data = BridgeAssetValidator.ArtDataPath(); string verdata = FilePath("verdata.mul"); DataLength = BridgeAssetValidator.MulLength(data); VerdataLength = BridgeAssetValidator.MulLength(verdata); Source = data != null && data.EndsWith(".uop", StringComparison.OrdinalIgnoreCase) ? "uop" : "legacy"; try { Index = BridgeAssetValidator.OpenArtIndex(); if (data != null) Reader = new BridgeAssetValidator.RecordReader(data, verdata); } catch (Exception e) { Console.WriteLine("[Bridge] art: could not open the art files: {0}", e.Message); } } public void Dispose() { if (Reader == null) return; try { Reader.Dispose(); } catch { // Closing a read-only handle. Nothing useful is left to do. } } } /// /// Lets the held pictures go once nothing has asked for one in five minutes. The id is /// derived from the client files rather than minted per build, so a walk that spans the /// drop resumes against the same catalogue instead of starting over. /// private static void Sweep() { lock (_sync) { if (_cache == null) return; if (DateTime.UtcNow - _cache.LastUsed > IdleFor) _cache = null; } } public static string Status() { lock (_sync) { if (_cache == null) return "art(empty)"; return String.Format("art(id={0} held={1} bytes={2} cap={3})", _cache.Id, _cache.ByKey.Count, _cache.Bytes, BridgeConfig.AssetArtCacheBytes); } } } }