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:
@@ -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 << 3) | (c >> 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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user