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