docs(link): what a 4 MB spawn file does to a 1 MiB wire (Phase 7) #242
197
link/v8.md
197
link/v8.md
@@ -1047,7 +1047,7 @@ portable key:
|
||||
| `Config/ChampionSpawns.xml` | 1 |
|
||||
| `Data/Decoration/**` | tree |
|
||||
|
||||
So the shard serves `tree/<label>` → bytes over the same batched request/reply path, and
|
||||
So the shard serves these files over the same batched request/reply path, and
|
||||
`spawnAtlasSource.js` gains a second backend behind its existing interface: **filesystem** (today,
|
||||
kept for same-host installs and for development) or **sidecar** (new, and the default once
|
||||
configured).
|
||||
@@ -1060,6 +1060,154 @@ sidecar a forwarder here too.
|
||||
|
||||
`SERVUO_PATH` and the `spawn_atlas_servuo_path` setting remain, and select the filesystem backend.
|
||||
|
||||
### 10.1 What phase 7 measured: `tree/<label>` → bytes cannot work
|
||||
|
||||
The sentence above said the shard would serve `tree/<label>` → 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`); that file as one base64 row is 5.4 MiB. The reply
|
||||
would be dropped, the request would time out, the import would re-request it, forever — a failure
|
||||
with no error anywhere in it. **Two files on a stock tree are in that state**, and a shard with
|
||||
hand-built spawn tables has more.
|
||||
|
||||
| Label group | Files | Bytes |
|
||||
|---|---|---|
|
||||
| `Spawns/*.xml` | 13 | 10,445,607 |
|
||||
| `Data/Decoration/**/*.cfg` | 120 | 1,278,425 |
|
||||
| `Data/Regions.xml` | 1 | 129,008 |
|
||||
| `Data/Locations/*.xml` | 6 | 37,549 |
|
||||
| `Config/ChampionSpawns.xml` | 1 | 4,838 |
|
||||
| **Total** | **141** | **11,895,427** |
|
||||
|
||||
Three carriages were measured before anything was written:
|
||||
|
||||
| | Wire | Pages | Undeliverable |
|
||||
|---|---|---|---|
|
||||
| Raw base64 | 15.1 MB | 31 | **2 files** |
|
||||
| Whole-file gzip | 1.21 MB | 3 | none *on this tree* |
|
||||
| **512 KiB chunks, each gzipped** | **1.26 MB** | **3** | **none, by construction** |
|
||||
|
||||
**The chunk is the bound and the compression is the saving**, and which is which is the whole
|
||||
decision. Compression is what makes it cheap — the tree gzips 12.5x, so the atlas source arrives in
|
||||
three pages instead of thirty-one. But nothing guarantees an operator's files compress at all, so
|
||||
the ceiling has to hold when they do not: a 512 KiB chunk that refuses to compress is ~683 KiB of
|
||||
base64, still inside the wire cap that §3.3's deliberate factor of two leaves room for. Whole-file
|
||||
gzip works on every tree anyone would test and fails on the first one nobody did — the difference
|
||||
between the second and third rows is entirely a difference in what is *guaranteed*.
|
||||
|
||||
Chunking costs 0.2 of the ratio (12.3x against 12.5x) and adds a depth segment to the key:
|
||||
|
||||
```
|
||||
tree/Spawns/trammel.xml the manifest row — size, hash, chunk count
|
||||
tree/Spawns/trammel.xml/c0 the first 512 KiB of it, gzipped
|
||||
```
|
||||
|
||||
which is §5's scheme doing the same job it does for `body/400/a0/f0`, and needing no protocol change
|
||||
to do it — the second time that has paid for itself (§11.2 was the first).
|
||||
|
||||
### 10.2 It is a family, not two new commands
|
||||
|
||||
§14 planned `tree.manifest` and `tree.fetch` with `/tree/*` REST beside them. Phase 5 had since
|
||||
built a **family registry** on `assets.fetch` — "the command is the transport and the family is a
|
||||
property of the key" — and §5 had already written `tree/Spawns/Trammel.xml` as a key. So phase 7
|
||||
registered a `tree` family instead, and generalised `assets.manifest` the same way, which is that
|
||||
lesson landing one level up.
|
||||
|
||||
What that bought: the single slot, the byte budget, the paging envelope, the key-count ceiling, the
|
||||
`catalog` mid-import guard and the 425 backoff, none of them written twice — **and `link` needed
|
||||
nothing at all**, for the third phase running, because `/assets/manifest?family=` and
|
||||
`POST /assets/fetch` forward verbatim. `tree.manifest`, `tree.fetch` and `/tree/*` are **not built**.
|
||||
|
||||
**It has its own consent, though: `Bridge.TreeEnabled`.** Reusing the command is not the same as
|
||||
reusing the switch. `Bridge.AssetsEnabled` is an operator agreeing that the website may read *their
|
||||
UO client* — art and animations licensed from EA. This is the operator'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 have silently disappeared for an operator who declined the first is their spawn
|
||||
atlas. So the consent check moved from the front of `assets.fetch` into the family lookup, and
|
||||
`assets.sources` now 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.
|
||||
|
||||
### 10.3 The three things the walk checks, and the two defects they found
|
||||
|
||||
Every check is a way this ends in a tree that *looks* imported. XML is forgiving about what it
|
||||
skips, so a mis-assembled spawn file parses cleanly and simply has fewer spawns in it.
|
||||
|
||||
- **Each chunk re-declares its own address** — label, index, offset — and carries the hash of its own
|
||||
uncompressed bytes. The reader places chunks by declared index rather than arrival order.
|
||||
- **The whole file is hashed after reassembly** against the manifest row, which is also the
|
||||
fingerprint the drift gate stores.
|
||||
- **The `catalog` must not move mid-walk**, or the import is refused rather than stitched out of two
|
||||
trees.
|
||||
|
||||
Two defects were found, and *which harness found which* is the part worth keeping.
|
||||
|
||||
**An empty `catalog` is not an absent one.** The shard compared `expected != null`, so a caller
|
||||
sending `""` — a serialisation of "I have no fingerprint to assert" — had every fetch refused, with
|
||||
a sentence naming no catalog at all ("catalog is now 8159778b"). Found by an offline probe that
|
||||
passed one by accident.
|
||||
|
||||
It was **inline in three places** by the time it was found: the body catalogue (phase 3), statics
|
||||
and land (phase 5) and the tree (phase 7). All three now go through one
|
||||
`BridgeAssets.CatalogMismatch`, 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. It 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.
|
||||
|
||||
Both defects are the same kind of thing and worth naming as one: **a value that means "I am not
|
||||
asserting anything" has two spellings on a wire, and a stock install finds whichever one you did not
|
||||
think about.** The plane is now checked against a stock ServUO install as a whole — every family,
|
||||
each asked with a real catalog, an absent one and an empty one:
|
||||
|
||||
```
|
||||
cliloc.table walk 67,496 rows, 12 pages
|
||||
body manifest / fetch 1,095 rows; ok with a real, absent and empty catalog
|
||||
static + land fetch ok with a real, absent and empty catalog
|
||||
static/land carry their OWN catalog art 66a112c1 vs body 323f284f — a cross-family id is refused
|
||||
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 is still refused body, static and tree all 422
|
||||
```
|
||||
|
||||
**`GZipStream` writes nothing for zero bytes of input.** The gzip header is emitted lazily on the
|
||||
first write, so a stream opened and closed without one yields a zero-length buffer rather than the
|
||||
20-byte empty member — which is not a valid gzip stream. **Stock ServUO 57.4 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 treats 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 a different runtime, disagreed.** The overlay now answers a literal empty gzip
|
||||
member; teaching the reader to accept an empty payload was rejected as putting a special case on the
|
||||
wire, where every future reader would have to know it.
|
||||
|
||||
### 10.4 One canonical order, because the parse is order-sensitive
|
||||
|
||||
The atlas parse keeps the **first** item id it sees for a decoration type, and writes `meta.source`
|
||||
in iteration order. The two readers agreed on a stock tree and agreed *by coincidence*: the
|
||||
filesystem reader walks each decoration directory with `localeCompare` while the shard sorts whole
|
||||
relative paths, and those diverge the moment a directory mixes cases.
|
||||
|
||||
So `buildFromFiles` sorts by label, ordinally, once, whatever order the files arrived in — and
|
||||
`PARSER_VERSION` goes **4 → 5**, because that is identical input producing a different answer for a
|
||||
handful of types, which is exactly what that number exists to push through the hash gate. The
|
||||
**source fingerprint also moved to raw bytes** at both ends: hashing decoded text hashes a UTF-8
|
||||
*re-encoding*, which is identical for valid UTF-8 and different for a file that is not, and one
|
||||
Latin-1 character in a creature name would have made the drift gate report a change on every import
|
||||
forever with the tree untouched.
|
||||
|
||||
This was found by the live walk too — the unit parity test used `deepEqual`, which ignores key
|
||||
order. It now asserts serialised equality as well.
|
||||
|
||||
### 10.5 Measured end to end
|
||||
|
||||
Against a live ServUO shard, the real sidecar and module-uo's own reader:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Manifest | 141 rows, one page, 32 KB, **65-92 ms** |
|
||||
| Full read | 158 chunks, 3 pages, **1.33 MB on the wire**, **512 ms** |
|
||||
| Verification | all 141 files byte-identical to the tree on disk |
|
||||
| Atlas parity | **identical** — 6,455 points, 800 creatures, 387 regions, 558 landmarks, 25 champions, 309 decoration types, built from the bridge and from the disk |
|
||||
| Drift re-check | 141 rows, **~70 ms**, no file bytes |
|
||||
| Traversal | `tree/../../Scripts/...`, `tree/Config/Bridge.cfg`, `tree/Saves/Accounts/accounts.xml` → all `absent` |
|
||||
|
||||
---
|
||||
|
||||
## 11. What is bulk and what is on demand
|
||||
@@ -1369,13 +1517,13 @@ new pipe.
|
||||
| `assets.fetch` | `assets.fetch.ok` | Content for an explicit key list, paged; base64 PNG per row | **phase 3** (`source` phase 4; `static`/`land` families phase 5) |
|
||||
| `assets.bodies` | `assets.bodies.ok` | Slug → body id (§8, Core thread) | **phase 3** |
|
||||
| `cliloc.table` | `cliloc.table.ok` | The decompressed table, paged (`?lang=`, `?cursor=`) | **phase 2** |
|
||||
| `tree.manifest` / `tree.fetch` | `.ok` | §10, the ServUO tree files | phase 7 |
|
||||
| ~~`tree.manifest` / `tree.fetch`~~ | — | §10's ServUO tree files — **not built**; they are a `tree` family on the two commands above (§10.2) | phase 7 |
|
||||
|
||||
Every one of them is refused outright when `Bridge.AssetsEnabled` is off, and every one of them
|
||||
requires a `reqId` — a request without one is refused rather than answered, because an uncorrelated
|
||||
reply is by definition an event, and §3.1 is the reason none of this may ever be one.
|
||||
|
||||
**Sidecar REST** mirrors those one for one under `/assets/*`, `/cliloc`, `/tree/*`, carrying
|
||||
**Sidecar REST** mirrors those one for one under `/assets/*` and `/cliloc`, carrying
|
||||
`X-UOLink-Version: 8` and forwarding verbatim. `GET /assets/sources` exists as of phase 1 and
|
||||
`GET /cliloc` as of phase 2; their responder maps `bridge.busy` to **425** (flow control, and the
|
||||
ordinary answer mid-import rather than a rare one) and reads the refusal's `code` for the rest —
|
||||
@@ -1409,6 +1557,21 @@ discovers the gap as a refusal *per key, per pass, forever*, with no picture eve
|
||||
warning in the log every few minutes. With it, that is one reported state carrying a sentence naming
|
||||
the fix.
|
||||
|
||||
**Phase 7 added a family, a switch and no command** (§10.2). The ServUO tree is served as the
|
||||
`tree` key family on `assets.fetch`, and `assets.manifest` became family-dispatched the way
|
||||
`assets.fetch` did in phase 5 — so the tree lists its files through the same envelope the body
|
||||
catalogue lists its bodies through. Additive, so **the protocol stays 8**, and `EXTRACTOR_VERSION`
|
||||
stays **3**: this family derives nothing, it forwards an operator's own file unchanged, so the
|
||||
number that tracks *our derivation* has nothing to say about it. `link` needed nothing for the
|
||||
third phase running.
|
||||
|
||||
Two things about it are not shared with the asset families. It answers to **`Bridge.TreeEnabled`**
|
||||
rather than `Bridge.AssetsEnabled`, because a UO client and a shard's own configuration are
|
||||
different consents; the gate therefore moved out of the front of `assets.fetch` and into the family
|
||||
lookup, and `assets.sources` now answers whenever either plane is enabled, with `families` filtered
|
||||
to what is actually on. And its rows carry `gzip` rather than `png`, chunked — see §10.1 for the
|
||||
measurement that forced that and §10.3 for the two defects the checks around it caught.
|
||||
|
||||
**Phase 6 added one field and no command.** Manifest and fetch rows carry `action` — which action
|
||||
of the body the thumbnail came from (§11.2). Additive, so **the protocol stays 8**; a consumer that
|
||||
ignores it sees the catalogue it always saw plus 73 rows, and one that reads it can build the right
|
||||
@@ -1445,7 +1608,7 @@ disagree, so a split bump means the next bundle silently fails to compose.
|
||||
|---|---|
|
||||
| `servuo-plugins/` | Extraction over ServUO's own `Ultima` (§4), the cliloc decompressor (§9), body resolution (§8), the request handlers, `overlay.toml` |
|
||||
| `link/` | Six command families forwarded, the REST surface, **the inbound line cap (§3.3)**, `PROTOCOL_VERSION`. **Nothing in phases 4 or 5** — `assets_call` forwards a request body verbatim and `respond_assets` returns the reply verbatim, so a new key family and a new reply field both pass through untouched |
|
||||
| `module-uo/` | Client calls, asset store, the atlas source backend (§10), cliloc ingest, admin surface |
|
||||
| `module-uo/` | Client calls, asset store, the atlas source backend (§10), cliloc ingest, admin surface. Phase 7 also bumped `PARSER_VERSION` 4 → 5 (§10.4) |
|
||||
| `website/` | Almost none — `ctx.uploads` already suffices (§12). Phase 2 deleted `server/tools/cliloc-export/`, the converter this protocol retires |
|
||||
| `docs/` | This file; rewrite `CLILOCS.md` §Converting and `SPAWN_ATLAS.md` §Artwork + §Configuring; **delete `UOFIDDLER.md`**; add the libgdiplus prerequisite to `SHARD_PREREQS.md` (§4.4) |
|
||||
| `installer/` | A `doctor` check for libgdiplus on Linux hosts (§4.4). Bundle pairing already enforces §15 |
|
||||
@@ -1465,7 +1628,7 @@ disagree, so a split bump means the next bundle silently fails to compose.
|
||||
| 4 | **DONE 2026-09-11.** The UOP animation decoder (§4.3, §4.9): `BridgeUop` + a PNG encoder that never touches `System.Drawing`, wired in beneath the legacy reader. **Two of the eight player bodies turned out to exist** (gargoyles 666/667); the other six are in no client file, and ghost ids left the player-body set (§5.2, §17.9). The same fallback added **233 other bodies**: the catalogue is **1,022 rows, 1,409 ms cold**, and all six player bodies have art for the first time. `EXTRACTOR_VERSION` 1 → 2 | servuo-plugins |
|
||||
| 5 | **DONE 2026-09-11.** Item statics and land on demand (§11.1): the `static` and `land` families, hue applied on the shard from `tiledata.mul`, the byte-bounded art cache, `assets.fetch` made family-aware, `families` on `assets.sources`. Website side: the warm pass, per-row `catalog` staleness, and pictures on the marketplace and the character sheet. **39,189 statics and 4,244 land tiles served; the only refusals are the 9,963 + 12,140 empty index slots §4.5 predicted.** Two traps found — the library's bitmap cache poisons a hued sprite, and `PartialHue` decides the picture from a file only the shard has. Protocol stays 8; `EXTRACTOR_VERSION` stays 2 | servuo-plugins, module-uo |
|
||||
| 6 | **DONE 2026-09-11, and not what this row said.** The measurement came first and changed the phase: a complete one-direction animation set is **174,453 frames / 281.5 MB**, not the ~119,000 estimated, and **the site displays still pictures** — so the deep keys and the bulk-fill switch were **not built** (§11.2, org lead 2026-09-11). What shipped is what the still-picture site was missing: the **73 bodies with no art at action 0 and real art deeper** (a horse at `body/820/a23`), the catalogue key carrying its action, the atlas join that reads it, and §4.10's per-body **action ceiling** — without which the fallback walk itself would serve **452 validated pictures of the next body**. Catalogue **1,022 → 1,095**; `EXTRACTOR_VERSION` 2 → 3; protocol stays 8 | servuo-plugins, module-uo |
|
||||
| 7 | The atlas over the sidecar (§10); shared-filesystem requirement retired | module-uo |
|
||||
| 7 | **DONE 2026-09-14.** The atlas over the sidecar (§10); shared-filesystem requirement retired. The measurement came first again and changed the shape: `tree/<label>` → bytes **cannot work** — `Spawns/trammel.xml` is 4.03 MB against a 1 MiB line cap — so a file crosses as **512 KiB chunks, each gzipped**, which is §5's depth scheme paying for itself a second time (§10.1). It is a `tree` **family** on `assets.fetch` rather than §14's separate commands, with `assets.manifest` generalised to match and its **own consent, `Bridge.TreeEnabled`** (§10.2) — so `link` needed nothing for the third phase running. **141 files / 11.9 MB / 158 chunks / 3 pages / 1.33 MB on the wire / 512 ms**, and the atlas built over the bridge is identical to the one built off the disk. Two defects, each found by a different harness: an empty `catalog` refusing every fetch, and `GZipStream` emitting **nothing** for the two empty files stock ServUO ships (§10.3). `PARSER_VERSION` 4 → 5 for one canonical read order (§10.4); protocol stays 8; `EXTRACTOR_VERSION` stays 3 | servuo-plugins, module-uo |
|
||||
| 8 | Admin surface, Import/Update, approve/reject, activity log | module-uo |
|
||||
| 9 | Docs pass across five repos; live walk on the real rig | docs |
|
||||
|
||||
@@ -1617,3 +1780,27 @@ in the document.
|
||||
would otherwise hit it: one action past a body's band is 643 validated pictures and 452
|
||||
byte-identical copies of the next body.
|
||||
|
||||
12. **§10: the atlas's three shapes — settled 2026-09-14, phase 7.** Put to the org lead after the
|
||||
tree was measured and before a line was written, because the first measurement said the phase as
|
||||
written could not work:
|
||||
|
||||
- **A file crosses as 512 KiB chunks, each gzipped.** Not raw base64 (15.1 MB, 31 pages, and two
|
||||
files on a *stock* tree that can never arrive under a 1 MiB line cap), and not whole-file gzip
|
||||
— which is smaller on this tree and bounded by nothing, so it works everywhere anyone would
|
||||
test and fails on the first tree nobody did. The chunk is the guarantee; the compression is
|
||||
only the saving. See §10.1.
|
||||
- **It is a `tree` family on `assets.fetch`, with its own switch.** §14's separate `tree.*`
|
||||
commands and `/tree/*` REST are not built: phase 5's family registry already owns the single
|
||||
slot, the paging envelope, the key ceiling and the mid-import guard, and reusing it left
|
||||
`link` with nothing to do for the third phase running. But the *consent* is its own —
|
||||
`Bridge.TreeEnabled` — because an operator declining to serve their EA-licensed client is not
|
||||
the same as declining to serve the spawn files they wrote, and the atlas would have been the
|
||||
casualty. See §10.2.
|
||||
- **Boot never calls the shard; importing is an admin action** (departing from the
|
||||
recommendation). The same answer §17.7 gave the cliloc table, and the same reasoning, with one
|
||||
thing accepted in exchange: an install whose atlas comes over the bridge has *no* automatic
|
||||
refresh at all, so an edited spawn file stays invisible until someone presses Import. The
|
||||
alternative — one manifest round trip per boot, ~70 ms and no file bytes — was on the table and
|
||||
was declined in favour of consistency. The panel and the CLI say so in as many words, and the
|
||||
skip is logged rather than silent.
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Spawn atlas
|
||||
|
||||
**Status:** Complete on `edge` — data pipeline in website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112), API + pages in website [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113).
|
||||
**Design:** [`docs/link/v3.md` §6](../link/v3.md) — Protocol 3.0 Part C.
|
||||
The source files stopped needing a shared filesystem in Protocol 8 phase 7.
|
||||
**Design:** [`docs/link/v3.md` §6](../link/v3.md) — Protocol 3.0 Part C;
|
||||
[`docs/link/v8.md` §10](../link/v8.md) — the sources over the bridge.
|
||||
|
||||
The spawn atlas is a browsable catalogue of what the shard *contains*: which
|
||||
creatures spawn, where, how many, and which champion altars are configured. It
|
||||
@@ -25,10 +27,13 @@ exactly the same treatment as a stock one, with no code change.
|
||||
|
||||
The atlas is **static shard content, not live shard state.**
|
||||
|
||||
- It does **not** come from the sidecar. Nothing here touches the bridge, and
|
||||
there is no event kind, no wire change and no `PROTOCOL_VERSION` bump for it.
|
||||
Part C is website-only.
|
||||
- It stays fully populated while the shard is down.
|
||||
- It stays fully populated while the shard is down. Nothing here is an event,
|
||||
nothing subscribes, and no live feed feeds it.
|
||||
- Its source files now *travel* over the sidecar (Protocol 8 phase 7, below), but
|
||||
only when an admin asks — on the request/reply path, never the event path.
|
||||
Until Protocol 8 this section said the atlas never touched the bridge at all,
|
||||
which was true and was bought at a price: the website had to be able to read
|
||||
the shard's filesystem.
|
||||
- Its champion table (`shard_champion_spawns`) is the *configured roster* —
|
||||
"there is an Unholy Terror altar in Deceit". The live `champ.update` feed in
|
||||
`shard_champs` is the separate, sidecar-fed answer to "it is on level 3 right
|
||||
@@ -37,25 +42,71 @@ The atlas is **static shard content, not live shard state.**
|
||||
Routes live at `/api/v1/public/atlas`, deliberately **not** under `/shard`,
|
||||
because `/shard/*` means sidecar-dependent.
|
||||
|
||||
## Configuring the tree
|
||||
## Where the source files come from
|
||||
|
||||
The website needs to be able to *read* the ServUO tree — same host, a bind mount,
|
||||
or a shared volume. Two ways to point at it, the setting winning over the
|
||||
environment:
|
||||
Two ends, and **the shard wins whenever uo-link is configured and enabled**:
|
||||
|
||||
| Source | Notes |
|
||||
| Source | When it is used |
|
||||
|---|---|
|
||||
| `spawn_atlas_servuo_path` setting | Admin-editable; changes take effect on the next refresh without a redeploy |
|
||||
| **The shard, over uo-link** (Protocol 8 phase 7) | Whenever a shard is linked and enabled. Nothing to configure — the sidecar connection the site already has is the whole setup |
|
||||
| A local ServUO tree | When there is no shard link: development, and same-host installs. Also a one-off `--servuo <path>`, which is an instruction and overrules the bridge |
|
||||
|
||||
With neither the atlas is simply skipped — the site runs normally without one.
|
||||
|
||||
**Why this changed.** Reading a ServUO tree required the website to have
|
||||
filesystem access to the shard — "same host, a bind mount, or a shared volume" —
|
||||
and that was the one place the platform's own rule (only the sidecar bridges the
|
||||
shard) was broken, by the component that faces the internet. The shard now serves
|
||||
the same five labelled groups over the same request/reply path as every other
|
||||
shard read, and the parsers did not move: `spawnAtlasParse.js` is still pure,
|
||||
still fs-free, and still covered by CI without a ServUO tree anywhere near it.
|
||||
|
||||
The local path remains, and remains configurable two ways, the setting winning
|
||||
over the environment:
|
||||
|
||||
| Setting | Notes |
|
||||
|---|---|
|
||||
| `spawn_atlas_servuo_path` | Admin-editable; changes take effect on the next refresh without a redeploy |
|
||||
| `SERVUO_PATH` env var | The deploy-time default, since the path usually describes a mount the deployment sets up |
|
||||
|
||||
With neither set the atlas is simply skipped — the site runs normally without
|
||||
one.
|
||||
### What crosses the wire, and what it costs
|
||||
|
||||
A stock 57.4 tree is **141 files and 11.34 MB**, and a spawn file is the awkward
|
||||
part: `Spawns/trammel.xml` alone is 4.03 MB against the sidecar's **1 MiB inbound
|
||||
line cap**. So a file crosses as **chunks of 512 KiB, each gzipped** —
|
||||
`tree/Spawns/trammel.xml/c0` and so on, which is the same key-depth scheme the
|
||||
asset families use.
|
||||
|
||||
Measured end to end against a live shard and sidecar:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Files / bytes | 141 / 11,895,427 |
|
||||
| Chunks / pages | 158 / 3 |
|
||||
| On the wire | **1.33 MB** (the tree gzips ~12.5x) |
|
||||
| Full import | **~0.5 s** |
|
||||
| "Has anything changed?" | one manifest call, ~32 KB, **~70 ms** — no file bytes at all |
|
||||
|
||||
The shard serves this under its own switch, **`Bridge.TreeEnabled`**, separate
|
||||
from `Bridge.AssetsEnabled`. The asset switch is an operator consenting to the
|
||||
website reading their *UO client*; this one is about the shard's *own
|
||||
configuration*, which they wrote. An operator can decline the first and still
|
||||
publish a spawn atlas.
|
||||
|
||||
## The boot path
|
||||
|
||||
On every start the server hashes the source files and compares them against what
|
||||
is loaded. Unchanged (the normal case on a restart) costs one read pass, ~120 ms,
|
||||
and no database write. A real change costs a ~400 ms parse and a reload.
|
||||
**On the bridge, boot imports nothing.** A local tree hashes in ~120 ms and
|
||||
skips; asking the shard would put a sidecar round trip in the boot sequence to
|
||||
answer a question whose answer is "no" on every restart that did not follow a map
|
||||
edit. Editing spawn files is an operator action, so importing is one too:
|
||||
**Admin → Spawn Atlas → Import now**, or the CLI. Whatever atlas is loaded keeps
|
||||
serving until then. (Identical reasoning, and the same decision, as the cliloc
|
||||
table — see [`CLILOCS.md`](CLILOCS.md).)
|
||||
|
||||
From a local tree it behaves as it always has: on every start the server hashes
|
||||
the source files and compares them against what is loaded. Unchanged (the normal
|
||||
case on a restart) costs one read pass, ~120 ms, and no database write. A real
|
||||
change costs a ~400 ms parse and a reload.
|
||||
|
||||
Two contracts govern it:
|
||||
|
||||
@@ -102,12 +153,13 @@ npm run atlas:import -- --reject # keep the current atlas, dismiss it
|
||||
|
||||
## The CLI
|
||||
|
||||
The server refreshes itself on boot, so this is for applying a map change
|
||||
*without* a restart, and for the approve/reject flow above.
|
||||
On a linked shard this and the admin panel are the *only* ways an import happens.
|
||||
From a local tree the server also refreshes itself on boot, so there it is for
|
||||
applying a map change *without* a restart, and for the approve/reject flow above.
|
||||
|
||||
```bash
|
||||
npm run atlas:import # import if the tree differs
|
||||
npm run atlas:import -- --servuo <path> # override the path for this run
|
||||
npm run atlas:import # import if the source differs
|
||||
npm run atlas:import -- --servuo <path> # read this local tree for this run
|
||||
npm run atlas:import -- --force # reimport even if unchanged
|
||||
```
|
||||
|
||||
@@ -117,12 +169,23 @@ one-off import.
|
||||
|
||||
## Sources
|
||||
|
||||
These five groups are the whole of it, at both ends: they are what the
|
||||
filesystem reader walks and they are the only labels the shard will serve.
|
||||
|
||||
| File | Count (stock ServUO 57.4) | Used for |
|
||||
|---|---|---|
|
||||
| `Spawns/*.xml` | 13 files, ~10.5 MB | Every spawner: location, size, delays, time-of-day, creature types |
|
||||
| `Spawns/*.xml` | 13 files, 10.4 MB | Every spawner: location, size, delays, time-of-day, creature types |
|
||||
| `Data/Regions.xml` | 129 KB, nested | Named regions and their rectangles |
|
||||
| `Data/Locations/*.xml` | 6 files | Landmarks (dungeon levels, town markers) |
|
||||
| `Data/Locations/*.xml` | 6 files, 37 KB | Landmarks (dungeon levels, town markers) |
|
||||
| `Config/ChampionSpawns.xml` | 4.8 KB | Configured champion altars |
|
||||
| `Data/Decoration/**/*.cfg` | 120 files, 1.28 MB, nested two deep | The decoration vocabulary world authoring offers |
|
||||
|
||||
A fetch for anything outside them is refused by name, and **the shard never joins
|
||||
a path that arrived on the wire** — a request names a label the shard itself
|
||||
enumerated, or it is answered `absent`. Two of those 120 decoration files are
|
||||
**zero bytes** on a stock tree, which is worth knowing because it broke the first
|
||||
implementation: .NET's `GZipStream` emits nothing at all for zero bytes of input,
|
||||
which is not a valid gzip stream.
|
||||
|
||||
**A stock tree has 13 spawn files but only 6 facets.** `Eodon.xml`,
|
||||
`GravewaterLake.xml`, `TreasuresOfKotl.xml` and the other named-area files hold
|
||||
@@ -315,10 +378,27 @@ whatever an older build derived, indefinitely, and a deploy that corrects the
|
||||
parse would never reach the data. A version mismatch counts as drift, so the
|
||||
correction lands on the next boot without an operator having to know it happened.
|
||||
|
||||
It is **5** as of Protocol 8 phase 7: the source files are now parsed in one
|
||||
canonical label order whichever end read them. That matters because the parse is
|
||||
order-sensitive in one place — the decoration index keeps the first item id it
|
||||
sees for a type, and the two readers sorted a nested directory differently, so the
|
||||
same tree could yield a different preview graphic depending on how it arrived.
|
||||
Identical files, a different answer for a handful of types: precisely what this
|
||||
number exists to push through the hash gate.
|
||||
|
||||
The **source fingerprint is taken over raw bytes** at both ends for the same
|
||||
reason. Hashing the decoded text would hash a UTF-8 *re-encoding* of the file —
|
||||
identical for valid UTF-8, and different for a file that is not, because an
|
||||
undecodable byte becomes U+FFFD and never comes back. One Latin-1 character in a
|
||||
creature name would then fingerprint differently depending on which end read it,
|
||||
and the drift gate would report a change on every import, forever, with the tree
|
||||
untouched.
|
||||
|
||||
## The API
|
||||
|
||||
Everything is served from MariaDB. Nothing on this path touches the sidecar, so
|
||||
the pages stay complete while the shard is down — which is why the routes sit at
|
||||
Everything is served from MariaDB. Nothing on the *read* path touches the sidecar
|
||||
(the source files reach the database at import time and stay there), so the pages
|
||||
stay complete while the shard is down — which is why the routes sit at
|
||||
`/api/v1/public/atlas` and **not** under `/public/shard`, where a prefix means
|
||||
"sidecar-dependent". Unlike `/shard/*`, they *are* `siteMode`-gated, like
|
||||
`/posts` and `/wiki`: a bestiary is site content and follows site content's rules.
|
||||
|
||||
Reference in New Issue
Block a user