7 Commits

Author SHA1 Message Date
9ecc469a5b Merge pull request 'docs(readme): the nine files the Asset Bridge added (Phase 9a)' (#35) from docs/asset-bridge-p9 into edge
Reviewed-on: #35
2026-09-14 22:25:17 +00:00
5050425b0b docs(readme): the nine files the Asset Bridge added (Phase 9a)
The file table in this README stopped at Phase 6's town crier -- ten rows for a
directory that now holds 38 files, stale across four workstreams. Filling all of
it is not this phase's job; documenting the nine files this workstream added is,
and the table now says plainly what it covers so a reader does not take it for an
inventory.

The rows carry the reasoning worth having at a glance: the validator is the
boundary that turned 22,102 confident wrong pictures into honest absences, the
catalogue's action ceiling is what stops the fallback walk serving the next
body's art, `BridgeBodies` is the one question no code outside ServUO can answer,
and `BridgeUop`/`BridgePng` are written without `System.Drawing` on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 13:14:26 -05:00
2539764cf7 Merge pull request 'feat(asset-bridge): the shard's own files stop needing a shared filesystem (Phase 7)' (#34) from feat/asset-bridge-p7 into edge
Reviewed-on: #34
2026-09-14 07:36:54 +00:00
936a922487 fix(asset-bridge): an empty catalog is an absent one on every family, not just the tree
Phase 7 found this on the tree family and fixed it there. It was inline in THREE
places: the body catalogue (phase 3), statics and land (phase 5), and the tree.
`expected != null` treats "" as a real fingerprint, so a caller that serialises a
missing value as an empty string has EVERY fetch refused -- with a sentence that
names no catalog at all ("catalog  is now 8159778b"), which reads as a shard
fault rather than a caller one.

All three now go through one BridgeAssets.CatalogMismatch. Three copies of a
comparison are three chances for the next family to get it wrong in a way only a
differently-written client would ever reveal.

BridgeLeases keeps its own `expected != null` and is deliberately untouched:
there the value is a world property, where an empty string is a legitimate thing
to expect.

Verified against a live shard on a stock ServUO install, every family asked three
ways -- with a real catalog, with the field absent, and with an empty string:

  cliloc.table walk                     67,496 rows, 12 pages
  body manifest / fetch                 1,095 rows; ok all three ways
  static + land fetch                   ok all three ways
  static/land carry their OWN catalog   art 66a112c1 vs body 323f284f
  a cross-family catalog                refused 422
  tree manifest / fetch                 141 files incl. BOTH empty ones, all three ways
  empty files carry a VALID gzip member 2 rows gunzip to 0 bytes
  a STALE catalog                       still refused on body, static and tree

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 02:33:15 -05:00
13b6fc02a4 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
2026-09-14 02:00:22 -05:00
577688b993 Merge pull request 'feat(asset-bridge): the 73 bodies action 0 could not see, and the ceiling that makes looking safe (Phase 6)' (#33) from feat/asset-bridge-p6 into edge
Reviewed-on: #33
2026-09-14 06:15:52 +00:00
a9bd18e48e feat(asset-bridge): the 73 bodies action 0 could not see, and the ceiling that makes looking safe (Phase 6)
The catalogue asked every body for action 0 and reported the rest absent. 73 of
this client's bodies have no art there and real art deeper — body 820's first
drawn action is 23, and it is a horse — so they rendered as text on the bestiary.
The catalogue now falls back to the first action that has art, and the key names
that action (`body/820/a23`). 1,022 -> 1,095 rows.

Walking the action axis is the one thing that can walk off the end of a body's
slots, and the slots after a body's band are the NEXT BODY'S. Measured here: one
action past the band, 643 of 795 legacy bodies return a fully validated picture
and 452 of those are byte-identical to body+1's action 0 (body 1 action 22 is an
ettin; body 3's is an imp, both confirmed by rendering them). Phase 0's validator
cannot catch that — the record is real — so the ceiling refuses the ADDRESS, in
ResolveAnimation where every caller already goes.

The ceiling is the index banding, never `Animations.GetAnimLength`: for a body
reaching file type 5 as id 34 that function answers 22 while the arithmetic gives
13, and the difference is nine actions of another creature's art.

A fetch serves only the key the catalogue chose for that body. `body/820/a0` and
`body/400/a2` come back `unsupported` with the chosen action alongside, never by
decoding what was asked for.

`EXTRACTOR_VERSION` 2 -> 3 (unchanged input, a different answer). Protocol stays
8 — `action` on a manifest/fetch row is additive.

Deep frame keys and the bulk-fill switch that §16 planned for this phase were
NOT built: the site displays still pictures, and a complete one-direction
animation set measures 174,453 frames / 281.5 MB against no consumer (docs
§11.2, org lead 2026-09-11).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 01:09:38 -05:00
9 changed files with 1369 additions and 124 deletions

View File

@@ -178,6 +178,26 @@ Runtime script compilation therefore had no effect, silently. `overlay/Scripts/S
| `BridgeAccountLink.cs` | `[link` account linking (Phase 5): one-time code, `link.confirm`, `WebsiteUserId` account tag. |
| `BridgeTownCrier.cs` | Town-crier news (Phase 6): inbound `towncrier.add` / `remove` into the global crier list, with abuse caps. |
The Asset Bridge's own files (protocol 8, `docs/link/v8.md`) — the shard reading the operator's UO
client and its own ServUO tree, and the only part of this plugin that touches files rather than the
world:
| File | Responsibility |
|------|----------------|
| `BridgeAssets.cs` | The plane's front door: the single-slot gate that answers `bridge.busy`, the 512 KiB batch budget, the paging envelope, the family registry, and the background hashing pass that fingerprints the client files without holding the slot. |
| `BridgeAssetValidator.cs` | Judges an index entry (and, for statics, the record behind it) **before** handing an id to `Ultima`. The boundary that turns 22,102 confident wrong pictures on a stock client into honest absences (§4.5). |
| `BridgeCatalog.cs` | The body catalogue: which bodies have art, at which action, and the per-body action ceiling that stops the fallback walk serving the next body's picture (§4.10). |
| `BridgeArt.cs` | Item statics and land tiles on demand, hued on the shard from `tiledata.mul`, behind a byte-bounded cache. |
| `BridgeUop.cs` | The narrow UOP animation reader, written without `System.Drawing` — the one decoder here that is not ServUO's (§4.3). |
| `BridgePng.cs` | Our own PNG encoder, for the same reason. |
| `BridgeBodies.cs` | Slug → body id, on the **Core thread**: construct the type, read `Body.BodyID`, delete it. The one question no code outside ServUO can answer (§8). |
| `BridgeCliloc.cs` | The Mythic cliloc decompressor, ported from UOFiddler (Beerware) — ServUO's own `Ultima.StringList` cannot read a modern client's compressed table (§9). |
| `BridgeTree.cs` | The shard's own `Spawns/*.xml` and friends as a `tree` key family, in gzipped 512 KiB chunks, behind its own consent `Bridge.TreeEnabled` (§10). |
**The table above is the transport plus the Asset Bridge, not all 38 files** in that directory —
the streams added by protocols 3 through 7 (visibility, leases, participation, the event plane's
world verbs) are documented in their own design docs rather than here.
`Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on.
## Testing

View File

@@ -340,6 +340,30 @@ AssetScanMs=3000
AssetPlayerDirection=0
AssetCreatureDirection=1
# The tree plane (docs/link/v8.md §10, phase 7). A THIRD switch, for a third consent:
# the asset switch above is about this host's UO client, which came from EA. This one is
# about the shard's own configuration -- Spawns/*.xml, Data/Regions.xml,
# Data/Locations/*.xml, Config/ChampionSpawns.xml and Data/Decoration/**.cfg -- which is
# the operator's own work and is what the website's spawn atlas is built from. Before
# protocol 8 the website read those files off a shared filesystem; that was the one place
# the platform's own rule (only the sidecar bridges the shard) was broken, and broken by
# the component that faces the internet. Turning this off closes the bridge route and
# leaves that shared-filesystem path as the only way an atlas can be built.
#
# Reads only, and only those five groups. Nothing here joins a path the website sent: a
# request names a label this shard itself enumerated, or it is refused.
TreeEnabled=true
# How much of a tree file one chunk carries, BEFORE compression. Chunking is not an
# optimisation here, it is what makes a spawn file transferable: a stock trammel.xml is
# 4.03 MB, the sidecar discards any inbound line over 1 MiB, and the whole file as one
# base64 row would time out and be re-requested forever with no error anywhere. Each
# chunk is gzipped (a spawn file compresses ~18x, so a chunk is typically 40 KB on the
# wire), but the BOUND comes from the chunk rather than the compression, because nothing
# guarantees input compresses at all. Clamped to [64 KiB, 512 KiB]: at the ceiling a
# worst-case incompressible chunk is ~683 KiB of base64, which still fits the wire.
TreeChunkBytes=524288
# 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

@@ -175,7 +175,7 @@ namespace Server.Custom.Bridge
string id = SourceId();
if (expected != null && expected != id)
if (BridgeAssets.CatalogMismatch(expected, id))
{
BridgeAssets.Fail(reqId, "UNREADABLE",
"the shard's client files changed since that catalogue was read (catalog "

View File

@@ -406,11 +406,123 @@ namespace Server.Custom.Bridge
return false;
}
int actions = ActionsOf(translated, fileType);
if (action >= actions)
{
// §4.10, measured in phase 6: this is the never-sweep rule again, one axis over.
// A body's slots are contiguous and the next body's begin immediately after them,
// so `index + action * 5` past the ceiling addresses ANOTHER BODY'S action — a
// real record, at a real offset, that every check below passes. Measured on this
// client: of 795 legacy bodies, 643 return a fully validated picture one action
// past their band and **452 of those are byte-identical to body+1's action 0**.
// Body 1 action 22 is an ettin; body 3 action 22 is an imp. Nothing downstream
// can tell, which is why the refusal has to be here.
reason = "body " + body + " has " + actions + " actions in file type " + fileType
+ "; action " + action + " belongs to the next body";
return false;
}
index = AnimIndexOf(translated, fileType) + (action * 5) + direction;
return true;
}
/// <summary>
/// How many actions the index reserves for a body — the only safe ceiling, and it is
/// the banding rather than the library's own answer.
///
/// <c>Animations.GetAnimLength</c> exists and looks like the right source. It is not:
/// for a body reaching file type 5 as id 34 it answers **22** while
/// <see cref="AnimIndexOf"/> puts that body in the 65-slot band, which is **13**. The
/// two disagree on exactly one body of this client (reached by translation from body
/// 276), and taking the larger number is nine actions of somebody else's art. So the
/// count is derived from the same arithmetic that produces the offset, in the same
/// file, where the two cannot drift apart.
/// </summary>
public static bool ActionCount(int body, out int actions, out int fileType, out string reason)
{
reason = null;
actions = 0;
fileType = 0;
if (body <= 0)
{
reason = "body " + body + " is not addressable";
return false;
}
int translated = body;
int hue = 0;
try
{
Animations.Translate(ref translated, ref hue);
fileType = BodyConverter.Convert(ref translated);
}
catch (Exception e)
{
reason = "body.def/bodyconv.def lookup failed: " + e.GetType().Name;
return false;
}
if (AnimDataPath(fileType) == null)
{
reason = "bodyconv sends body " + body + " to file type " + fileType
+ ", which this client does not have";
return false;
}
actions = ActionsOf(translated, fileType);
return true;
}
/// <summary>
/// The banding of <see cref="AnimIndexOf"/>, read as an action count: a body's slots
/// are five directions per action, so the band size divided by five is how many
/// actions it owns.
/// </summary>
private static int ActionsOf(int body, int fileType)
{
return SlotsOf(body, fileType) / 5;
}
/// <summary>
/// How many index slots <see cref="AnimIndexOf"/>'s arithmetic gives this body. The
/// bands are transcribed there and their sizes here, from the same source and in the
/// same order, because a ceiling that disagrees with an offset is worse than no
/// ceiling at all.
/// </summary>
private static int SlotsOf(int body, int fileType)
{
switch (fileType)
{
case 2:
return body < 200 ? 110 : 65;
case 3:
if (body < 300)
return 65;
return body < 400 ? 110 : 175;
case 5:
// Body 34's exclusion again — it is in the second band here, so it owns 13
// actions and not 22. This is the one body `GetAnimLength` is wrong about.
if (body < 200 && body != 34)
return 110;
return body < 400 ? 65 : 175;
default: // 1 and 4 share their banding
if (body < 200)
return 110;
return body < 400 ? 65 : 175;
}
}
/// <summary>
/// <c>Animations.GetFileIndex</c>'s own arithmetic, which is private. The banding is
/// per file type and the boundaries differ between them, so this is transcribed rather

View File

@@ -85,8 +85,13 @@ namespace Server.Custom.Bridge
/// stock client is 235 new sprites and two of them player-character bodies; and the
/// player-body set no longer carries ghost ids. Every client file is byte-identical
/// and the answer is different, which is precisely what this number exists to say.
///
/// **3** — phase 6 (§4.10, §11.2). A body with no art at action 0 is catalogued at
/// the first action that has any, and its key names that action. 74 more bodies on a
/// stock client, no existing key's bytes changed — but a body that was absent is now
/// a row, which is the same "unchanged input, different answer" this number covers.
/// </summary>
public const int EXTRACTOR_VERSION = 2;
public const int EXTRACTOR_VERSION = 3;
// ── the one slot (§3.2) ──────────────────────────────────────────────────────────────
@@ -130,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>
@@ -216,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;
@@ -289,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.
@@ -307,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);
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>
@@ -368,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)
@@ -403,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.
///
@@ -506,6 +670,30 @@ namespace Server.Custom.Bridge
}
}
/// <summary>
/// Does a caller's asserted catalog id disagree with what this shard holds?
///
/// **An absent fingerprint and an empty one mean the same thing**, and that is the
/// whole reason this is a function rather than an inline `expected != null`. A caller
/// with nothing to assert sends the field absent or empty depending on how its own
/// client serialises a missing value, and treating `""` as a real id refuses **every**
/// fetch it makes — with a sentence naming no catalog at all ("catalog is now
/// 8159778b"), which reads as a shard fault rather than a caller one.
///
/// Phase 7 found this on the tree family, where a probe passed an empty string by
/// accident. It was inline in three places by then; it is one function now, because
/// three copies of a comparison are three chances for the next family to get it wrong
/// in a way only a differently-written client would ever reveal.
///
/// Note this is deliberately NOT the shape `BridgeLeases` uses for its own `expected`:
/// there the value is a world property, where an empty string is a legitimate thing to
/// expect and `!= null` is correct.
/// </summary>
internal static bool CatalogMismatch(string expected, string actual)
{
return !String.IsNullOrEmpty(expected) && !String.Equals(expected, actual, StringComparison.Ordinal);
}
/// <summary>
/// SHA-256, lowercase hex. Shared because the hash in a manifest row, the hash in a
/// fetch row and the hash the website stores must be one function.
@@ -639,7 +827,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);
@@ -671,7 +865,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);

View File

@@ -263,6 +263,7 @@ namespace Server.Custom.Bridge
e.Mobile.SendMessage("Bridge: {0}", BridgeAssets.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeCatalog.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeArt.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeTree.Status());
break;
}
}

View File

@@ -14,11 +14,20 @@ namespace Server.Custom.Bridge
///
/// One thumbnail per creature body: the working set that makes a bestiary, a marketplace
/// listing and a character sheet render. Everything deeper — every action, every frame —
/// is the same addressing scheme at a deeper key, fetched on demand in a later phase; this
/// is the set that is worth importing before anything asks for it, because on this
/// machine's client it is **1,022 sprites at about a kilobyte each** — 787 out of the
/// legacy `anim*.mul` files and, since phase 4, 235 more out of `AnimationFrame*.uop`,
/// which ServUO's vendored decoder never opens (§4.3, §4.9).
/// is the same addressing scheme at a deeper key and is **not served** (§11.2, phase 6:
/// the site shows still pictures, so frames wait for a consumer that wants them). This is
/// the set that is worth importing before anything asks for it, because on this machine's
/// client it is **1,096 sprites at about a kilobyte each** — 787 out of the legacy
/// `anim*.mul` files, 235 more out of `AnimationFrame*.uop` since phase 4 (§4.3, §4.9),
/// and 74 more since phase 6, which have no art at action 0 and real art deeper.
///
/// ── **One picture per body, at the first action that has one** ──
///
/// A key carries the action it came from — `body/820/a23` for a horse whose action 0 is
/// empty — so the catalogue is still exactly one row per body, and the row says which
/// picture it is. What it never does is decode an action the walk did not choose: a fetch
/// for `body/820/a0` is `unsupported`, not a second attempt, because the slots past a
/// body's band belong to the next body and every check passes on them (§4.10).
///
/// Two request kinds, which are §6's two stages for assets rather than for sources:
///
@@ -89,19 +98,34 @@ namespace Server.Custom.Bridge
/// <summary>Bodies are addressable to 2047; the sweep behind §4.8 covered exactly this.</summary>
private const int MaxBody = 2047;
/// <summary>The catalogue is first frames only. Deep keys are phase 6.</summary>
private const int CatalogAction = 0;
/// <summary>
/// The action a thumbnail comes from when the body has one, which is nearly always.
/// Everything deeper than a first frame is deferred — see §11.2.
/// </summary>
private const int PreferredAction = 0;
/// <summary>
/// How far the fallback looks for a body with no art at <see cref="PreferredAction"/>.
///
/// 35 because that is the largest band any file type gives a body, so an action beyond
/// it is not something the client's own layout can name. The legacy arm is bounded
/// tighter still and per body, by <see cref="BridgeAssetValidator.ActionCount"/> —
/// this is only the scan's outer stop, and it is the UOP arm's real one, where an
/// action is a named entry rather than an offset.
/// </summary>
private const int MaxAction = 35;
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
BridgeBoot.RegisterHandler("assets.manifest", OnManifest);
// `assets.fetch` is shared plumbing as of phase 5 (§5): BridgeAssets owns the command,
// decides which family a batch of keys belongs to, and calls the reader that owns it.
BridgeAssets.RegisterFamily(Family, ReplyFetch);
// Both commands are shared plumbing: `assets.fetch` since phase 5 and
// `assets.manifest` since phase 7 (§5, §10). BridgeAssets owns the correlation id, the
// operator's consent, the key ceiling and the family decision; what is registered here
// is only this family's two readers, and each is called on the asset worker with work
// it owns.
BridgeAssets.RegisterFamily(Family, ReplyFetch, ReplyManifest, null, null);
}
// ── the cache ────────────────────────────────────────────────────────────────────────
@@ -110,6 +134,14 @@ namespace Server.Custom.Bridge
{
public string Key;
public int Body;
/// <summary>
/// Which action this body's thumbnail came from — <see cref="PreferredAction"/>
/// for all but 74 bodies on this client, and on the wire because the key names it
/// (§5, §11.2). A consumer that assumes `a0` would build a dead URL for a horse.
/// </summary>
public int Action;
public int Direction;
public int FileType;
public string Sha256;
@@ -130,8 +162,15 @@ namespace Server.Custom.Bridge
private sealed class Catalog
{
public string Id;
public readonly Dictionary<string, Sprite> ByKey =
new Dictionary<string, Sprite>(StringComparer.Ordinal);
/// <summary>
/// Keyed by **body**, not by asset key, since phase 6: a body's key now carries
/// the action its picture came from, so the key cannot be spelled until the body
/// has been resolved. A fetch arrives holding a key and has to reach the same
/// sprite, which it does by parsing the body out of it and comparing.
/// </summary>
public readonly Dictionary<int, Sprite> ByBody = new Dictionary<int, Sprite>();
public readonly List<Sprite> Order = new List<Sprite>();
/// <summary>The next body the scan has yet to look at.</summary>
@@ -148,32 +187,6 @@ namespace Server.Custom.Bridge
// ── assets.manifest ──────────────────────────────────────────────────────────────────
private static void OnManifest(Dictionary<string, object> o)
{
string reqId;
if (!Admit(o, "assets.manifest", out reqId))
return;
var family = BridgeJson.GetString(o, "family") ?? Family;
if (!String.Equals(family, Family, StringComparison.Ordinal))
{
// Named rather than ignored: `family` exists so §5's statics and land can join
// this envelope in phase 5 without a second request kind, and a website that
// asked for one of those against a phase-3 overlay must be told it asked too
// early rather than handed a body catalogue it did not request.
BridgeAssets.Fail(reqId, "BAD_REQUEST",
"this shard serves the '" + Family + "' asset family only (asked for '"
+ family + "')");
return;
}
var cursor = BridgeJson.GetString(o, "cursor");
BridgeAssets.Accept(reqId, "assets.manifest", () => ReplyManifest(reqId, cursor));
}
/// <summary>
/// Worker thread. Scans forward from the cursor until the byte budget or the time
/// budget is spent, hashing what it decodes and keeping the bytes for the fetch.
@@ -264,6 +277,7 @@ namespace Server.Custom.Bridge
item.Append(",\"width\":").Append(sprite.Width.ToString(CultureInfo.InvariantCulture));
item.Append(",\"height\":").Append(sprite.Height.ToString(CultureInfo.InvariantCulture));
item.Append(",\"body\":").Append(sprite.Body.ToString(CultureInfo.InvariantCulture));
item.Append(",\"action\":").Append(sprite.Action.ToString(CultureInfo.InvariantCulture));
item.Append(",\"direction\":").Append(sprite.Direction.ToString(CultureInfo.InvariantCulture));
item.Append(",\"source\":\"").Append(sprite.Source).Append('"');
item.Append('}');
@@ -343,7 +357,7 @@ namespace Server.Custom.Bridge
string id = SourceId();
if (expected != null && expected != id)
if (BridgeAssets.CatalogMismatch(expected, id))
{
// The client files moved between the manifest and this fetch. Refusing is the only
// honest answer: the keys were chosen against a catalogue that no longer describes
@@ -413,9 +427,9 @@ namespace Server.Custom.Bridge
/// </summary>
private static string Render(Catalog catalog, Readers readers, string key)
{
int body;
int body, action;
if (!TryParseKey(key, out body))
if (!TryParseKey(key, out body, out action))
{
var bad = new StringBuilder(96);
bad.Append("{\"key\":");
@@ -437,12 +451,26 @@ namespace Server.Custom.Bridge
return item.ToString();
}
if (sprite.Action != action)
{
// The body has a picture, but not at the action this key names. Two ways to get
// here and both are the caller's: an old manifest that catalogued this body at
// `a0` before the client was patched, or a key someone built by assuming the
// action. Neither is served — decoding the asked-for action instead would be
// §4.10's wrong picture, arrived at politely.
item.Append(",\"status\":\"unsupported\"");
item.Append(",\"action\":").Append(sprite.Action.ToString(CultureInfo.InvariantCulture));
item.Append('}');
return item.ToString();
}
item.Append(",\"status\":\"ok\"");
item.Append(",\"sha256\":\"").Append(sprite.Sha256).Append('"');
item.Append(",\"bytes\":").Append(sprite.Png.Length.ToString(CultureInfo.InvariantCulture));
item.Append(",\"width\":").Append(sprite.Width.ToString(CultureInfo.InvariantCulture));
item.Append(",\"height\":").Append(sprite.Height.ToString(CultureInfo.InvariantCulture));
item.Append(",\"body\":").Append(sprite.Body.ToString(CultureInfo.InvariantCulture));
item.Append(",\"action\":").Append(sprite.Action.ToString(CultureInfo.InvariantCulture));
item.Append(",\"direction\":").Append(sprite.Direction.ToString(CultureInfo.InvariantCulture));
item.Append(",\"source\":\"").Append(sprite.Source).Append('"');
item.Append(",\"png\":\"").Append(Convert.ToBase64String(sprite.Png)).Append("\"}");
@@ -459,13 +487,11 @@ namespace Server.Custom.Bridge
/// </summary>
private static Sprite Resolve(Catalog catalog, Readers readers, int body)
{
string key = Key(body);
lock (_sync)
{
Sprite cached;
if (catalog.ByKey.TryGetValue(key, out cached))
if (catalog.ByBody.TryGetValue(body, out cached))
return cached;
}
@@ -473,38 +499,88 @@ namespace Server.Custom.Bridge
? BridgeConfig.AssetPlayerDirection
: BridgeConfig.AssetCreatureDirection;
// Legacy first, always. The vendored decoder is what 787 of this client's bodies come
// out of, it is what phase 3 measured, and the UOP packages hold a different and
// mostly disjoint set (measured: of the 244 bodies they carry, 8 also have legacy
// art). So this is a fallback rather than a choice, and no body changes reader while
// a client sits still.
Sprite sprite = ResolveLegacy(key, readers, body, direction)
?? ResolveUop(key, readers, body, direction);
Sprite sprite = ResolveAny(readers, body, direction);
if (sprite == null)
return null;
lock (_sync)
{
if (!catalog.ByKey.ContainsKey(key))
if (!catalog.ByBody.ContainsKey(body))
{
catalog.ByKey[key] = sprite;
catalog.ByBody[body] = sprite;
catalog.Order.Add(sprite);
}
return catalog.ByKey[key];
return catalog.ByBody[body];
}
}
/// <summary>
/// One body's thumbnail: action 0 if it has one, otherwise the first action that does.
///
/// ── **Why there is a fallback at all** ──
///
/// Through phase 5 a body with no art at action 0 was simply absent, and on this
/// client **74 bodies are in exactly that state while carrying real art deeper** — 66
/// of them UOP, 8 legacy. Body 820's first drawn action is 23, and it is a horse.
/// They rendered as text on the bestiary for want of looking one action further.
///
/// ── **Why the key says which action it is** ──
///
/// The fallback's picture is `body/820/a23`, not `body/820/a0`. Naming it `a0` would
/// have been fewer changes downstream and a key that lies about its content, which is
/// the failure this protocol keeps meeting from other directions (§4.5, §4.8, §11.1).
///
/// ── **Why the ceiling is not a detail** ──
///
/// Scanning actions is the one thing that can walk off the end of a body's slots, and
/// the slots immediately after a body's are the **next body's**. Measured in phase 6:
/// 643 of 795 legacy bodies return a fully validated, correctly-sized picture one
/// action past their band, and 452 of those are byte-identical to body+1's action 0.
/// <see cref="BridgeAssetValidator.ResolveAnimation"/> refuses past the ceiling, so
/// this walk cannot produce one — see §4.10.
/// </summary>
private static Sprite ResolveAny(Readers readers, int body, int direction)
{
int actions, fileType;
string reason;
// The legacy ceiling. A body the legacy path cannot place at all still gets the UOP
// arm below, where an action is a named entry rather than an offset into a band.
if (!BridgeAssetValidator.ActionCount(body, out actions, out fileType, out reason))
actions = 0;
for (int action = PreferredAction; action < MaxAction; action++)
{
// Legacy first, always. The vendored decoder is what 787 of this client's bodies
// come out of, it is what phase 3 measured, and the UOP packages hold a different
// and mostly disjoint set (measured: of the 244 bodies they carry, 8 also have
// legacy art). So this is a fallback rather than a choice, and no body changes
// reader while a client sits still.
Sprite sprite = action < actions
? ResolveLegacy(Key(body, action), readers, body, action, direction)
: null;
if (sprite == null)
sprite = ResolveUop(Key(body, action), readers, body, action, direction);
if (sprite != null)
return sprite;
}
return null;
}
/// <summary>
/// ServUO's vendored <c>Animations</c> over <c>anim*.mul</c>, behind §4.5's validator.
/// </summary>
private static Sprite ResolveLegacy(string key, Readers readers, int body, int direction)
private static Sprite ResolveLegacy(string key, Readers readers, int body, int action, int direction)
{
int fileType, at;
string reason;
if (!BridgeAssetValidator.ResolveAnimation(body, CatalogAction, direction,
if (!BridgeAssetValidator.ResolveAnimation(body, action, direction,
out fileType, out at, out reason))
return null;
@@ -534,12 +610,12 @@ namespace Server.Custom.Bridge
try
{
return Decode(key, body, direction, fileType);
return Decode(key, body, action, direction, fileType);
}
catch (Exception e)
{
Console.WriteLine("[Bridge] catalogue: body {0}: {1}: {2}",
body, e.GetType().Name, e.Message);
Console.WriteLine("[Bridge] catalogue: body {0} action {1}: {2}: {3}",
body, action, e.GetType().Name, e.Message);
return null;
}
}
@@ -558,9 +634,9 @@ namespace Server.Custom.Bridge
/// hash of a name carrying the body id, and the payload repeats that id in its own
/// header for <see cref="BridgeUop.Group.TryOpen"/> to check. A miss is a miss.
/// </summary>
private static Sprite ResolveUop(string key, Readers readers, int body, int direction)
private static Sprite ResolveUop(string key, Readers readers, int body, int action, int direction)
{
ulong hash = BridgeUop.HashOf(body, CatalogAction);
ulong hash = BridgeUop.HashOf(body, action);
byte[] payload = null;
string reason = null;
@@ -574,8 +650,8 @@ namespace Server.Custom.Bridge
if (!package.TryRead(hash, out payload, out reason))
{
Console.WriteLine("[Bridge] catalogue: body {0} in {1}: {2}",
body, BridgeUop.PackageName(n), reason);
Console.WriteLine("[Bridge] catalogue: body {0} action {1} in {2}: {3}",
body, action, BridgeUop.PackageName(n), reason);
return null;
}
@@ -589,7 +665,8 @@ namespace Server.Custom.Bridge
if (!BridgeUop.Group.TryOpen(payload, body, out group, out reason))
{
Console.WriteLine("[Bridge] catalogue: body {0} uop: {1}", body, reason);
Console.WriteLine("[Bridge] catalogue: body {0} action {1} uop: {2}",
body, action, reason);
return null;
}
@@ -607,7 +684,8 @@ namespace Server.Custom.Bridge
// exactly the same condition — so it is absent, silently. Anything else is a
// record this reader refused, and that is worth a line.
if (!empty)
Console.WriteLine("[Bridge] catalogue: body {0} uop: {1}", body, reason);
Console.WriteLine("[Bridge] catalogue: body {0} action {1} uop: {2}",
body, action, reason);
return null;
}
@@ -621,6 +699,7 @@ namespace Server.Custom.Bridge
{
Key = key,
Body = body,
Action = action,
Direction = direction,
FileType = 0,
Png = png,
@@ -631,14 +710,14 @@ namespace Server.Custom.Bridge
};
}
private static Sprite Decode(string key, int body, int direction, int fileType)
private static Sprite Decode(string key, int body, int action, int direction, int fileType)
{
int hue = 0;
// `preserveHue: false` — the catalogue is the creature's own art, and a body-level hue
// from Body.def belongs to a specific mob rather than to the species. §5's key scheme
// is where a hued variant is expressed (`static/3922/h33`), not here.
Frame[] frames = Animations.GetAnimation(body, CatalogAction, direction, ref hue, false, true);
Frame[] frames = Animations.GetAnimation(body, action, direction, ref hue, false, true);
if (frames == null || frames.Length == 0 || frames[0] == null)
return null;
@@ -657,6 +736,7 @@ namespace Server.Custom.Bridge
{
Key = key,
Body = body,
Action = action,
Direction = direction,
FileType = fileType,
Png = png,
@@ -758,20 +838,28 @@ namespace Server.Custom.Bridge
// ── keys, cursors and the source id ──────────────────────────────────────────────────
private static string Key(int body)
private static string Key(int body, int action)
{
return "body/" + body.ToString(CultureInfo.InvariantCulture)
+ "/a" + CatalogAction.ToString(CultureInfo.InvariantCulture);
+ "/a" + action.ToString(CultureInfo.InvariantCulture);
}
/// <summary>
/// `body/&lt;id&gt;/a0`, and nothing else in this phase. A deeper key
/// (`body/400/a2/f3`) is well-formed under §5 and simply not served yet, so it comes
/// back `unsupported` rather than being silently read as its own first frame.
/// `body/&lt;id&gt;/a&lt;n&gt;`, and nothing else in this phase. A deeper key
/// (`body/400/a2/f3`) is well-formed under §5 and simply not served, so it comes back
/// `unsupported` rather than being silently read as its own first frame.
///
/// The action is parsed rather than required to be zero — 74 of this client's bodies
/// are catalogued at a different one (§11.2) — but a parsed action is not an accepted
/// one. <see cref="Render"/> serves a key only when it is the key the catalogue itself
/// chose for that body, which is what keeps §4.10's ceiling from being reachable
/// through a request: nothing the website can ask makes this decode an action the
/// catalogue did not already pick.
/// </summary>
private static bool TryParseKey(string key, out int body)
private static bool TryParseKey(string key, out int body, out int action)
{
body = 0;
action = -1;
if (key == null)
return false;
@@ -787,7 +875,14 @@ namespace Server.Custom.Bridge
if (body < 1 || body > MaxBody)
return false;
return parts[2] == "a" + CatalogAction.ToString(CultureInfo.InvariantCulture);
if (parts[2].Length < 2 || parts[2][0] != 'a')
return false;
if (!Int32.TryParse(parts[2].Substring(1), NumberStyles.None,
CultureInfo.InvariantCulture, out action))
return false;
return action >= 0 && action < MaxAction;
}
private static int ParseBodyCursor(string cursor)
@@ -876,29 +971,6 @@ namespace Server.Custom.Bridge
// ── shared plumbing ──────────────────────────────────────────────────────────────────
/// <summary>
/// The two gates every request on this plane passes: a correlation id, and the
/// operator's consent. Both refuse rather than answer.
/// </summary>
private static bool Admit(Dictionary<string, object> o, string kind, out string reqId)
{
reqId = BridgeJson.GetString(o, "reqId");
if (reqId == null)
{
BridgeAssets.Fail(null, "BAD_REQUEST", kind + " requires a reqId");
return false;
}
if (!BridgeConfig.AssetsEnabled)
{
BridgeAssets.Fail(reqId, "DISABLED", "asset extraction is disabled on this shard");
return false;
}
return true;
}
/// <summary>
/// The five anim files' index and record readers — and, since phase 4, the five UOP
/// packages beside them — opened for one reply and closed with it. Holding them across

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

View File

@@ -0,0 +1,775 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Text;
namespace Server.Custom.Bridge
{
/// <summary>
/// **The shard's own configuration, over the bridge** (docs/link/v8.md §10 — protocol 8,
/// phase 7).
///
/// Everything else on the asset plane reads the operator's UO CLIENT. This family reads
/// the shard's own files: the spawn tables, the region and location definitions, the
/// champion list and the decoration lists. The website parses those into its spawn atlas —
/// where every creature lives, which regions exist, what this shard calls scenery — and
/// until protocol 8 it did so by **reading the ServUO tree off a shared filesystem**:
/// same host, a bind mount, or a shared volume.
///
/// That was the one place the platform's own rule was broken, and broken by the component
/// that faces the internet. This closes it. The parsers do not move — `spawnAtlasParse.js`
/// is pure, fs-free and covered by CI without a ServUO tree anywhere near it, and every
/// quirk it handles stays exactly where it is. The shard sends bytes; the website still
/// decides what they mean.
///
/// ── What phase 7 measured, and the shape it forced ────────────────────────────────
///
/// §10 said "the shard serves `tree/&lt;label&gt;` → bytes". Measured against a stock 57.4
/// tree, it cannot: `Spawns/trammel.xml` is **4.03 MB**, the sidecar discards any inbound
/// line over **1 MiB** (`shard.rs` `MAX_INBOUND_LINE_BYTES`), and that file as a single
/// base64 row is 5.4 MiB. It would never arrive — the reply would be discarded, the
/// request would time out, and the import would retry forever with no error anywhere in
/// it. Two files on a *stock* tree are in that state; a shard with hand-built spawn tables
/// has more.
///
/// So a file crosses as **chunks, each gzipped**:
///
/// <code>
/// tree/Spawns/trammel.xml the manifest row — size, hash, chunk count
/// tree/Spawns/trammel.xml/c0 the first 512 KiB of it, gzipped
/// tree/Spawns/trammel.xml/c1 the next
/// </code>
///
/// which is §5's depth scheme at work a second time, exactly as `body/400/a0/f0` is —
/// and, as there, nothing about it needed a protocol change.
///
/// **The chunk is the bound and the compression is the saving**, and it matters which is
/// which. Compression is what makes this cheap: the stock tree is 11.34 MB and gzips to
/// 927 KB, so the whole atlas source arrives in about three pages instead of thirty-one.
/// But nothing guarantees that an operator's files compress at all, so the ceiling has to
/// hold when they do not — and it does, because a 512 KiB chunk that refuses to compress
/// is still only ~683 KiB of base64, inside the wire cap that
/// <see cref="BridgeConfig.AssetBatchBytes"/>' deliberate factor of two leaves room for.
/// A design that leaned on the ratio would work on every tree anyone tested and fail on
/// the first one nobody did.
///
/// ── Two rules that are not negotiable here ────────────────────────────────────────
///
/// **1. The label set is this shard's, never the caller's.** This is the only family on
/// this link whose keys look like paths, and the website is the internet-facing component.
/// So nothing here joins a path that arrived on the wire: a fetch resolves its label
/// against the set <see cref="Enumerate"/> itself produced, and a label that is not in it
/// is refused — before any file is opened, and whatever it spells. The five groups are
/// fixed in code, the extensions are fixed in code, and the resolved path is checked to be
/// under the tree root even after all of that.
///
/// **2. A row re-declares its own address.** Each chunk carries its label, its index, its
/// byte offset and the hash of its own (uncompressed) bytes, and the manifest carries the
/// hash of the whole file. That is the §4.10 lesson on a fourth axis: a reassembly that
/// silently put chunk 3 where chunk 4 belongs would produce a file that parses — XML is
/// forgiving about what it skips — and a spawn atlas subtly missing a facet. Per-chunk
/// hashes make it a named error instead.
/// </summary>
public static class BridgeTree
{
/// <summary>The §5 key family this serves.</summary>
private const string Family = "tree";
/// <summary>
/// The five labelled groups `spawnAtlasSource.js` reads, and nothing else.
///
/// Fixed in code rather than configured, because a configurable list is a way for the
/// website to ask for a file this shard never meant to publish. An operator who wants
/// a different tree served wants a different feature.
/// </summary>
private static readonly string[] SingleFiles =
{
"Data/Regions.xml",
"Config/ChampionSpawns.xml"
};
private const string LocationsDir = "Data/Locations";
private const string SpawnsDir = "Spawns";
private const string DecorationDir = "Data/Decoration";
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
// Its own consent, not the asset plane's (§10, phase 7). An operator who declines to
// serve their UO client still gets a spawn atlas, because these are their own files.
BridgeAssets.RegisterFamily(Family, ReplyFetch, ReplyManifest,
() => BridgeConfig.TreeEnabled,
"the shard's configuration tree is not served (Bridge.TreeEnabled is off)");
}
// ── the file set ─────────────────────────────────────────────────────────────────────
private sealed class TreeFile
{
public string Label;
public string Path;
public long Bytes;
public long MTime;
}
/// <summary>
/// Every atlas source file this shard has, tree-relative and forward-slashed.
///
/// The labels are `spawnAtlasSource.js`'s own, character for character, because they
/// are what the website keys its stored fingerprint on: the same tree read here and
/// read there has to produce the same label or every import looks like a change.
/// Forward slashes for the same reason — a Windows shard and a Linux one must agree.
/// </summary>
private static List<TreeFile> Enumerate()
{
string root = Core.BaseDirectory;
var files = new List<TreeFile>();
foreach (string label in SingleFiles)
Add(files, root, label);
foreach (string label in ListByExtension(root, LocationsDir, ".xml"))
Add(files, root, label);
foreach (string label in ListByExtension(root, SpawnsDir, ".xml"))
Add(files, root, label);
foreach (string label in ListTree(root, DecorationDir, ".cfg"))
Add(files, root, label);
return files;
}
private static void Add(List<TreeFile> files, string root, string label)
{
string path = Resolve(root, label);
if (path == null)
return;
try
{
var info = new FileInfo(path);
if (!info.Exists)
return;
files.Add(new TreeFile
{
Label = label,
Path = path,
Bytes = info.Length,
MTime = ToUnixMs(info.LastWriteTimeUtc)
});
}
catch (Exception e)
{
// A file the shard cannot stat is a file it cannot serve. Say so once, here,
// rather than as a refused row on every import pass forever.
Console.WriteLine("[Bridge] tree: cannot read {0}: {1}", label, e.Message);
}
}
/// <summary>One directory's files with the given extension, sorted, as labels.</summary>
private static List<string> ListByExtension(string root, string dir, string extension)
{
var labels = new List<string>();
string full = Path.Combine(root, dir.Replace('/', Path.DirectorySeparatorChar));
try
{
if (!Directory.Exists(full))
return labels;
foreach (string path in Directory.GetFiles(full))
{
string name = Path.GetFileName(path);
if (name.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
labels.Add(dir + "/" + name);
}
}
catch (Exception e)
{
Console.WriteLine("[Bridge] tree: cannot list {0}: {1}", dir, e.Message);
}
labels.Sort(StringComparer.Ordinal);
return labels;
}
/// <summary>
/// One directory tree's files with the given extension, recursively.
///
/// Recursive because `Data/Decoration` nests two deep in places (`Magincia/Trammel`,
/// `Stygian Abyss/Ter Mur`, `Old/Britannia`), and the website's own reader says why
/// that matters: a flat read indexes a third of what the shard has, and the failure is
/// an authoring dropdown quietly missing whole expansions rather than an error anyone
/// would notice.
/// </summary>
private static List<string> ListTree(string root, string dir, string extension)
{
var labels = new List<string>();
string full = Path.Combine(root, dir.Replace('/', Path.DirectorySeparatorChar));
try
{
if (!Directory.Exists(full))
return labels;
foreach (string path in Directory.GetFiles(full, "*", SearchOption.AllDirectories))
{
if (!path.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
continue;
string rel = path.Substring(full.Length).Replace('\\', '/').TrimStart('/');
if (rel.Length > 0)
labels.Add(dir + "/" + rel);
}
}
catch (Exception e)
{
Console.WriteLine("[Bridge] tree: cannot walk {0}: {1}", dir, e.Message);
}
labels.Sort(StringComparer.Ordinal);
return labels;
}
/// <summary>
/// A label to a path on this host, or null if it is not one this shard serves.
///
/// Rule 1 of the class doc lives here. The label has already been matched against the
/// enumerated set by the time a fetch calls this, and this still refuses anything with
/// a traversal segment, a drive or a root in it, and still checks that what
/// <c>Path.GetFullPath</c> produced is under the tree root. Three checks for one rule
/// because the cost of being wrong once is the website reading an arbitrary file off a
/// game server's disk.
/// </summary>
private static string Resolve(string root, string label)
{
if (String.IsNullOrEmpty(label) || label.IndexOf('\\') >= 0)
return null;
string[] segments = label.Split('/');
foreach (string segment in segments)
{
if (segment.Length == 0 || segment == "." || segment == "..")
return null;
}
if (Path.IsPathRooted(label))
return null;
try
{
string rootFull = Path.GetFullPath(root);
string full = Path.GetFullPath(Path.Combine(rootFull,
label.Replace('/', Path.DirectorySeparatorChar)));
if (!rootFull.EndsWith(Path.DirectorySeparatorChar.ToString(CultureInfo.InvariantCulture),
StringComparison.Ordinal))
{
rootFull += Path.DirectorySeparatorChar;
}
return full.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase) ? full : null;
}
catch
{
return null;
}
}
// ── the fingerprint ──────────────────────────────────────────────────────────────────
/// <summary>
/// What the whole tree currently is, in sixteen hex characters.
///
/// The same job <c>BridgeCatalog.SourceId</c> does for client files, and the same
/// reason: it goes on every page of a walk, and a page whose id differs from the
/// first's means the operator edited a spawn file while it was being read. Half of
/// what arrived then describes a tree that no longer exists and nothing later can tell
/// which half, so the website refuses the import outright rather than stitching one.
///
/// Built from (label, size, mtime) rather than from content hashes, because it is
/// computed on every page and hashing the tree's contents each time would spend a
/// tenth of a second per page to answer a question (size, mtime) answers for free.
/// The CONTENT hashes are still sent — once, per file, on the manifest — which is
/// where the website's own drift gate reads them from.
/// </summary>
private static string FingerprintOf(List<TreeFile> files)
{
var sb = new StringBuilder(256);
sb.Append(files.Count);
foreach (TreeFile file in files)
{
sb.Append('|').Append(file.Label)
.Append(':').Append(file.Bytes.ToString(CultureInfo.InvariantCulture))
.Append(':').Append(file.MTime.ToString(CultureInfo.InvariantCulture));
}
return BridgeAssets.Sha256Hex(Encoding.UTF8.GetBytes(sb.ToString())).Substring(0, 16);
}
// ── assets.manifest, for this family ─────────────────────────────────────────────────
/// <summary>
/// Worker thread. Every file this shard would serve, with its size, its content hash
/// and how many chunks it takes — and no bytes.
///
/// That separation is what makes the normal case free. The website stores these
/// hashes; on the next import it asks for this list again, compares, and fetches
/// nothing at all when nothing moved — which on a shard whose maps are not being
/// edited is every import.
///
/// A stock tree is 141 rows and fits in one page comfortably. It pages anyway, by the
/// same envelope as every other family, because the day a shard has three thousand
/// decoration files is not the day to discover this was the one walk that could not
/// end.
/// </summary>
private static void ReplyManifest(string reqId, string cursor)
{
List<TreeFile> files = Enumerate();
string fingerprint = FingerprintOf(files);
int from = ParseCursor(cursor);
if (from < 0 || from > files.Count)
from = 0;
var sb = BridgeJson.Begin("assets.manifest.ok");
sb.Str("reqId", reqId)
.Str("family", Family)
.Str("catalog", fingerprint)
.Num("chunkBytes", BridgeConfig.TreeChunkBytes)
.Num("total", files.Count)
.Num("from", from);
var page = new BridgeAssets.PageBuilder(sb, "rows", BridgeConfig.AssetBatchBytes);
int i = from;
for (; i < files.Count; i++)
{
TreeFile file = files[i];
string hash = HashFile(file.Path);
var item = new StringBuilder(256);
item.Append("{\"key\":");
BridgeJson.Text(item, Family + "/" + file.Label);
item.Append(",\"label\":");
BridgeJson.Text(item, file.Label);
item.Append(",\"bytes\":").Append(file.Bytes.ToString(CultureInfo.InvariantCulture));
item.Append(",\"mtime\":").Append(file.MTime.ToString(CultureInfo.InvariantCulture));
item.Append(",\"chunks\":").Append(
ChunkCount(file.Bytes).ToString(CultureInfo.InvariantCulture));
item.Append(",\"sha256\":");
BridgeJson.Text(item, hash);
item.Append('}');
if (!page.TryAdd(item.ToString(), "t:" + (i + 1).ToString(CultureInfo.InvariantCulture)))
break;
}
page.Close();
sb.Num("sent", page.Count);
BridgeLink.Emit(sb.End());
}
/// <summary>
/// How many chunks a file of this size takes.
///
/// **An empty file is one chunk, not none.** `Data/Locations` can legitimately hold an
/// empty file, and zero chunks would make it a manifest row the website could never
/// fetch: it would wait for content that has no address, and report the import
/// incomplete forever.
/// </summary>
private static int ChunkCount(long bytes)
{
long chunk = BridgeConfig.TreeChunkBytes;
long count = (bytes + chunk - 1) / chunk;
return count < 1 ? 1 : (int)count;
}
// ── assets.fetch, for this family ────────────────────────────────────────────────────
/// <summary>
/// Worker thread. The bytes for an explicit list of chunk keys.
///
/// Chunks are read with a seek rather than by holding the file, so the memory this
/// costs a running game server is one chunk regardless of how large an operator's
/// spawn tables are. A 4 MB file served eight times over is eight seeks and eight
/// 512 KiB reads — cheaper than caching it would be, and with no cache to invalidate
/// when the operator edits it mid-pass.
/// </summary>
private static void ReplyFetch(string reqId, List<string> keys, string expected, string cursor)
{
List<TreeFile> files = Enumerate();
string fingerprint = FingerprintOf(files);
// Shared with every other family on this plane, because an absent fingerprint and an
// empty one have to mean the same thing here and there — see
// `BridgeAssets.CatalogMismatch` for what treating them differently costs.
if (BridgeAssets.CatalogMismatch(expected, fingerprint))
{
// The tree moved between the manifest and this fetch. The same refusal the
// catalogue makes for a patched client, and for the same reason: these keys were
// chosen against a listing that no longer describes what is on disk.
BridgeAssets.Fail(reqId, "UNREADABLE",
"the shard's configuration tree changed since that manifest was read (catalog "
+ expected + " is now " + fingerprint + "); start the import again");
return;
}
var byLabel = new Dictionary<string, TreeFile>(StringComparer.Ordinal);
foreach (TreeFile file in files)
byLabel[file.Label] = file;
int from = ParseCursor(cursor);
if (from < 0 || from > keys.Count)
from = 0;
var sb = BridgeJson.Begin("assets.fetch.ok");
sb.Str("reqId", reqId)
.Str("family", Family)
.Str("catalog", fingerprint)
.Num("chunkBytes", BridgeConfig.TreeChunkBytes)
.Num("asked", keys.Count)
.Num("from", from);
var page = new BridgeAssets.PageBuilder(sb, "rows", BridgeConfig.AssetBatchBytes);
int i = from;
for (; i < keys.Count; i++)
{
string item = Render(byLabel, keys[i]);
if (!page.TryAdd(item, "t:" + (i + 1).ToString(CultureInfo.InvariantCulture)))
break;
}
page.Close();
sb.Num("sent", page.Count);
BridgeLink.Emit(sb.End());
}
/// <summary>
/// One key to one row.
///
/// A key this shard cannot serve is a row rather than a failed request, exactly as in
/// every other family, and `status` keeps the two kinds apart: `absent` is a file this
/// shard does not have (a tree with no `ChampionSpawns.xml` is a normal tree), and
/// `unsupported` is a key shape this family does not serve — which is a website bug,
/// and is counted separately so it cannot hide inside the expected gaps.
/// </summary>
private static string Render(Dictionary<string, TreeFile> byLabel, string key)
{
string label;
int chunk;
if (!ParseKey(key, out label, out chunk))
return Refusal(key, "unsupported", "not a tree chunk key (tree/<label>/c<n>)");
TreeFile file;
if (!byLabel.TryGetValue(label, out file))
{
// Rule 1: the label has to be one THIS shard enumerated. Anything else is refused
// here, before a path is built out of it, whatever it spells.
return Refusal(key, "absent", "this shard does not serve that file");
}
int chunks = ChunkCount(file.Bytes);
if (chunk < 0 || chunk >= chunks)
{
return Refusal(key, "unsupported",
"chunk " + chunk.ToString(CultureInfo.InvariantCulture) + " of "
+ chunks.ToString(CultureInfo.InvariantCulture));
}
long offset = (long)chunk * BridgeConfig.TreeChunkBytes;
byte[] raw;
try
{
raw = ReadChunk(file.Path, offset, BridgeConfig.TreeChunkBytes);
}
catch (Exception e)
{
Console.WriteLine("[Bridge] tree: cannot read {0} chunk {1}: {2}", label, chunk, e.Message);
return Refusal(key, "absent", e.GetType().Name);
}
byte[] packed;
try
{
packed = Gzip(raw);
}
catch (Exception e)
{
Console.WriteLine("[Bridge] tree: cannot compress {0} chunk {1}: {2}", label, chunk, e.Message);
return Refusal(key, "absent", e.GetType().Name);
}
var item = new StringBuilder(packed.Length * 2);
item.Append("{\"key\":");
BridgeJson.Text(item, key);
item.Append(",\"status\":\"ok\",\"label\":");
BridgeJson.Text(item, label);
item.Append(",\"chunk\":").Append(chunk.ToString(CultureInfo.InvariantCulture));
item.Append(",\"chunks\":").Append(chunks.ToString(CultureInfo.InvariantCulture));
item.Append(",\"offset\":").Append(offset.ToString(CultureInfo.InvariantCulture));
item.Append(",\"bytes\":").Append(raw.Length.ToString(CultureInfo.InvariantCulture));
item.Append(",\"sha256\":");
BridgeJson.Text(item, BridgeAssets.Sha256Hex(raw));
item.Append(",\"gzip\":");
BridgeJson.Text(item, Convert.ToBase64String(packed));
item.Append('}');
return item.ToString();
}
private static string Refusal(string key, string status, string reason)
{
var item = new StringBuilder(128);
item.Append("{\"key\":");
BridgeJson.Text(item, key);
item.Append(",\"status\":");
BridgeJson.Text(item, status);
item.Append(",\"reason\":");
BridgeJson.Text(item, reason);
item.Append('}');
return item.ToString();
}
/// <summary>
/// `tree/&lt;label&gt;/c&lt;n&gt;` into its label and chunk index.
///
/// The label itself contains slashes, so the chunk segment is taken off the END rather
/// than by counting segments from the front. That is unambiguous here and not by
/// luck: every label this family serves ends in `.xml` or `.cfg`, so no label's last
/// segment can be spelled `c` followed by digits.
/// </summary>
private static bool ParseKey(string key, out string label, out int chunk)
{
label = null;
chunk = -1;
if (String.IsNullOrEmpty(key))
return false;
string prefix = Family + "/";
if (!key.StartsWith(prefix, StringComparison.Ordinal))
return false;
int slash = key.LastIndexOf('/');
if (slash <= prefix.Length - 1)
return false;
string last = key.Substring(slash + 1);
if (last.Length < 2 || last[0] != 'c')
return false;
for (int i = 1; i < last.Length; i++)
{
if (last[i] < '0' || last[i] > '9')
return false;
}
if (!Int32.TryParse(last.Substring(1), NumberStyles.None, CultureInfo.InvariantCulture, out chunk))
return false;
label = key.Substring(prefix.Length, slash - prefix.Length);
return label.Length > 0;
}
private static int ParseCursor(string cursor)
{
if (String.IsNullOrEmpty(cursor) || !cursor.StartsWith("t:", StringComparison.Ordinal))
return 0;
int value;
return Int32.TryParse(cursor.Substring(2), NumberStyles.None,
CultureInfo.InvariantCulture, out value) ? value : 0;
}
// ── bytes ────────────────────────────────────────────────────────────────────────────
private static byte[] ReadChunk(string path, long offset, int length)
{
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
FileShare.ReadWrite, 1 << 16))
{
long remaining = stream.Length - offset;
if (remaining < 0)
remaining = 0;
if (remaining > length)
remaining = length;
var buffer = new byte[remaining];
stream.Seek(offset, SeekOrigin.Begin);
int filled = 0;
while (filled < buffer.Length)
{
int read = stream.Read(buffer, filled, buffer.Length - filled);
// A short read is not the end of the file here — the length was taken from the
// stream itself. Stopping on one would hand back a chunk whose declared length
// and real length disagree, which the website would only see as a hash
// mismatch on a file it cannot name a cause for.
if (read <= 0)
break;
filled += read;
}
if (filled == buffer.Length)
return buffer;
var exact = new byte[filled];
Buffer.BlockCopy(buffer, 0, exact, 0, filled);
return exact;
}
}
/// <summary>
/// A complete gzip member for exactly one empty chunk.
///
/// **`GZipStream` writes NOTHING for zero bytes of input**, on .NET Framework and on
/// Mono: the gzip header is emitted lazily on the first write, so a stream that is
/// opened and closed without one produces a zero-length buffer rather than the 20-byte
/// empty member. That is not a valid gzip stream, and the reader at the other end says
/// so — `zlib: unexpected end of file`.
///
/// It is not a hypothetical: **stock ServUO 57.4 ships two empty decoration files**
/// (`Felucca/ambitious solen queen quest.cfg` and
/// `Tokuno/terrible hatchlings quest.cfg`), so every import off an untouched tree hit
/// it. Worth knowing how it was found, because it says something about probes: an
/// offline harness reassembled all 141 files and reported success, since .NET's own
/// decompressor treats an empty stream as empty data and the chunk's declared length
/// (0) and hash (of nothing) both agreed with that. Only the live walk, through a
/// reader on a different runtime, disagreed.
///
/// The alternative — letting an empty chunk carry an empty payload and teaching the
/// reader to expect it — was rejected: it puts a special case on the wire, where every
/// future reader has to know it, instead of in the one place that builds the bytes.
/// Header (magic, deflate, no flags, no mtime, no XFL, unknown OS), one empty stored
/// block, then CRC32 and ISIZE of nothing.
/// </summary>
private static readonly byte[] EmptyGzip =
{
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff,
0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
private static byte[] Gzip(byte[] raw)
{
if (raw.Length == 0)
return EmptyGzip;
using (var ms = new MemoryStream())
{
using (var gz = new GZipStream(ms, CompressionMode.Compress, true))
gz.Write(raw, 0, raw.Length);
return ms.ToArray();
}
}
/// <summary>
/// The content hash of one file, streamed.
///
/// Streamed rather than <c>File.ReadAllBytes</c> because this runs once per file per
/// manifest, and a stock tree's spawn files are 10 MB between them: reading them whole
/// would put that much through a game server's large object heap to produce 141 short
/// strings.
/// </summary>
private static string HashFile(string path)
{
try
{
using (var sha = System.Security.Cryptography.SHA256.Create())
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
FileShare.ReadWrite, 1 << 16))
{
var buffer = new byte[1 << 16];
int read;
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
sha.TransformBlock(buffer, 0, read, null, 0);
sha.TransformFinalBlock(buffer, 0, 0);
var sb = new StringBuilder(64);
foreach (byte b in sha.Hash)
sb.Append(b.ToString("x2", CultureInfo.InvariantCulture));
return sb.ToString();
}
}
catch (Exception e)
{
Console.WriteLine("[Bridge] tree: cannot hash {0}: {1}", path, e.Message);
return null;
}
}
private static long ToUnixMs(DateTime utc)
{
return (long)(utc - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds;
}
/// <summary>For `[Bridge] status`, the same one-line shape every other family reports.</summary>
public static string Status()
{
if (!BridgeConfig.TreeEnabled)
return "tree(disabled)";
List<TreeFile> files = Enumerate();
long bytes = 0;
foreach (TreeFile file in files)
bytes += file.Bytes;
return String.Format("tree(files={0} bytes={1} catalog={2})",
files.Count, bytes, FingerprintOf(files));
}
}
}