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,11 +1,8 @@
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;
@@ -101,7 +98,10 @@ namespace Server.Custom.Bridge
return;
BridgeBoot.RegisterHandler("assets.manifest", OnManifest);
BridgeBoot.RegisterHandler("assets.fetch", OnFetch);
// `assets.fetch` is shared plumbing as of phase 5 (§5): BridgeAssets owns the command,
// decides which family a batch of keys belongs to, and calls the reader that owns it.
BridgeAssets.RegisterFamily(Family, ReplyFetch);
}
// ── the cache ────────────────────────────────────────────────────────────────────────
@@ -324,36 +324,11 @@ namespace Server.Custom.Bridge
// ── assets.fetch ─────────────────────────────────────────────────────────────────────
private static void OnFetch(Dictionary<string, object> 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));
}
/// <summary>
/// The `body` family's half of <c>assets.fetch</c>. The correlation id, the operator's
/// consent, the key ceiling and the family decision have already been made by
/// <see cref="BridgeAssets.RegisterFamily"/>'s caller; every key here is this family's.
/// </summary>
private static void ReplyFetch(string reqId, List<string> keys, string expected, string cursor)
{
string imagingReason;
@@ -651,7 +626,7 @@ namespace Server.Custom.Bridge
Png = png,
Width = pixels.Width,
Height = pixels.Height,
Sha256 = Hash(png),
Sha256 = BridgeAssets.Sha256Hex(png),
Source = "uop"
};
}
@@ -673,7 +648,7 @@ namespace Server.Custom.Bridge
if (bitmap == null || bitmap.Width <= 0 || bitmap.Height <= 0)
return null;
byte[] png = ToPng(bitmap);
byte[] png = BridgeAssets.BitmapToPng(bitmap);
if (png == null)
return null;
@@ -687,112 +662,11 @@ namespace Server.Custom.Bridge
Png = png,
Width = bitmap.Width,
Height = bitmap.Height,
Sha256 = Hash(png),
Sha256 = BridgeAssets.Sha256Hex(png),
Source = "legacy"
};
}
/// <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>
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) ─────────────────────────────────────────────────────────────
/// <summary>
@@ -997,7 +871,7 @@ namespace Server.Custom.Bridge
}
}
return Hash(Encoding.UTF8.GetBytes(sb.ToString())).Substring(0, 16);
return BridgeAssets.Sha256Hex(Encoding.UTF8.GetBytes(sb.ToString())).Substring(0, 16);
}
// ── shared plumbing ──────────────────────────────────────────────────────────────────