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

@@ -124,6 +124,36 @@ namespace Server.Custom.Bridge
public static int AssetPlayerDirection { get; private set; }
public static int AssetCreatureDirection { get; private set; }
// ---- the tree plane (docs/link/v8.md §10, phase 7) ----
//
// Its OWN gate, and the third one on this link for the third kind of consent. The asset
// gate above is the operator agreeing that the website may read THEIR UO CLIENT -- art
// and animations and a string table that came from EA. This one is the operator agreeing
// that it may read THE SHARD'S OWN CONFIGURATION: the spawn files, the region and
// location definitions, the champion table, the decoration lists. Those are the
// operator's own work rather than a licensed client, and they are what the spawn atlas is
// built out of -- so a shard that declines to serve client art must still be able to
// publish where its creatures live. One switch could not have expressed both, and the
// atlas would have been the thing that silently disappeared.
//
// Reads only, and only the five labelled groups SPAWN_ATLAS.md already names. Nothing
// here joins a path the website sent: a request names a label this shard enumerated, or
// it is refused.
public static bool TreeEnabled { get; private set; }
// How much of a tree file one chunk carries, BEFORE compression (§10). The chunk is the
// thing that makes this transferable at all: a stock Spawns/trammel.xml is 4.03 MB and
// the sidecar discards any inbound line over 1 MiB, so the file as a single base64 row
// could never arrive -- it would time out and be re-requested forever, which is a failure
// with no error in it anywhere.
//
// Compression is what makes it cheap (a spawn file gzips ~18x, so a chunk is typically
// 40 KB on the wire) and the chunk is what makes it BOUNDED: gzip cannot be relied on to
// shrink anything, so the ceiling has to hold for input that does not compress at all.
// At 512 KiB a worst-case incompressible chunk is ~683 KiB of base64, which still fits
// the wire under AssetBatchBytes' deliberate factor of two.
public static int TreeChunkBytes { get; private set; }
// How many bytes of rendered item and land art the shard holds between requests (§11,
// phase 5). This is a convenience, not a store: the website keeps every picture it fetches
// and does not ask twice, so what this actually buys is the second page of a batch, a
@@ -239,6 +269,19 @@ namespace Server.Custom.Bridge
if (AssetArtCacheBytes > 64 * 1024 * 1024)
AssetArtCacheBytes = 64 * 1024 * 1024;
TreeEnabled = Config.Get("Bridge.TreeEnabled", true);
// Floor and ceiling both matter. Below 64 KiB a stock tree is thousands of chunks and
// the per-row overhead starts to dominate the payload; above 512 KiB an incompressible
// chunk stops fitting inside the sidecar's inbound line cap, which is the one bound
// this number exists to respect. Kept equal to AssetBatchBytes' own ceiling so the two
// budgets cannot drift into disagreeing about the same wire.
TreeChunkBytes = Config.Get("Bridge.TreeChunkBytes", 512 * 1024);
if (TreeChunkBytes < 64 * 1024)
TreeChunkBytes = 64 * 1024;
if (TreeChunkBytes > 512 * 1024)
TreeChunkBytes = 512 * 1024;
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);