feat(asset-bridge): the body catalogue and slug to body id (Phase 3)

Two request families, and they run on opposite threads on purpose.

`assets.bodies` (BridgeBodies) answers the question only code inside ServUO
can: the atlas knows a creature by the class name in Spawns/*.xml, the client
knows it by a body id, and nothing in the tree declares the mapping. Construct
the type, read Body.BodyID, Delete(). That is world mutation, so it answers on
the CORE thread and is the one family here that does not take the asset
worker's slot -- and the batch is capped at 100 names, REFUSED rather than
truncated, because a truncated answer is indistinguishable from a complete one
from the website's side.

`assets.manifest` / `assets.fetch` (BridgeCatalog) are the catalogue, on the
worker. The manifest carries { key, sha256, bytes, width, height } and no
pixels, so an Update fetches only what moved; the fetch carries base64 PNG.
The scan keeps the bytes it hashed rather than decoding all 787 sprites twice.

Three things worth stating about the shapes:

- It pages on the WALL CLOCK as well as on bytes. The rows are ~90 bytes and
  the whole catalogue is one page by the byte budget, but building it means
  decoding hundreds of sprites against a 10 s reply timeout.
- `catalog` is derived from the client files (sizes, mtimes, both direction
  settings, EXTRACTOR_VERSION), not minted per build -- the cache is released
  when idle, and a fresh id per build would force a restart mid-import although
  nothing about the client moved.
- ARGB1555 is expanded to 32bpp here rather than handed to GDI+, because what
  it does with a one-bit alpha channel varies by platform and a black rectangle
  behind every sprite would pass any test that only checked the bytes decoded.

Nothing trusts the library's success. Every body goes through CheckEntry and
AnimationRecordSane before it is decoded, which is what keeps the 357 bodies
whose index entry reads `length 0` -- and which the decoder hands back the
PREVIOUS creature's bitmap for -- out of the catalogue.

Walked on a live shard: 787 rows in one 734 ms page; bodies 320, 607, 666 all
absent rather than wrong; 783 at direction 1 and 4 at direction 0; all 455 stock
creature classes resolved at ~190 ms per 100 with zero mobiles leaked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-10 18:40:12 -05:00
parent c79a2a3b2b
commit 64c0ec00b1
6 changed files with 1375 additions and 0 deletions

View File

@@ -97,6 +97,33 @@ namespace Server.Custom.Bridge
public static bool AssetsEnabled { get; private set; }
public static int AssetBatchBytes { get; private set; }
// How many types one `assets.bodies` request may name (§8, phase 3). This is the ONLY
// asset-plane bound counted in items rather than bytes, and deliberately so: the cost it
// bounds is not the size of the reply, it is constructing and deleting that many real
// mobiles ON THE CORE THREAD, between two ticks of the world.
public static int AssetBodyBatch { get; private set; }
// How many keys one `assets.fetch` request may name. Bytes still cut the page; this only
// bounds how large a request the shard will parse and walk at all.
public static int AssetFetchKeys { get; private set; }
// The wall-clock budget for one catalogue page (§4.8, phase 3). The catalogue's rows are
// ninety bytes, so the byte budget never stops it -- but building them means decoding
// hundreds of animations, and the sidecar gives a reply ten seconds. Kept well under that,
// because the reply still has to be built, serialised and cross the wire afterwards.
public static int AssetScanMs { get; private set; }
// Which direction the catalogue renders (§5.1). Both are settings and neither is in the
// asset key, because five directions would five-fold every count in §11 to express a
// choice nobody is going to vary.
//
// The split is not arbitrary and was found by RENDERING all five rather than from a table:
// index 0 is head-on, which is what a character portrait wants and the least legible view
// there is of a four-legged creature. A wolf seen from the front is a dark blob; at index
// 1, the front three-quarter, it is unmistakably a wolf.
public static int AssetPlayerDirection { get; private set; }
public static int AssetCreatureDirection { get; private set; }
public static int LeaseMaxDurationSec { get; private set; }
public static int LeaseGraceSec { get; private set; }
@@ -168,6 +195,33 @@ namespace Server.Custom.Bridge
if (AssetBatchBytes > 512 * 1024)
AssetBatchBytes = 512 * 1024;
AssetBodyBatch = Config.Get("Bridge.AssetBodyBatch", 100);
if (AssetBodyBatch < 1)
AssetBodyBatch = 1;
if (AssetBodyBatch > 500)
AssetBodyBatch = 500;
AssetFetchKeys = Config.Get("Bridge.AssetFetchKeys", 2000);
if (AssetFetchKeys < 1)
AssetFetchKeys = 1;
if (AssetFetchKeys > 10000)
AssetFetchKeys = 10000;
AssetScanMs = Config.Get("Bridge.AssetScanMs", 3000);
if (AssetScanMs < 250)
AssetScanMs = 250;
// Half the sidecar's 10 s reply timeout, so the page still has time to be serialised
// and written after the scan stops. A budget set at the timeout would produce replies
// that are always thrown away.
if (AssetScanMs > 5000)
AssetScanMs = 5000;
// Clamped to 0-4: 5-7 are the client MIRRORING 1-3, which `Frame` decodes through a
// different pointer-arithmetic branch that nothing in BridgeAssetValidator has
// checked. Accepting one would hand an unverified write path a bitmap to fill.
AssetPlayerDirection = Clamp(Config.Get("Bridge.AssetPlayerDirection", 0), 0, 4);
AssetCreatureDirection = Clamp(Config.Get("Bridge.AssetCreatureDirection", 1), 0, 4);
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
@@ -518,6 +572,14 @@ namespace Server.Custom.Bridge
return fallback;
}
private static int Clamp(int value, int min, int max)
{
if (value < min)
return min;
return value > max ? max : value;
}
public static string Describe()
{
return String.Format(