feat(asset-bridge): the shard's own files stop needing a shared filesystem (Phase 7)

The spawn atlas was the one place the platform's rule -- only the sidecar
bridges the shard -- was broken, and it was broken by the component that faces
the internet: SPAWN_ATLAS.md required the website to read the ServUO tree off a
bind mount or a shared volume. This serves those files over the loopback link
instead (docs/link/v8.md 10).

The measurement came first and changed the shape. 10 said the shard would serve
`tree/<label>` -> bytes; against a stock 57.4 tree it cannot. Spawns/trammel.xml
is 4.03 MB, the sidecar discards any inbound line over 1 MiB, and that file as
one base64 row is 5.4 MiB -- it would be dropped, time out, and be re-requested
forever with no error anywhere. Two files on a STOCK tree are in that state.

So a file crosses as 512 KiB chunks, each gzipped: tree/Spawns/trammel.xml/c0
and so on, which is 5's depth scheme doing the same job it does for
body/400/a0/f0 and needing no protocol change to do it. The chunk is the bound
and the compression is only the saving -- nothing guarantees an operator's files
compress, so the ceiling has to hold when they do not, and a 512 KiB chunk that
refuses to compress is still ~683 KiB of base64, inside the wire cap that
AssetBatchBytes' deliberate factor of two leaves room for.

It is a `tree` FAMILY on assets.fetch rather than 14's separate tree.* commands:
phase 5 had already learned that the command is the transport and the family is
a property of the key, and assets.manifest is generalised here the same way.
That reuses the single slot, the paging envelope, the key ceiling and the
mid-import guard -- and leaves `link` with nothing to do for the third phase
running.

But it gets its OWN consent, Bridge.TreeEnabled. AssetsEnabled is an operator
agreeing the website may read their EA-licensed UO client; this is the shard's
own configuration, which they wrote, and which the public bestiary is built
from. One switch could not express both, and the thing that would silently
disappear for an operator who declined the first is their spawn atlas. So the
consent check moved into the family lookup, and assets.sources answers whenever
either plane is on, reporting `families` filtered to what is actually enabled --
which is how a tree-only shard's website discovers there is anything to ask for.

Two defects found, and which harness found which is the part worth keeping:

  - An empty `catalog` is not an absent one. `expected != null` refused every
    fetch from a caller that sent "", with a sentence naming no catalog at all.
    Found by an offline probe that passed one by accident.
  - GZipStream writes NOTHING for zero bytes of input -- the header is emitted
    lazily, so a stream opened and closed without a write yields a zero-length
    buffer rather than the 20-byte empty member. Stock ServUO ships two empty
    decoration files, so this broke every import off an untouched tree. The
    offline probe reassembled all 141 files and reported success, because .NET's
    own decompressor reads an empty stream as empty data and the chunk's
    declared length (0) and hash (of nothing) both agreed. Only the live walk,
    through a reader on another runtime, disagreed.

Measured end to end against a live shard, the real sidecar and the website's own
reader: 141 files, 11,895,427 bytes, 158 chunks, 3 pages, 1.33 MB on the wire,
512 ms; every file byte-identical to disk; the atlas built over the bridge
identical to the one built off it. A drift check is the manifest alone -- 32 KB,
~70 ms, no file bytes.

The label set is this shard's, never the caller's: a fetch resolves against the
set the shard itself enumerated, and tree/../../Scripts/..., Config/Bridge.cfg
and Saves/Accounts/accounts.xml are all answered `absent` before a path is built
out of them.

Protocol stays 8 and EXTRACTOR_VERSION stays 3 -- this family derives nothing,
it forwards an operator's own file unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-14 02:00:22 -05:00
parent 577688b993
commit 13b6fc02a4
6 changed files with 1043 additions and 77 deletions

View File

@@ -135,6 +135,10 @@ namespace Server.Custom.Bridge
BridgeBoot.RegisterHandler("assets.sources", OnSources);
BridgeBoot.RegisterHandler("assets.fetch", OnFetch);
// Owned here since phase 7, for the same reason `assets.fetch` moved here in phase 5:
// it is the transport, and more than one family has something to enumerate.
BridgeBoot.RegisterHandler("assets.manifest", OnManifest);
}
/// <summary>
@@ -221,7 +225,11 @@ namespace Server.Custom.Bridge
return;
}
if (!BridgeConfig.AssetsEnabled)
// Stage 1 answers for the whole plane, not for the client files alone: since phase 7
// an operator can serve the shard's own configuration tree while declining to serve
// their UO client, and `families` is where a website discovers which. Refused only
// when there is nothing at all to report.
if (Families().Count == 0)
{
Fail(reqId, "DISABLED", "asset extraction is disabled on this shard");
return;
@@ -294,8 +302,33 @@ namespace Server.Custom.Bridge
/// </summary>
internal delegate void FamilyFetch(string reqId, List<string> keys, string catalog, string cursor);
private static readonly Dictionary<string, FamilyFetch> _families =
new Dictionary<string, FamilyFetch>(StringComparer.Ordinal);
/// <summary>
/// One family's answer to a manifest walk — everything it can serve, no payload.
/// Runs on the asset worker, never the Core thread. A family with nothing to
/// enumerate (statics and land are addressed, not listed) registers none.
/// </summary>
internal delegate void FamilyManifest(string reqId, string cursor);
/// <summary>
/// What one §5 key family registered: how to serve it, how to list it, and — since
/// phase 7 — which operator consent it answers to.
///
/// The gate is per family rather than per plane because the planes are not one
/// consent. `body`, `static` and `land` are the operator's UO CLIENT, licensed from
/// EA and read off their disk; `tree` is the shard's OWN configuration, which they
/// wrote. An operator can reasonably want the second published and not the first, and
/// before this the atlas would have been what silently disappeared when they said so.
/// </summary>
private sealed class FamilyReader
{
public FamilyFetch Fetch;
public FamilyManifest Manifest;
public Func<bool> Enabled;
public string DisabledReason;
}
private static readonly Dictionary<string, FamilyReader> _families =
new Dictionary<string, FamilyReader>(StringComparer.Ordinal);
/// <summary>
/// Claims one §5 key family for a reader.
@@ -312,33 +345,114 @@ namespace Server.Custom.Bridge
/// handler does not read until a request arrives.
/// </summary>
internal static void RegisterFamily(string name, FamilyFetch fetch)
{
RegisterFamily(name, fetch, null, null, null);
}
/// <summary>
/// The full registration: a fetch reader, an optional manifest reader, and the
/// consent this family answers to.
///
/// <paramref name="enabled"/> null means the asset plane's own gate
/// (<c>Bridge.AssetsEnabled</c>), which is what every client-file family wants.
/// A family that reads something else entirely passes its own.
/// </summary>
internal static void RegisterFamily(string name, FamilyFetch fetch, FamilyManifest manifest,
Func<bool> enabled, string disabledReason)
{
lock (_families)
{
_families[name] = fetch;
_families[name] = new FamilyReader
{
Fetch = fetch,
Manifest = manifest,
Enabled = enabled,
DisabledReason = disabledReason
};
}
}
/// <summary>The families this shard can serve, for §6's stage 1 and for diagnostics.</summary>
/// <summary>
/// The families this shard can serve **right now**, for §6's stage 1 and for
/// diagnostics.
///
/// Filtered by consent rather than by registration, because that is the question the
/// website is actually asking: a family it can see in this list is one it can fetch.
/// Listing a family the operator has switched off would turn one clear refusal at
/// import time into a per-key refusal on every pass, forever — which is exactly the
/// failure `families` was added in phase 5 to prevent.
/// </summary>
internal static List<string> Families()
{
var names = new List<string>();
lock (_families)
{
var names = new List<string>(_families.Keys);
names.Sort(StringComparer.Ordinal);
return names;
foreach (var pair in _families)
{
if (EnabledFor(pair.Value))
names.Add(pair.Key);
}
}
names.Sort(StringComparer.Ordinal);
return names;
}
private static bool EnabledFor(FamilyReader reader)
{
if (reader == null)
return false;
try
{
return reader.Enabled == null ? BridgeConfig.AssetsEnabled : reader.Enabled();
}
catch
{
// A gate that throws is a gate that has not consented.
return false;
}
}
private static FamilyFetch FamilyFor(string name)
private static FamilyReader FamilyFor(string name)
{
lock (_families)
{
FamilyFetch fetch;
return _families.TryGetValue(name, out fetch) ? fetch : null;
FamilyReader reader;
return _families.TryGetValue(name, out reader) ? reader : null;
}
}
/// <summary>
/// Resolves a named family and answers the request itself when it cannot.
///
/// Shared by <c>assets.fetch</c> and <c>assets.manifest</c> so the two cannot drift
/// apart about what "this shard does not serve that" means — and so the consent check
/// happens in exactly one place for both.
/// </summary>
private static bool Resolve(string reqId, string family, out FamilyReader reader)
{
reader = FamilyFor(family);
if (reader == null)
{
Fail(reqId, "BAD_REQUEST",
"this shard serves no '" + family + "' asset family (it serves "
+ String.Join(", ", Families().ToArray()) + ")");
return false;
}
if (!EnabledFor(reader))
{
Fail(reqId, "DISABLED", reader.DisabledReason
?? "asset extraction is disabled on this shard");
return false;
}
return true;
}
/// <summary>
/// The family segment of a §5 key: everything before the first `/`.
/// </summary>
@@ -373,12 +487,9 @@ namespace Server.Custom.Bridge
return;
}
if (!BridgeConfig.AssetsEnabled)
{
Fail(reqId, "DISABLED", "asset extraction is disabled on this shard");
return;
}
// The consent check is NOT here any more (phase 7). It cannot be: which consent this
// request needs is a property of the keys, and the keys have not been read yet. So the
// shape checks come first and the gate happens in `Resolve`, once the family is known.
var keys = BridgeJson.GetStringList(o, "keys");
if (keys.Count == 0)
@@ -408,22 +519,70 @@ namespace Server.Custom.Bridge
return;
}
FamilyFetch fetch = FamilyFor(family);
FamilyReader reader;
if (fetch == null)
if (!Resolve(reqId, family, out reader))
return;
if (reader.Fetch == null)
{
Fail(reqId, "BAD_REQUEST",
"this shard serves no '" + family + "' asset family (it serves "
+ String.Join(", ", Families().ToArray()) + ")");
"the '" + family + "' family cannot be fetched by key on this shard");
return;
}
var catalog = BridgeJson.GetString(o, "catalog");
var cursor = BridgeJson.GetString(o, "cursor");
FamilyFetch fetch = reader.Fetch;
Accept(reqId, "assets.fetch", () => fetch(reqId, keys, catalog, cursor));
}
/// <summary>
/// §14's `assets.manifest`, for every family that has one.
///
/// Phase 3 gave this command to the body catalogue outright and phase 5 learned, for
/// `assets.fetch`, that the command is the transport and the family is a property of
/// the key. Phase 7 is where the same lesson lands one level up: the tree family
/// enumerates its files exactly the way the catalogue enumerates its bodies, and
/// nothing about the envelope, the cursor or the consent differs between them.
///
/// **`family` still defaults to `body`.** A phase-3 website asks without naming one
/// and must keep getting the catalogue it asked for.
/// </summary>
private static void OnManifest(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
if (reqId == null)
{
Fail(null, "BAD_REQUEST", "assets.manifest requires a reqId");
return;
}
var family = BridgeJson.GetString(o, "family") ?? "body";
FamilyReader reader;
if (!Resolve(reqId, family, out reader))
return;
if (reader.Manifest == null)
{
// Named rather than defaulted: statics and land are ADDRESSED (§11.1) rather than
// listed, and a website that asked for a list of 49,152 item graphics has made a
// mistake it needs told about rather than an empty page it will read as "none".
Fail(reqId, "BAD_REQUEST",
"the '" + family + "' family is fetched by key and has no manifest");
return;
}
var cursor = BridgeJson.GetString(o, "cursor");
FamilyManifest manifest = reader.Manifest;
Accept(reqId, "assets.manifest", () => manifest(reqId, cursor));
}
/// <summary>
/// ARGB1555 to a PNG with a transparent background.
///
@@ -644,7 +803,13 @@ namespace Server.Custom.Bridge
var sb = BridgeJson.Begin("assets.sources.ok");
sb.Str("reqId", reqId)
.Num("extractorVersion", EXTRACTOR_VERSION);
.Num("extractorVersion", EXTRACTOR_VERSION)
// Which of the two consents this shard has given (phase 7). Without it a website
// whose operator switched client-file extraction off would read an empty `files`
// array as "your client has no cliloc.enu" — a sentence that sends them looking at
// their client install for a setting that lives on their shard.
.Bool("assetsEnabled", BridgeConfig.AssetsEnabled)
.Bool("treeEnabled", BridgeConfig.TreeEnabled);
WriteImaging(sb);
@@ -676,7 +841,11 @@ namespace Server.Custom.Bridge
var page = new PageBuilder(sb, "files", BridgeConfig.AssetBatchBytes);
bool anyMissingHash = false;
for (int i = 0; i < SourceFiles.Length; i++)
// The client files are the asset plane's own subject, so they are listed under the
// asset plane's own consent. A tree-only shard answers this call — that is how its
// website learns the `tree` family exists — and reports no client files at all,
// which is the truthful answer to "what may I read here".
for (int i = 0; BridgeConfig.AssetsEnabled && i < SourceFiles.Length; i++)
{
string name = SourceFiles[i];
string path = ResolvePath(name);