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:
@@ -307,6 +307,39 @@ AssetsEnabled=true
|
||||
# still fits.
|
||||
AssetBatchBytes=524288
|
||||
|
||||
# How many ServUO class names one `assets.bodies` request may carry (phase 3). The only
|
||||
# bound on this plane counted in items rather than bytes, because what it bounds is not
|
||||
# reply size -- it is constructing and deleting that many real mobiles ON THE CORE
|
||||
# THREAD, between two ticks of the world. A larger request is refused, never truncated.
|
||||
# Clamped to [1, 500].
|
||||
AssetBodyBatch=100
|
||||
|
||||
# How many keys one `assets.fetch` request may name. The byte budget above still decides
|
||||
# where a page is cut; this only bounds how large a request the shard will parse at all.
|
||||
# Clamped to [1, 10000].
|
||||
AssetFetchKeys=2000
|
||||
|
||||
# The wall-clock budget for one catalogue page, in milliseconds. The catalogue's manifest
|
||||
# rows are ~90 bytes so the byte budget never stops it -- but building them means
|
||||
# decoding hundreds of animations, and the sidecar waits 10 s for a reply. Kept well
|
||||
# under that, because the page still has to be serialised and written afterwards.
|
||||
# Clamped to [250, 5000].
|
||||
AssetScanMs=3000
|
||||
|
||||
# Which direction the catalogue renders. NOT part of the asset key: five directions
|
||||
# would five-fold every count in the working set to express a choice nobody varies.
|
||||
#
|
||||
# The split was found by RENDERING all five, not from a table. 0 is head-on, facing the
|
||||
# viewer -- 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). 1 is the front
|
||||
# three-quarter, where the same wolf is unmistakably a wolf.
|
||||
#
|
||||
# Which bodies count as player bodies is asked of the shard (every registered race's
|
||||
# male/female/ghost ids), never hardcoded. Clamped to [0, 4]: 5-7 are the client
|
||||
# mirroring 1-3 through a decode branch this overlay has not verified.
|
||||
AssetPlayerDirection=0
|
||||
AssetCreatureDirection=1
|
||||
|
||||
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
|
||||
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
||||
# Config.Get returns the default of false when a key is missing, so a deployed
|
||||
|
||||
@@ -473,6 +473,21 @@ namespace Server.Custom.Bridge
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this host can produce a picture, for the families that produce pictures.
|
||||
///
|
||||
/// <see cref="WriteImaging"/> reports this on the source gate so an operator learns it
|
||||
/// while setting the shard up. The catalogue needs the same answer as a *decision* —
|
||||
/// it must refuse rather than throw a <c>DllNotFoundException</c> out of the middle of
|
||||
/// a decode loop — so the check itself is shared and this is its one accessor.
|
||||
/// </summary>
|
||||
internal static bool ImagingOk(out string reason)
|
||||
{
|
||||
CheckImaging();
|
||||
reason = _imagingReason;
|
||||
return _imagingOk;
|
||||
}
|
||||
|
||||
private static void CheckImaging()
|
||||
{
|
||||
if (_imagingChecked)
|
||||
|
||||
254
overlay/Scripts/Custom/Bridge/BridgeBodies.cs
Normal file
254
overlay/Scripts/Custom/Bridge/BridgeBodies.cs
Normal file
@@ -0,0 +1,254 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// **Slug to body id — the part only the shard can do** (docs/link/v8.md §8, protocol 8,
|
||||
/// phase 3).
|
||||
///
|
||||
/// The spawn atlas knows a creature by a **slug** derived from the class name it found in
|
||||
/// `Spawns/*.xml` ("giant-spider"). The client knows the same creature by a **body id**
|
||||
/// (28). Nothing in the ServUO tree declares that mapping as data. Today an operator
|
||||
/// bridges it by hand, grepping `Scripts/Mobiles/Normal/<Name>.cs` for `Body =`,
|
||||
/// which appears as a decimal, as hex (`0xD1`), as `Utility.RandomList(35, 36)` and as an
|
||||
/// `m_IDs[]` table — a parse that is wrong on the shard's own custom creatures, which is
|
||||
/// precisely the set an operator most wants pictures for.
|
||||
///
|
||||
/// Inside ServUO the problem does not exist: construct the type, read `Body.BodyID`,
|
||||
/// delete it. <c>BridgeWorld.cs</c> already does exactly that for a different feature.
|
||||
///
|
||||
/// **This is the one asset-plane family that does NOT run on the asset worker**, and the
|
||||
/// reason is the whole point of §8. Constructing and deleting a mobile is world mutation,
|
||||
/// so it must happen on the Core thread — while the decode in <see cref="BridgeCatalog"/>
|
||||
/// must happen off it, because it reads hundreds of megabytes and would stop the world for
|
||||
/// every player on the shard. That split is why body resolution is its own request kind
|
||||
/// rather than a step inside asset extraction.
|
||||
///
|
||||
/// Two consequences follow from answering on the Core thread, and both are bounds:
|
||||
///
|
||||
/// **The batch is small and the shard enforces the cap itself.** Every type constructed
|
||||
/// here runs a real constructor — packing items, rolling skills, starting AI timers — and
|
||||
/// all of that happens between two ticks of the world. The website chunks its own list;
|
||||
/// a request over <see cref="BridgeConfig.AssetBodyBatch"/> names is **refused** rather
|
||||
/// than truncated, so the two sides cannot quietly disagree about what was answered.
|
||||
///
|
||||
/// **It does not take the asset plane's single slot.** The slot exists to stop several
|
||||
/// large replies queueing at once (§3.2); this reply is a few kilobytes and the work is
|
||||
/// not on the worker, so claiming the slot would only make a body pass and a catalogue
|
||||
/// page refuse each other for no benefit.
|
||||
///
|
||||
/// **A creature whose constructor randomises its body reports one of its variants**, not
|
||||
/// an error and not a set. Constructing twice to detect that would double every side
|
||||
/// effect above to learn something the bestiary does not render differently — both ids are
|
||||
/// the same creature. The answer is stable enough to cache and cheap enough to redo.
|
||||
/// </summary>
|
||||
public static class BridgeBodies
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
BridgeBoot.RegisterHandler("assets.bodies", OnBodies);
|
||||
}
|
||||
|
||||
// ── the request ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static void OnBodies(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
if (reqId == null)
|
||||
{
|
||||
// Rule 1 of the asset plane: without a correlation id this reply lands on the
|
||||
// event path, is persisted to the sidecar's store and broadcast to every
|
||||
// subscriber. Refuse rather than answer.
|
||||
BridgeAssets.Fail(null, "BAD_REQUEST", "assets.bodies requires a reqId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!BridgeConfig.AssetsEnabled)
|
||||
{
|
||||
BridgeAssets.Fail(reqId, "DISABLED", "asset extraction is disabled on this shard");
|
||||
return;
|
||||
}
|
||||
|
||||
var types = BridgeJson.GetStringList(o, "types");
|
||||
|
||||
if (types.Count == 0)
|
||||
{
|
||||
BridgeAssets.Fail(reqId, "BAD_REQUEST",
|
||||
"assets.bodies requires a non-empty `types` array of ServUO class names");
|
||||
return;
|
||||
}
|
||||
|
||||
if (types.Count > BridgeConfig.AssetBodyBatch)
|
||||
{
|
||||
// Refuse, never truncate. A silently shortened answer looks identical to a
|
||||
// complete one from the website's side, and the types that fell off the end would
|
||||
// be recorded as "asked and unanswerable" rather than "never asked".
|
||||
BridgeAssets.Fail(reqId, "BAD_REQUEST",
|
||||
"assets.bodies takes at most " + BridgeConfig.AssetBodyBatch
|
||||
+ " types per request (asked for " + types.Count + "); send them in chunks");
|
||||
return;
|
||||
}
|
||||
|
||||
Reply(reqId, types);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core thread. Constructs each type once, reads its body, deletes it.
|
||||
///
|
||||
/// Every outcome is a **row**, never a failed request: a shard is expected to be asked
|
||||
/// about types it does not have (an atlas built from a tree that has since changed, a
|
||||
/// spawn file naming a creature from a script package the operator removed), and a
|
||||
/// status screen that fails the whole pass over one of those teaches an operator to
|
||||
/// stop pressing the button.
|
||||
/// </summary>
|
||||
private static void Reply(string reqId, List<string> types)
|
||||
{
|
||||
var sb = BridgeJson.Begin("assets.bodies.ok");
|
||||
|
||||
sb.Str("reqId", reqId)
|
||||
.Num("extractorVersion", BridgeAssets.EXTRACTOR_VERSION)
|
||||
.Num("asked", types.Count);
|
||||
|
||||
// The envelope is shared with every other family (§3.4) even though this one never
|
||||
// pages: the website drives the chunking, so `more` is always false and `cut` always
|
||||
// "end". Writing it anyway means one reader shape on the other side rather than two.
|
||||
var page = new BridgeAssets.PageBuilder(sb, "rows", BridgeConfig.AssetBatchBytes);
|
||||
|
||||
int resolved = 0;
|
||||
|
||||
foreach (var name in types)
|
||||
{
|
||||
string status;
|
||||
int body;
|
||||
|
||||
Resolve(name, out body, out status);
|
||||
|
||||
if (status == "ok")
|
||||
resolved++;
|
||||
|
||||
var item = new StringBuilder(96);
|
||||
|
||||
item.Append("{\"type\":");
|
||||
BridgeJson.Text(item, name);
|
||||
item.Append(",\"status\":\"").Append(status).Append('"');
|
||||
|
||||
if (status == "ok")
|
||||
item.Append(",\"body\":").Append(body.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
item.Append('}');
|
||||
|
||||
// A chunk this small cannot spend the budget — the cap above is a hundred names
|
||||
// and the budget is half a megabyte — but the check costs nothing and the day
|
||||
// someone raises `AssetBodyBatch` it is the difference between a short page and a
|
||||
// line the sidecar drops.
|
||||
if (!page.TryAdd(item.ToString(), null))
|
||||
break;
|
||||
}
|
||||
|
||||
page.Close();
|
||||
|
||||
sb.Num("resolved", resolved);
|
||||
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One type name to one body id.
|
||||
///
|
||||
/// `status` is the field the website records, and the four values are four different
|
||||
/// things an operator can act on:
|
||||
///
|
||||
/// <c>ok</c> — constructed, body read.
|
||||
/// <c>unknown</c> — no such type on this shard. The spawn file names something the
|
||||
/// scripts do not define, which is a real drift an operator wants to see.
|
||||
/// <c>notCreature</c> — the type exists but is not a `BaseCreature`. Spawn files
|
||||
/// legitimately name items and static decorations; those have no body and never will,
|
||||
/// so this is a permanent answer rather than a retryable failure.
|
||||
/// <c>failed</c> — the constructor threw, or the type has none that takes no
|
||||
/// arguments. Caught per type, because one creature whose constructor depends on a
|
||||
/// script package the operator removed must not cost the other ninety-nine.
|
||||
/// </summary>
|
||||
private static void Resolve(string name, out int body, out string status)
|
||||
{
|
||||
body = 0;
|
||||
status = "failed";
|
||||
|
||||
Type type;
|
||||
|
||||
try
|
||||
{
|
||||
// `true` is ignoreCase — spawn files are hand-edited and their casing drifts from
|
||||
// the class it names far more often than the name itself does.
|
||||
type = ScriptCompiler.FindTypeByName(name, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
status = "failed";
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == null)
|
||||
{
|
||||
status = "unknown";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!typeof(BaseCreature).IsAssignableFrom(type) || type.IsAbstract)
|
||||
{
|
||||
status = "notCreature";
|
||||
return;
|
||||
}
|
||||
|
||||
BaseCreature creature = null;
|
||||
|
||||
try
|
||||
{
|
||||
creature = Activator.CreateInstance(type) as BaseCreature;
|
||||
|
||||
if (creature == null)
|
||||
{
|
||||
status = "failed";
|
||||
return;
|
||||
}
|
||||
|
||||
body = creature.Body.BodyID;
|
||||
status = body > 0 ? "ok" : "failed";
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine("[Bridge] assets.bodies: {0}: {1}: {2}",
|
||||
name, e.GetType().Name, e.Message);
|
||||
|
||||
status = "failed";
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (creature != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Deleting the mobile deletes the items it packed — `Mobile.Delete` walks
|
||||
// `Items`, and `Item.Delete` walks what each contains — and stops its AI
|
||||
// timer. A creature left alive here is a creature standing at (0,0,0) on
|
||||
// the internal map forever, saved with the world, once per import.
|
||||
creature.Delete();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nothing useful is left to do, and throwing out of `finally` would lose
|
||||
// whatever the try block was already reporting.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,7 @@ namespace Server.Custom.Bridge
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeWorld.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeOneShots.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeAssets.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeCatalog.Status());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
1010
overlay/Scripts/Custom/Bridge/BridgeCatalog.cs
Normal file
1010
overlay/Scripts/Custom/Bridge/BridgeCatalog.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user