feat(asset-bridge): item and land art on demand, hued where the files are (Phase 5)

The body catalogue is a set; this is not. This client addresses 49,152 static
ids and has art for 39,189 of them, plus 4,244 land tiles of 16,384 -- and hues
multiply the statics by three thousand. So there is no manifest and no scan:
`assets.fetch` grows two more families (`static`, `land`) and answers the keys
the website's own data names.

`assets.fetch` becomes shared plumbing. BridgeAssets now owns the command, does
the reqId/consent/key-ceiling checks once, derives the family from the keys
themselves (§5 made the key the address; a request that also named its family
would have two places to be wrong and one of them silent) and dispatches to the
reader that registered it. A batch must be of one family, because the reply
carries one `catalog` id. `assets.sources` gains `families` -- additive, so the
protocol stays 8, and EXTRACTOR_VERSION stays 2 because no existing key's bytes
change.

Two traps, both in §4.5's family -- a confident, plausible, wrong picture:

- `Art.GetStatic` memoises into a static Bitmap[0xFFFF] and hands back the SAME
  instance, while `Hue.ApplyTo` repaints in place. Hue a static once and the
  plain key comes back hued from then on, and the next hue stacks on the last.
  Measured on this client before the fix. `Files.CacheData` is now off for the
  life of the process; `TryHue` re-checks it and refuses rather than risk it,
  and the same flag decides whether a bitmap is ours to dispose.

- `PartialHue` decides whether a hue repaints every pixel or only the grey ones,
  per item id, out of `tiledata.mul` -- 13,259 of 65,536 ids on this client.
  Item 597 is a wooden screen with painted flowers: one mode reddens the
  flowers, the other the whole screen. Both decode. The first cut of this reader
  bound `TileData` to ServUO's OWN `Server.TileData` (the enclosing namespace
  beats `using Ultima;`, and it has a PartialHue flag too), which compiled and
  refused every hued key at runtime. Every such type is spelled `Ultima.` now.

Land takes no hue segment: the mode that decides how is an item flag and land
has no equivalent, so `land/3/h33` is refused rather than guessed. `h0` is not a
key either -- hue 0 means "not hued", and the plain key already names it.

Measured through the reader over the whole range: 39,189 statics and 4,244 land
tiles served, and the only refusals are the 9,963 + 12,140 empty index slots
§4.5 predicted. Nothing that carries art is refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-11 05:58:45 -05:00
parent 452be696df
commit 1be1f24562
5 changed files with 1187 additions and 139 deletions

View File

@@ -1,7 +1,10 @@
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 System.Threading;
@@ -123,7 +126,58 @@ namespace Server.Custom.Bridge
if (!BridgeConfig.Enabled)
return;
DisableTheLibraryCache();
BridgeBoot.RegisterHandler("assets.sources", OnSources);
BridgeBoot.RegisterHandler("assets.fetch", OnFetch);
}
/// <summary>
/// **Turns <c>Ultima.Files.CacheData</c> off for the life of the process** (phase 5,
/// §17.10). One line, and it answers two separate problems that both end in a
/// confident wrong picture or an out-of-memory shard.
///
/// **The poisoning.** <c>Art.GetStatic</c> and <c>Art.GetLand</c> memoise into a
/// <c>Bitmap[0xFFFF]</c> and hand back **the same instance** on every call, while
/// <c>Hue.ApplyTo</c> repaints a bitmap **in place**. So hueing a static edits the
/// library's cached copy: measured on this client, hue item 3922 once and every later
/// request for the *plain* 3922 comes back hued, and a second hue stacks on the first.
/// Nothing downstream can see it — the row is the right size, the right shape and the
/// right id. It is §4.5's failure mode arriving through a completely different door.
///
/// **The retention.** That array is never trimmed. Decoding this client's 39,189
/// statics once would leave 74 MB of <c>Bitmap</c> in a static field of a game server,
/// kept for as long as the process lives, to serve pictures nobody asked for twice.
///
/// The obvious alternative — copy each bitmap before hueing — was rejected, and not
/// only for the retention: <c>new Bitmap(src)</c> **throws** on the
/// <c>Format16bppArgb1555</c> these decoders produce, so the copy has to name the
/// source pixel format explicitly, which is a subtlety on the wrong side of a
/// correctness boundary.
///
/// **What it costs is nothing measurable here.** <c>Animations</c> — the whole of the
/// body catalogue — does not consult this flag at all, and
/// <see cref="BridgeCatalog"/> and <see cref="BridgeArt"/> each keep their own cache of
/// *encoded PNG bytes*, which is the thing worth holding: a tenth of the size, already
/// hashed, and released when it goes idle.
///
/// It is a process-global on a library nothing else in this overlay reads, which is why
/// setting it here rather than saving and restoring it around each decode is safe —
/// and a save/restore would not be, because the asset worker is a thread.
/// </summary>
private static void DisableTheLibraryCache()
{
try
{
Files.CacheData = false;
}
catch (Exception e)
{
// A client this library cannot even open. The families report that for themselves,
// per key, with a reason; it must not stop the plugin booting.
Console.WriteLine("[Bridge] assets: could not disable the Ultima bitmap cache: {0}",
e.Message);
}
}
public static string Status()
@@ -228,6 +282,248 @@ namespace Server.Custom.Bridge
BridgeLink.Emit(sb.End());
}
// ── assets.fetch, and the families behind it (§5, phase 5) ───────────────────────────
/// <summary>
/// One family's answer to a fetch. Runs on the asset worker, never the Core thread.
/// </summary>
internal delegate void FamilyFetch(string reqId, List<string> keys, string catalog, string cursor);
private static readonly Dictionary<string, FamilyFetch> _families =
new Dictionary<string, FamilyFetch>(StringComparer.Ordinal);
/// <summary>
/// Claims one §5 key family for a reader.
///
/// Phase 3 gave <c>assets.fetch</c> to the body catalogue outright, which was right
/// while there was one family and wrong the moment there were three: the command is
/// the *transport*, and the family is a property of the key. So the shared parts — the
/// correlation id, the operator's consent, the key-count ceiling, and deciding which
/// reader a request belongs to — live here once, and a family only ever sees a list of
/// keys it owns.
///
/// Registration is order-independent on purpose: ServUO calls every
/// <c>Initialize</c> in an order nothing here controls, and this fills a dictionary the
/// handler does not read until a request arrives.
/// </summary>
internal static void RegisterFamily(string name, FamilyFetch fetch)
{
lock (_families)
{
_families[name] = fetch;
}
}
/// <summary>The families this shard can serve, for §6's stage 1 and for diagnostics.</summary>
internal static List<string> Families()
{
lock (_families)
{
var names = new List<string>(_families.Keys);
names.Sort(StringComparer.Ordinal);
return names;
}
}
private static FamilyFetch FamilyFor(string name)
{
lock (_families)
{
FamilyFetch fetch;
return _families.TryGetValue(name, out fetch) ? fetch : null;
}
}
/// <summary>
/// The family segment of a §5 key: everything before the first `/`.
/// </summary>
internal static string FamilyOfKey(string key)
{
if (String.IsNullOrEmpty(key))
return null;
int slash = key.IndexOf('/');
return slash <= 0 ? null : key.Substring(0, slash);
}
/// <summary>
/// §14's `assets.fetch`, for every family.
///
/// **The family is derived from the keys and is not a request field.** §5 made the key
/// the address of an asset, so a request that had to name its family as well would have
/// two places to be wrong and one of them silent. A batch must be of one family —
/// mixing them is refused rather than split — because the reply carries a single
/// `catalog` id, and that id is what stops an operator patching their client mid-import
/// from stitching one asset set out of two. Two families, two fingerprints, and a reply
/// that claimed one of them would be lying about the other.
/// </summary>
private static void OnFetch(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
if (reqId == null)
{
Fail(null, "BAD_REQUEST", "assets.fetch requires a reqId");
return;
}
if (!BridgeConfig.AssetsEnabled)
{
Fail(reqId, "DISABLED", "asset extraction is disabled on this shard");
return;
}
var keys = BridgeJson.GetStringList(o, "keys");
if (keys.Count == 0)
{
Fail(reqId, "BAD_REQUEST", "assets.fetch requires a non-empty `keys` array");
return;
}
if (keys.Count > BridgeConfig.AssetFetchKeys)
{
Fail(reqId, "BAD_REQUEST",
"assets.fetch takes at most " + BridgeConfig.AssetFetchKeys
+ " keys per request (asked for " + keys.Count + ")");
return;
}
string family = FamilyOfKey(keys[0]);
for (int i = 1; i < keys.Count; i++)
{
if (String.Equals(FamilyOfKey(keys[i]), family, StringComparison.Ordinal))
continue;
Fail(reqId, "BAD_REQUEST",
"assets.fetch takes keys of one family per request; this one mixes '"
+ family + "' with '" + FamilyOfKey(keys[i]) + "'");
return;
}
FamilyFetch fetch = FamilyFor(family);
if (fetch == null)
{
Fail(reqId, "BAD_REQUEST",
"this shard serves no '" + family + "' asset family (it serves "
+ String.Join(", ", Families().ToArray()) + ")");
return;
}
var catalog = BridgeJson.GetString(o, "catalog");
var cursor = BridgeJson.GetString(o, "cursor");
Accept(reqId, "assets.fetch", () => fetch(reqId, keys, catalog, cursor));
}
/// <summary>
/// ARGB1555 to a PNG with a transparent background.
///
/// <c>Frame</c> 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>(c &lt;&lt; 3) | (c &gt;&gt; 2)</c>) rather than by shifting alone, which would
/// cap white at 248 and tint the whole catalogue.
/// </summary>
internal static byte[] BitmapToPng(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();
}
}
}
/// <summary>
/// SHA-256, lowercase hex. Shared because the hash in a manifest row, the hash in a
/// fetch row and the hash the website stores must be one function.
/// </summary>
internal static string Sha256Hex(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();
}
}
/// <summary>
/// The asset plane's one refusal frame, shared by every family on it.
///
@@ -354,6 +650,24 @@ namespace Server.Custom.Bridge
sb.Str("artDataFile", artData == null ? null : Path.GetFileName(artData));
// Which §5 families this shard can be asked for. Additive, so the protocol stays 8: a
// consumer that does not read it behaves exactly as it did. One that does can tell an
// older overlay (bodies only) from this one without discovering it as a refused fetch
// halfway through a warm pass.
var families = Families();
sb.Append(",\"families\":[");
for (int i = 0; i < families.Count; i++)
{
if (i > 0)
sb.Append(',');
BridgeJson.Text(sb, families[i]);
}
sb.Append(']');
var page = new PageBuilder(sb, "files", BridgeConfig.AssetBatchBytes);
bool anyMissingHash = false;