Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeArt.cs
Claude 936a922487 fix(asset-bridge): an empty catalog is an absent one on every family, not just the tree
Phase 7 found this on the tree family and fixed it there. It was inline in THREE
places: the body catalogue (phase 3), statics and land (phase 5), and the tree.
`expected != null` treats "" as a real fingerprint, so a caller that serialises a
missing value as an empty string has EVERY fetch refused -- with a sentence that
names no catalog at all ("catalog  is now 8159778b"), which reads as a shard
fault rather than a caller one.

All three now go through one BridgeAssets.CatalogMismatch. Three copies of a
comparison are three chances for the next family to get it wrong in a way only a
differently-written client would ever reveal.

BridgeLeases keeps its own `expected != null` and is deliberately untouched:
there the value is a world property, where an empty string is a legitimate thing
to expect.

Verified against a live shard on a stock ServUO install, every family asked three
ways -- with a real catalog, with the field absent, and with an empty string:

  cliloc.table walk                     67,496 rows, 12 pages
  body manifest / fetch                 1,095 rows; ok all three ways
  static + land fetch                   ok all three ways
  static/land carry their OWN catalog   art 66a112c1 vs body 323f284f
  a cross-family catalog                refused 422
  tree manifest / fetch                 141 files incl. BOTH empty ones, all three ways
  empty files carry a VALID gzip member 2 rows gunzip to 0 bytes
  a STALE catalog                       still refused on body, static and tree

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 02:33:15 -05:00

843 lines
35 KiB
C#

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Globalization;
using System.IO;
using System.Text;
using Ultima;
namespace Server.Custom.Bridge
{
/// <summary>
/// **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: <c>FileIndex</c> 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) ──
///
/// <code>
/// 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
/// </code>
///
/// ── **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 <c>PartialHue</c> flag in <c>tiledata.mul</c>, 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** ──
///
/// <c>Art.GetStatic</c> memoises into a static <c>Bitmap[0xFFFF]</c> and returns **the same
/// instance** every time; <c>Hue.ApplyTo</c> 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*.
///
/// <see cref="BridgeAssets.Initialize"/> turns <c>Files.CacheData</c> off for the life of
/// the process, which makes every bitmap this file receives its own. That invariant is
/// load-bearing enough that <see cref="Render"/> **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
/// <c>Ultima</c> (<see cref="BridgeAssetValidator.CheckEntry"/>), a static's record header
/// and row table are walked bounded (<c>StaticSane</c>), a land record is checked against
/// the 2,024 bytes <c>LoadLand</c> reads whatever the length says (<c>LandLengthSane</c>),
/// and the bound is taken against **whichever file <c>FileIndex</c> actually opened** —
/// <c>artLegacyMUL.uop</c> on every current client, never <c>art.mul</c> (§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 <c>FileIndex.Seek</c> 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).
/// </summary>
public static class BridgeArt
{
/// <summary>Item graphics. <c>static/&lt;id&gt;</c>, optionally <c>/h&lt;hue&gt;</c>.</summary>
private const string StaticFamily = "static";
/// <summary>Land tiles. <c>land/&lt;id&gt;</c>, and no hue segment — see <see cref="TryParseKey"/>.</summary>
private const string LandFamily = "land";
/// <summary>The art index addresses land at its own id and statics at <c>0x4000 + id</c>.</summary>
private const int StaticBase = 0x4000;
/// <summary>Land is addressed with <c>index &amp; 0x3FFF</c> by the library itself.</summary>
private const int LandCount = 0x4000;
/// <summary><c>hues.mul</c> holds 3,000 slots; the wire's hue 1 is slot 0.</summary>
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<string, Rendered> ByKey =
new Dictionary<string, Rendered>(StringComparer.Ordinal);
/// <summary>Insertion order, for eviction. See <see cref="Remember"/>.</summary>
public readonly Queue<string> Order = new Queue<string>();
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 ───────────────────────────────────────────
/// <summary>
/// Both families' answer to <c>assets.fetch</c>. The correlation id, the operator's
/// consent, the key ceiling and the family decision were made by
/// <see cref="BridgeAssets.OnFetch"/>; 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.
/// </summary>
private static void ReplyFetch(string reqId, List<string> 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();
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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 ───────────────────────────────────────────────────────────────────────────
/// <summary>
/// Validate, decode, hue, encode. In that order, and the order is the point.
/// </summary>
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();
}
}
/// <summary>
/// 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 <c>ApplyTo</c> repaints in
/// place: with the cache on, this would edit the copy <c>Art</c> hands to everyone
/// else. <see cref="BridgeAssets.Initialize"/> 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 <c>Hues.Initialize</c> does not throw
/// — it fills all 3,000 slots with a <c>new Hue(index)</c> 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 — <c>Ultima.Map</c> does the
/// same <c>GetHue(hue - 1)</c> at line 450 — and <c>GetHue</c> 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 <c>PartialHue</c> flag decides the mode**, per item id, out of
/// <c>tiledata.mul</c>. 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 <see cref="TryParseKey"/> does not accept a
/// hue on a land key at all rather than guessing a mode for it.
/// </summary>
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;
}
/// <summary>
/// The <c>PartialHue</c> flag for one item id.
///
/// Refuses rather than defaults when <c>tiledata.mul</c> 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 <c>Ultima.</c> on purpose, and it is not style.**
/// ServUO declares its own <c>Server.TileData</c>, <c>Server.ItemData</c> and
/// <c>Server.TileFlag</c> — with a <c>PartialHue</c> member — in
/// <c>Server/TileData.cs</c>. This file lives in <c>Server.Custom.Bridge</c>, so the
/// enclosing namespace beats the <c>using Ultima;</c> 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 <c>Core.DataDirectories</c> rather than through
/// <c>Ultima.Files</c>, 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 <c>TypeInitializationException</c>
/// from a class this code never meant to name.
/// </summary>
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 ──────────────────────────────────────────────────
/// <summary>
/// <c>static/&lt;id&gt;</c>, <c>static/&lt;id&gt;/h&lt;hue&gt;</c> and
/// <c>land/&lt;id&gt;</c>.
///
/// **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.
/// </summary>
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;
}
/// <summary>
/// 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
/// <c>PartialHue</c> flags changes no pixel in any source file and every hued picture
/// derived from them.
/// </summary>
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 ──────────────────────────────────────────────────────────────────
/// <summary>
/// The art index and its record reader, opened for one reply and closed with it — the
/// same lifetime rule phase 3's <c>Readers</c> 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.
/// </summary>
private sealed class Readers : IDisposable
{
public readonly FileIndex Index;
public readonly BridgeAssetValidator.RecordReader Reader;
public readonly long DataLength;
public readonly long VerdataLength;
/// <summary>
/// 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.
/// </summary>
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.
}
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
}