using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using Ultima;
namespace Server.Custom.Bridge
{
///
/// **The body catalogue** (docs/link/v8.md §4.8, §5, §6, §11 — protocol 8, phase 3).
///
/// One thumbnail per creature body: the working set that makes a bestiary, a marketplace
/// listing and a character sheet render. Everything deeper — every action, every frame —
/// is the same addressing scheme at a deeper key, fetched on demand in a later phase; this
/// is the set that is worth importing before anything asks for it, because on this
/// machine's client it is **787 sprites at about a kilobyte each**.
///
/// Two request kinds, which are §6's two stages for assets rather than for sources:
///
/// assets.manifest — `[{ key, sha256, bytes, width, height }]`, no pixels. The
/// website diffs it against what it already holds and asks only for what changed. That is
/// the whole difference between an Update and a re-download.
///
/// assets.fetch — the pixels, for an explicit list of keys.
///
/// ── **Why the manifest builds the pictures it refuses to send** ──
///
/// A manifest row carries a hash of the bytes, and the only way to hash bytes is to have
/// them. So the scan decodes, encodes to PNG and hashes, then sends the row and **keeps
/// the bytes** — a megabyte for the whole catalogue, against re-decoding all 787 sprites a
/// second time when the fetch arrives moments later.
///
/// ── **Why the manifest pages on TIME rather than on bytes** ──
///
/// Every other family on this plane pages because its rows are large. This one's rows are
/// ninety bytes and the whole catalogue is one page by the byte budget — but producing
/// that page means decoding 787 animations, and the sidecar gives a reply ten seconds
/// (§3.3). So the scan carries a **wall-clock budget** as well
/// () and cuts the page `limit` when it is spent,
/// resuming from its cursor on the next call. The byte budget is still enforced, because
/// the day a family's rows grow is not the day to discover only one of the two bounds was
/// real.
///
/// ── **Why `catalog` is derived from the sources and not minted per build** ──
///
/// A manifest walk and the fetch that follows it must be talking about the same client
/// files, or the website stitches one catalogue out of two. The obvious answer is a fresh
/// id per build, and it is wrong: this cache is released when it goes idle, so a rebuild
/// halfway through a slow import would change the id and force a restart although nothing
/// about the client moved. So the id is a hash of what actually decides the bytes — every
/// anim file's size and mtime, both direction settings and
/// . It is stable across a rebuild and it
/// changes exactly when an operator patches their client.
///
/// ── **The never-sweep rule, and the 357** ──
///
/// Nothing here asks a file type for an index it does not own, and nothing here trusts the
/// library's own success. takes
/// `BodyConverter.Convert`'s answer and reports nothing if it leads nowhere (sweeping
/// instead puts a giant spider on the gargoyle page, decoding cleanly); and every body is
/// put through and
/// RecordReader.AnimationSane **before** it is decoded, because a body whose index
/// entry reads `length 0` gets a bitmap back anyway — the previously-decoded creature's,
/// from the library's reused stream buffer. That is 357 of the 1,144 bodies the library
/// claims on a stock client, and importing them would have written 357 duplicate
/// portraits whose subject depended on the order this walk happened to run in.
///
public static class BridgeCatalog
{
/// The only family this phase serves. §5's key scheme covers the rest.
private const string Family = "body";
/// Bodies are addressable to 2047; the sweep behind §4.8 covered exactly this.
private const int MaxBody = 2047;
/// The catalogue is first frames only. Deep keys are phase 6.
private const int CatalogAction = 0;
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
BridgeBoot.RegisterHandler("assets.manifest", OnManifest);
BridgeBoot.RegisterHandler("assets.fetch", OnFetch);
}
// ── the cache ────────────────────────────────────────────────────────────────────────
private sealed class Sprite
{
public string Key;
public int Body;
public int Direction;
public int FileType;
public string Sha256;
public byte[] Png;
public int Width;
public int Height;
}
private sealed class Catalog
{
public string Id;
public readonly Dictionary ByKey =
new Dictionary(StringComparer.Ordinal);
public readonly List Order = new List();
/// The next body the scan has yet to look at.
public int Next = 1;
public bool Complete;
public DateTime LastUsed;
}
private static readonly object _sync = new object();
private static Catalog _catalog;
private static readonly TimeSpan IdleFor = TimeSpan.FromMinutes(5);
// ── assets.manifest ──────────────────────────────────────────────────────────────────
private static void OnManifest(Dictionary o)
{
string reqId;
if (!Admit(o, "assets.manifest", out reqId))
return;
var family = BridgeJson.GetString(o, "family") ?? Family;
if (!String.Equals(family, Family, StringComparison.Ordinal))
{
// Named rather than ignored: `family` exists so §5's statics and land can join
// this envelope in phase 5 without a second request kind, and a website that
// asked for one of those against a phase-3 overlay must be told it asked too
// early rather than handed a body catalogue it did not request.
BridgeAssets.Fail(reqId, "BAD_REQUEST",
"this shard serves the '" + Family + "' asset family only (asked for '"
+ family + "')");
return;
}
var cursor = BridgeJson.GetString(o, "cursor");
BridgeAssets.Accept(reqId, "assets.manifest", () => ReplyManifest(reqId, cursor));
}
///
/// Worker thread. Scans forward from the cursor until the byte budget or the time
/// budget is spent, hashing what it decodes and keeping the bytes for the fetch.
///
private static void ReplyManifest(string reqId, string cursor)
{
string imagingReason;
if (!BridgeAssets.ImagingOk(out imagingReason))
{
// Never a stack trace and never a 500: on a Linux host without libgdiplus this is
// the expected outcome, and it is actionable in one line (§4.4).
BridgeAssets.Fail(reqId, "UNAVAILABLE",
"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 + ")");
return;
}
string id = SourceId();
Catalog catalog;
lock (_sync)
{
if (_catalog == null || _catalog.Id != id)
_catalog = new Catalog { Id = id };
catalog = _catalog;
catalog.LastUsed = DateTime.UtcNow;
}
int from = ParseBodyCursor(cursor);
var sb = BridgeJson.Begin("assets.manifest.ok");
sb.Str("reqId", reqId)
.Str("family", Family)
// What the website compares across pages, and across the fetch that follows. A
// change means the operator patched their client mid-import and the half already
// read describes files that no longer exist.
.Str("catalog", catalog.Id)
.Num("extractorVersion", BridgeAssets.EXTRACTOR_VERSION)
.Num("maxBody", MaxBody)
.Num("from", from);
WritePlayerBodies(sb);
var page = new BridgeAssets.PageBuilder(sb, "rows", BridgeConfig.AssetBatchBytes);
int scanned = 0;
int last = from - 1;
bool timedOut = false;
bool budgetCut = false;
var deadline = DateTime.UtcNow.AddMilliseconds(BridgeConfig.AssetScanMs);
using (var readers = new Readers())
{
int body = from;
for (; body <= MaxBody; body++)
{
// Checked before the body rather than after it, so the budget bounds the reply
// rather than the reply plus one more decode. One sprite is milliseconds; the
// ceiling this lives under is ten seconds and the cost of overshooting it is
// the whole page, retried.
if (body > from && DateTime.UtcNow >= deadline)
{
timedOut = true;
break;
}
scanned++;
last = body;
Sprite sprite = Resolve(catalog, readers, body);
if (sprite == null)
continue;
var item = new StringBuilder(128);
item.Append("{\"key\":");
BridgeJson.Text(item, sprite.Key);
item.Append(",\"sha256\":\"").Append(sprite.Sha256).Append('"');
item.Append(",\"bytes\":").Append(sprite.Png.Length.ToString(CultureInfo.InvariantCulture));
item.Append(",\"width\":").Append(sprite.Width.ToString(CultureInfo.InvariantCulture));
item.Append(",\"height\":").Append(sprite.Height.ToString(CultureInfo.InvariantCulture));
item.Append(",\"body\":").Append(sprite.Body.ToString(CultureInfo.InvariantCulture));
item.Append(",\"direction\":").Append(sprite.Direction.ToString(CultureInfo.InvariantCulture));
item.Append('}');
if (!page.TryAdd(item.ToString(), "b:" + body.ToString(CultureInfo.InvariantCulture)))
{
// The budget stopped this page BEFORE this body's row went on it, so the
// next page must resume AT this body rather than after it. Getting this
// one line wrong drops exactly one creature from the catalogue per page,
// which nothing downstream could ever notice.
budgetCut = true;
last = body - 1;
scanned--;
break;
}
}
}
if (timedOut)
page.Cut("limit");
// The walk reached the end of the addressable range without either budget stopping it.
// Derived from the two flags rather than from the row count, because a page that ends
// exactly on a boundary is indistinguishable from a finished one by count alone —
// §3.4's whole argument for `cut` existing.
bool finished = !timedOut && !budgetCut && last >= MaxBody;
int held;
lock (_sync)
{
if (_catalog == catalog)
{
catalog.Next = Math.Max(catalog.Next, last + 1);
catalog.LastUsed = DateTime.UtcNow;
if (finished)
catalog.Complete = true;
}
held = catalog.Order.Count;
}
page.Close();
// Past Close(), which is normally the mistake BridgeCliloc's `from` comment warns
// about — but these three are not knowable until the scan has run, and they cost
// about fifty bytes against PageBuilder's 256-byte reserve, of which Close() itself
// spends around forty. Anything larger than this belongs before the page opens.
sb.Num("scanned", scanned)
.Num("held", held)
.Bool("complete", finished);
BridgeLink.Emit(sb.End());
Sweep();
}
// ── assets.fetch ─────────────────────────────────────────────────────────────────────
private static void OnFetch(Dictionary o)
{
string reqId;
if (!Admit(o, "assets.fetch", out reqId))
return;
var keys = BridgeJson.GetStringList(o, "keys");
if (keys.Count == 0)
{
BridgeAssets.Fail(reqId, "BAD_REQUEST",
"assets.fetch requires a non-empty `keys` array");
return;
}
if (keys.Count > BridgeConfig.AssetFetchKeys)
{
BridgeAssets.Fail(reqId, "BAD_REQUEST",
"assets.fetch takes at most " + BridgeConfig.AssetFetchKeys
+ " keys per request (asked for " + keys.Count + ")");
return;
}
var catalog = BridgeJson.GetString(o, "catalog");
var cursor = BridgeJson.GetString(o, "cursor");
BridgeAssets.Accept(reqId, "assets.fetch", () => ReplyFetch(reqId, keys, catalog, cursor));
}
private static void ReplyFetch(string reqId, List keys, string expected, string cursor)
{
string imagingReason;
if (!BridgeAssets.ImagingOk(out imagingReason))
{
BridgeAssets.Fail(reqId, "UNAVAILABLE",
"this shard host cannot render images - Mono's System.Drawing needs "
+ "libgdiplus. (" + imagingReason + ")");
return;
}
string id = SourceId();
if (expected != null && expected != id)
{
// The client files moved between the manifest and this fetch. Refusing is the only
// honest answer: the keys were chosen against a catalogue that no longer describes
// what is on disk, and serving them would mix two clients in one import with no
// error anywhere.
BridgeAssets.Fail(reqId, "UNREADABLE",
"the shard's client files changed since that manifest was read (catalog "
+ expected + " is now " + id + "); start the import again");
return;
}
Catalog catalog;
lock (_sync)
{
if (_catalog == null || _catalog.Id != id)
_catalog = new Catalog { Id = id };
catalog = _catalog;
catalog.LastUsed = DateTime.UtcNow;
}
int from = ParseKeyCursor(cursor);
var sb = BridgeJson.Begin("assets.fetch.ok");
sb.Str("reqId", reqId)
.Str("family", Family)
.Str("catalog", catalog.Id)
.Num("extractorVersion", BridgeAssets.EXTRACTOR_VERSION)
.Num("asked", keys.Count)
.Num("from", from);
var page = new BridgeAssets.PageBuilder(sb, "rows", BridgeConfig.AssetBatchBytes);
int i = from;
using (var readers = new Readers())
{
for (; i < keys.Count; i++)
{
var item = Render(catalog, readers, keys[i]);
if (!page.TryAdd(item, "k:" + (i + 1).ToString(CultureInfo.InvariantCulture)))
break;
}
}
page.Close();
sb.Num("sent", page.Count);
BridgeLink.Emit(sb.End());
Sweep();
}
///
/// One key to one row, with the bytes.
///
/// A key this shard cannot serve is a **row**, not a failed request: the website asked
/// for a list, and one key naming a body whose art this client does not carry must not
/// cost the other four hundred. `status` distinguishes the two ways that happens —
/// `absent` (this client has no art at that key, the expected answer for two thirds of
/// the player bodies) and `unsupported` (a key shape this phase does not serve, which
/// is a website bug rather than a client gap).
///
private static string Render(Catalog catalog, Readers readers, string key)
{
int body;
if (!TryParseKey(key, out body))
{
var bad = new StringBuilder(96);
bad.Append("{\"key\":");
BridgeJson.Text(bad, key);
bad.Append(",\"status\":\"unsupported\"}");
return bad.ToString();
}
Sprite sprite = Resolve(catalog, readers, body);
var item = new StringBuilder(2048);
item.Append("{\"key\":");
BridgeJson.Text(item, key);
if (sprite == null)
{
item.Append(",\"status\":\"absent\"}");
return item.ToString();
}
item.Append(",\"status\":\"ok\"");
item.Append(",\"sha256\":\"").Append(sprite.Sha256).Append('"');
item.Append(",\"bytes\":").Append(sprite.Png.Length.ToString(CultureInfo.InvariantCulture));
item.Append(",\"width\":").Append(sprite.Width.ToString(CultureInfo.InvariantCulture));
item.Append(",\"height\":").Append(sprite.Height.ToString(CultureInfo.InvariantCulture));
item.Append(",\"body\":").Append(sprite.Body.ToString(CultureInfo.InvariantCulture));
item.Append(",\"direction\":").Append(sprite.Direction.ToString(CultureInfo.InvariantCulture));
item.Append(",\"png\":\"").Append(Convert.ToBase64String(sprite.Png)).Append("\"}");
return item.ToString();
}
// ── decode ───────────────────────────────────────────────────────────────────────────
///
/// The catalogue entry for one body, decoded and hashed on first sight and cached
/// after. Returns null when this client has no art for it — which is an ordinary
/// answer for well over half of the addressable range, not a failure.
///
private static Sprite Resolve(Catalog catalog, Readers readers, int body)
{
string key = Key(body);
lock (_sync)
{
Sprite cached;
if (catalog.ByKey.TryGetValue(key, out cached))
return cached;
}
int direction = IsPlayerBody(body)
? BridgeConfig.AssetPlayerDirection
: BridgeConfig.AssetCreatureDirection;
int fileType, at;
string reason;
if (!BridgeAssetValidator.ResolveAnimation(body, CatalogAction, direction,
out fileType, out at, out reason))
return null;
FileIndex index = readers.Index(fileType);
if (index == null)
return null;
if (BridgeAssetValidator.CheckEntry(index, at, readers.MulLength(fileType),
readers.VerdataLength, out reason) != BridgeAssetValidator.Verdict.Ok)
{
// The `length 0` case lands here, and it is the 357. The library would hand back
// the previously-decoded body's bitmap for every one of them.
return null;
}
var reader = readers.Reader(fileType);
if (reader == null)
return null;
// `maxFrames: 1` because that is what `firstFrame: true` decodes. Checking frames
// nobody reads would invent refusals, and a checker that refuses real art is worse
// than no checker at all.
if (!reader.AnimationSane(index, at, 1, out reason))
return null;
Sprite sprite;
try
{
sprite = Decode(key, body, direction, fileType);
}
catch (Exception e)
{
Console.WriteLine("[Bridge] catalogue: body {0}: {1}: {2}",
body, e.GetType().Name, e.Message);
return null;
}
if (sprite == null)
return null;
lock (_sync)
{
if (!catalog.ByKey.ContainsKey(key))
{
catalog.ByKey[key] = sprite;
catalog.Order.Add(sprite);
}
return catalog.ByKey[key];
}
}
private static Sprite Decode(string key, int body, int direction, int fileType)
{
int hue = 0;
// `preserveHue: false` — the catalogue is the creature's own art, and a body-level hue
// from Body.def belongs to a specific mob rather than to the species. §5's key scheme
// is where a hued variant is expressed (`static/3922/h33`), not here.
Frame[] frames = Animations.GetAnimation(body, CatalogAction, direction, ref hue, false, true);
if (frames == null || frames.Length == 0 || frames[0] == null)
return null;
Bitmap bitmap = frames[0].Bitmap;
if (bitmap == null || bitmap.Width <= 0 || bitmap.Height <= 0)
return null;
byte[] png = ToPng(bitmap);
if (png == null)
return null;
return new Sprite
{
Key = key,
Body = body,
Direction = direction,
FileType = fileType,
Png = png,
Width = bitmap.Width,
Height = bitmap.Height,
Sha256 = Hash(png)
};
}
///
/// ARGB1555 to a PNG with a transparent background.
///
/// Frame writes 16-bit ARGB1555: a pixel the sprite does not cover is left as
/// zero and a pixel it does cover carries the top bit set. Saving that format straight
/// to PNG asks GDI+ to make the conversion, and what it does with a one-bit alpha
/// channel varies by platform — on Mono it is a different implementation entirely. A
/// sprite that came back with a black rectangle behind it would look fine in a test
/// that only checked the bytes decoded, and wrong on every page that showed it.
///
/// So the expansion is done here, explicitly: alpha bit clear becomes fully
/// transparent, and each 5-bit channel is widened to 8 bits by repeating its high bits
/// ((c << 3) | (c >> 2)) rather than by shifting alone, which would
/// cap white at 248 and tint the whole catalogue.
///
private static byte[] ToPng(Bitmap source)
{
var rect = new Rectangle(0, 0, source.Width, source.Height);
if (source.PixelFormat != PixelFormat.Format16bppArgb1555)
{
// Not what this library has ever produced. Save it rather than reinterpret it:
// guessing at an unknown layout is how a catalogue fills with confident nonsense.
using (var ms = new MemoryStream())
{
source.Save(ms, ImageFormat.Png);
return ms.ToArray();
}
}
using (var target = new Bitmap(source.Width, source.Height, PixelFormat.Format32bppArgb))
{
BitmapData src = source.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format16bppArgb1555);
BitmapData dst = null;
try
{
dst = target.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);
var line = new short[source.Width];
var outLine = new int[source.Width];
for (int y = 0; y < source.Height; y++)
{
Marshal.Copy(new IntPtr(src.Scan0.ToInt64() + ((long)y * src.Stride)),
line, 0, source.Width);
for (int x = 0; x < source.Width; x++)
{
int p = line[x] & 0xFFFF;
if ((p & 0x8000) == 0)
{
outLine[x] = 0;
continue;
}
int r = (p >> 10) & 0x1F;
int g = (p >> 5) & 0x1F;
int b = p & 0x1F;
outLine[x] = unchecked((int)0xFF000000)
| (((r << 3) | (r >> 2)) << 16)
| (((g << 3) | (g >> 2)) << 8)
| ((b << 3) | (b >> 2));
}
Marshal.Copy(outLine, 0, new IntPtr(dst.Scan0.ToInt64() + ((long)y * dst.Stride)),
source.Width);
}
}
finally
{
if (dst != null)
target.UnlockBits(dst);
source.UnlockBits(src);
}
using (var ms = new MemoryStream())
{
target.Save(ms, ImageFormat.Png);
return ms.ToArray();
}
}
}
private static string Hash(byte[] bytes)
{
using (var sha = SHA256.Create())
{
byte[] digest = sha.ComputeHash(bytes);
var sb = new StringBuilder(digest.Length * 2);
foreach (byte b in digest)
sb.Append(b.ToString("x2", CultureInfo.InvariantCulture));
return sb.ToString();
}
}
// ── player bodies (§5.2) ─────────────────────────────────────────────────────────────
///
/// Asked of the shard, never hardcoded.
///
/// Every registered race carries four body ids, and a shard that calls `RegisterRace`
/// adds ids no table of ours could contain. Even on stock ServUO a hardcoded list
/// would already be wrong in a way that is easy to miss: `RaceDefinitions.cs` passes
/// the gargoyle's ghost bodies in the OPPOSITE order to the other two races.
///
/// This set is the whole of what §5.1 gives direction 0 — head-on, facing the viewer,
/// because a character is a portrait and should look at you. Everything else takes
/// direction 1, the front three-quarter, because head-on is the least legible view of
/// a four-legged creature: a wolf seen from the front is a dark blob.
///
///
/// Cached for the life of the process: `RegisterRace` runs at Configure time, before
/// anything on this plane can be asked a question, so the set cannot change under a
/// running shard. Rebuilding it per body would enumerate every race 2,047 times per
/// scan to answer a question whose answer is twelve integers.
///
private static HashSet _playerBodies;
private static HashSet PlayerBodies()
{
var cached = _playerBodies;
if (cached != null)
return cached;
var set = new HashSet();
try
{
foreach (var race in Race.AllRaces)
{
if (race == null)
continue;
set.Add(race.MaleBody);
set.Add(race.FemaleBody);
set.Add(race.MaleGhostBody);
set.Add(race.FemaleGhostBody);
}
}
catch (Exception e)
{
Console.WriteLine("[Bridge] catalogue: cannot enumerate races: {0}", e.Message);
}
set.Remove(0);
_playerBodies = set;
return set;
}
private static bool IsPlayerBody(int body)
{
return PlayerBodies().Contains(body);
}
private static void WritePlayerBodies(StringBuilder sb)
{
var bodies = new List(PlayerBodies());
bodies.Sort();
sb.Append(",\"playerBodies\":[");
for (int i = 0; i < bodies.Count; i++)
{
if (i > 0)
sb.Append(',');
sb.Append(bodies[i].ToString(CultureInfo.InvariantCulture));
}
sb.Append(']');
}
// ── keys, cursors and the source id ──────────────────────────────────────────────────
private static string Key(int body)
{
return "body/" + body.ToString(CultureInfo.InvariantCulture)
+ "/a" + CatalogAction.ToString(CultureInfo.InvariantCulture);
}
///
/// `body/<id>/a0`, and nothing else in this phase. A deeper key
/// (`body/400/a2/f3`) is well-formed under §5 and simply not served yet, so it comes
/// back `unsupported` rather than being silently read as its own first frame.
///
private static bool TryParseKey(string key, out int body)
{
body = 0;
if (key == null)
return false;
string[] parts = key.Split('/');
if (parts.Length != 3 || parts[0] != "body")
return false;
if (!Int32.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out body))
return false;
if (body < 1 || body > MaxBody)
return false;
return parts[2] == "a" + CatalogAction.ToString(CultureInfo.InvariantCulture);
}
private static int ParseBodyCursor(string cursor)
{
if (cursor == null)
return 1;
int value;
if (cursor.StartsWith("b:", StringComparison.Ordinal)
&& Int32.TryParse(cursor.Substring(2), NumberStyles.None, CultureInfo.InvariantCulture, out value))
return Math.Max(1, value + 1);
return 1;
}
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 the bytes, hashed into one short id.
///
/// Deliberately (size, mtime) rather than content: §6 makes exactly the same choice
/// for the source gate, and for the same reason — the anim files are 195 MB and
/// hashing them on every page of a walk would turn a manifest into a minute.
/// `assets.sources` is where an operator gets content hashes, computed off the request
/// path; this is a "did it move while I was reading" check, which (size, mtime)
/// answers.
///
private static string SourceId()
{
var sb = new StringBuilder(256);
sb.Append(BridgeAssets.EXTRACTOR_VERSION)
.Append(':').Append(BridgeConfig.AssetPlayerDirection)
.Append(':').Append(BridgeConfig.AssetCreatureDirection);
for (int fileType = 1; fileType <= 5; fileType++)
{
sb.Append('|');
string path = BridgeAssetValidator.AnimDataPath(fileType);
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 Hash(Encoding.UTF8.GetBytes(sb.ToString())).Substring(0, 16);
}
// ── shared plumbing ──────────────────────────────────────────────────────────────────
///
/// The two gates every request on this plane passes: a correlation id, and the
/// operator's consent. Both refuse rather than answer.
///
private static bool Admit(Dictionary o, string kind, out string reqId)
{
reqId = BridgeJson.GetString(o, "reqId");
if (reqId == null)
{
BridgeAssets.Fail(null, "BAD_REQUEST", kind + " requires a reqId");
return false;
}
if (!BridgeConfig.AssetsEnabled)
{
BridgeAssets.Fail(reqId, "DISABLED", "asset extraction is disabled on this shard");
return false;
}
return true;
}
///
/// The five anim files' index and record readers, opened for one reply and closed with
/// it. Holding them across replies would keep handles on the operator's client files
/// for as long as the cache lives, for no gain: opening five is microseconds and a
/// page decodes hundreds of sprites through them.
///
private sealed class Readers : IDisposable
{
private readonly FileIndex[] _index = new FileIndex[6];
private readonly BridgeAssetValidator.RecordReader[] _reader =
new BridgeAssetValidator.RecordReader[6];
private readonly long[] _length = new long[6];
private readonly bool[] _open = new bool[6];
public readonly long VerdataLength;
public Readers()
{
VerdataLength = BridgeAssetValidator.MulLength(VerdataPath());
}
private static string VerdataPath()
{
try
{
return Files.GetFilePath("verdata.mul");
}
catch
{
return null;
}
}
private void Ensure(int fileType)
{
if (fileType < 1 || fileType > 5 || _open[fileType])
return;
_open[fileType] = true;
string path = BridgeAssetValidator.AnimDataPath(fileType);
if (path == null)
return;
try
{
_index[fileType] = BridgeAssetValidator.OpenAnimIndex(fileType);
_length[fileType] = BridgeAssetValidator.MulLength(path);
_reader[fileType] = new BridgeAssetValidator.RecordReader(path, VerdataPath());
}
catch (Exception e)
{
Console.WriteLine("[Bridge] catalogue: anim file type {0}: {1}",
fileType, e.Message);
}
}
public FileIndex Index(int fileType)
{
Ensure(fileType);
return fileType >= 1 && fileType <= 5 ? _index[fileType] : null;
}
public long MulLength(int fileType)
{
Ensure(fileType);
return fileType >= 1 && fileType <= 5 ? _length[fileType] : 0;
}
public BridgeAssetValidator.RecordReader Reader(int fileType)
{
Ensure(fileType);
return fileType >= 1 && fileType <= 5 ? _reader[fileType] : null;
}
public void Dispose()
{
for (int i = 1; i <= 5; i++)
{
if (_reader[i] == null)
continue;
try
{
_reader[i].Dispose();
}
catch
{
// Closing a read-only handle. Nothing useful is left to do.
}
}
}
}
///
/// Lets a megabyte of the operator's client art go once nothing has asked for it in
/// five minutes. A rebuild costs one scan and, because is
/// derived from the files rather than minted per build, it produces the same catalogue
/// id — so an import that spans the drop does not have to start over.
///
private static void Sweep()
{
lock (_sync)
{
if (_catalog == null)
return;
if (DateTime.UtcNow - _catalog.LastUsed > IdleFor)
_catalog = null;
}
}
public static string Status()
{
lock (_sync)
{
if (_catalog == null)
return "catalog(empty)";
return String.Format("catalog(id={0} held={1} next={2} complete={3})",
_catalog.Id, _catalog.Order.Count, _catalog.Next, _catalog.Complete);
}
}
}
}