Merge pull request 'feat(asset-bridge): the body catalogue and slug → body id (Phase 3)' (#30) from feat/asset-bridge-p3 into edge

Reviewed-on: #30
This commit is contained in:
2026-09-10 23:57:33 +00:00
6 changed files with 1375 additions and 0 deletions

View File

@@ -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

View File

@@ -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)

View 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/&lt;Name&gt;.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.
}
}
}
}
}
}

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

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(