diff --git a/public/screens/admin-client-files.webp b/public/screens/admin-client-files.webp
new file mode 100644
index 0000000..73c5484
Binary files /dev/null and b/public/screens/admin-client-files.webp differ
diff --git a/public/screens/marketplace.webp b/public/screens/marketplace.webp
index eb8212f..7d35217 100644
Binary files a/public/screens/marketplace.webp and b/public/screens/marketplace.webp differ
diff --git a/public/screens/spawn-atlas.webp b/public/screens/spawn-atlas.webp
index d82b851..7563b80 100644
Binary files a/public/screens/spawn-atlas.webp and b/public/screens/spawn-atlas.webp differ
diff --git a/scripts/seedDemo.mjs b/scripts/seedDemo.mjs
index c3ecd11..b9161cc 100644
--- a/scripts/seedDemo.mjs
+++ b/scripts/seedDemo.mjs
@@ -14,7 +14,7 @@
* UOLINK_BASE http://127.0.0.1:8080 sidecar REST, written to Admin → Shard
* UOLINK_WS ws://127.0.0.1:8080/ws sidecar WebSocket
* UOLINK_TOKEN (unset) sidecar auth token; skipped when absent
- * UOLINK_PROTOCOL 4 wire protocol to pin — see the note below
+ * UOLINK_PROTOCOL (platform.json) wire protocol to pin — see the note below
*
* ---------------------------------------------------------------------------------------
* WHY THE SEED DRIVES THE API AND NEVER THE DATABASE
@@ -45,6 +45,8 @@
import { readFileSync } from 'node:fs';
+import platform from '../src/data/platform.json' with { type: 'json' };
+
const BASE = (process.env.RG_BASE || 'http://localhost:3000').replace(/\/+$/, '');
const API = `${BASE}/api/v1`;
const ADMIN_USER = process.env.RG_ADMIN_USER || 'demoadmin';
@@ -53,16 +55,21 @@ const DEMO_PASS = process.env.RG_DEMO_PASS || 'DemoReview!2026';
const UOLINK_BASE = process.env.UOLINK_BASE || 'http://127.0.0.1:8080';
const UOLINK_WS = process.env.UOLINK_WS || 'ws://127.0.0.1:8080/ws';
const UOLINK_TOKEN = process.env.UOLINK_TOKEN || '';
-// The pinned wire protocol has to be STATED, not left to the module's default.
+// The pinned wire protocol, read from `platform.json` rather than written down here.
//
-// `module-uo`'s schema fragment still carries `protocol INT NOT NULL DEFAULT 3`, from the
-// protocol-3 cutover; the sidecar on `link` `main` speaks 4. The module handles protocol 4's
-// frames — `guild.roster` and `guild.leave` ingest landed with the Teams cutover — but a
-// FRESH install pins 3, and the sidecar answers a 3 with `409 protocol version mismatch` on
-// every REST call. So a new deployment reads nothing from its shard until somebody edits the
-// number in Admin → Shard. Raised with the org lead rather than patched from here: the fix
-// belongs in `module-uo`, not in this repo's screenshot rig (PLAN.md §13 phase 9).
-const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || 4);
+// It was a literal `4` until the Asset Bridge cutover, with a note explaining that
+// `module-uo` pinned 3 on a fresh install while the sidecar spoke 4, so a new deployment
+// read nothing from its shard until somebody edited the number in Admin → Shard. That debt
+// has since been paid: the module's schema fragment defaults the column to the protocol its
+// build speaks and carries a one-shot migration per bump, so both a fresh install and an
+// upgraded one land on the right number by themselves.
+//
+// What remains is the rig's own reason to state it: this seed points a demo deployment at a
+// sidecar, and if it pins the wrong number every REST call comes back `409`. Reading it from
+// `platform.json` means the number is the one `checkFacts.mjs` verified against `link`'s
+// `main` — so the rig cannot quietly drift two protocols behind the platform again, which is
+// exactly what the literal did.
+const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || platform.protocol);
const DRY = process.argv.includes('--dry-run');
diff --git a/src/config/sidebar.mjs b/src/config/sidebar.mjs
index b7baadd..b954176 100644
--- a/src/config/sidebar.mjs
+++ b/src/config/sidebar.mjs
@@ -43,6 +43,7 @@ export const docsSidebar = [
{ label: 'Message templates', slug: 'docs/administration/message-templates' },
{ label: 'Managing modules', slug: 'docs/administration/managing-modules' },
{ label: 'The shard connection', slug: 'docs/administration/the-shard-connection' },
+ { label: 'Client files', slug: 'docs/administration/client-files' },
{ label: 'Maintenance and upgrades', slug: 'docs/administration/maintenance-and-upgrades' },
{ label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' },
],
@@ -122,6 +123,7 @@ export const plannedSidebar = {
'Message templates',
'Managing modules',
'The shard connection',
+ 'Client files',
'Maintenance and upgrades',
'Troubleshooting',
],
diff --git a/src/content/docs/docs/administration/client-files.mdx b/src/content/docs/docs/administration/client-files.mdx
new file mode 100644
index 0000000..ddb08e9
--- /dev/null
+++ b/src/content/docs/docs/administration/client-files.mdx
@@ -0,0 +1,103 @@
+---
+title: Client files
+description: Creature portraits, item pictures and the game's own name table — where they come from, the one button that imports them, and why nothing here happens on a restart.
+---
+
+import Screenshot from '../../../../components/Screenshot.astro';
+import { Aside } from '@astrojs/starlight/components';
+
+Most of what a game shows you is not text. Ultima Online keeps its creature artwork, its
+item graphics and even its item *names* inside the client files, and a site that cannot read
+them shows a bestiary of words and a marketplace of numbers.
+
+With the `uo` module installed, **Client files** appears in the admin sidebar at
+`/admin/uo/files`. It is where those three things arrive.
+
+
+
+## Where they come from
+
+A ServUO shard cannot boot without a UO client — it resolves one at startup to read the
+world's own data. So the files were already on the shard host, and the shard reads and
+decodes them there, handing the results over the bridge like everything else.
+
+**Nothing is converted on a desktop and nothing is uploaded.** Earlier versions of this
+platform asked an operator to install a third-party tool, build a converter against it and
+copy the output onto the web host. That path is gone.
+
+## Three things, one page
+
+| Section | Fills | How it arrives |
+|---|---|---|
+| **Creature portraits** | The bestiary and the spawn atlas | One picture per creature body, imported as a **set** |
+| **Item and land pictures** | Marketplace listings and character sheets | **One at a time**, shortly after a page asks for one |
+| **Item and title names (clilocs)** | Anywhere an item is named | The whole table at once — tens of thousands of names |
+
+They are one page because they are one job: they come out of one client install, and they
+all change at the same moment — when you patch it.
+
+
+
+## Update, or re-import everything
+
+Every section offers the same pair, and the difference is worth knowing:
+
+- **Update** asks the shard what changed first and transfers only that. When nothing has, it
+ costs one small round trip and answers *"unchanged"*.
+- **Re-import everything** fetches the lot. It is for the case the first cannot see — you
+ restored a backup, or lost the uploads volume, and the database still remembers pictures
+ that are no longer on disk.
+
+Item and land pictures work differently, because there are tens of thousands of item
+graphics times every dye colour and importing them as a set would be absurd. They arrive
+lazily instead. The two buttons there — *Fetch waiting pictures* and *Refresh the ones I
+have* — exist for the two moments waiting is the wrong answer: you have just linked a shard,
+or you have just patched a client.
+
+## When the page says something is wrong
+
+Every one of these is a reported state with a reason, not an error. The site keeps serving
+whatever is already imported in all of them.
+
+| What you see | What it means |
+|---|---|
+| **The shard is busy with another client-file request** | Not a fault. The shard serves one of these at a time, and an import — or the item-picture pass refilling itself — is holding it. It frees itself. |
+| **The shard is not answering for client files** | The ordinary bridge problem: see [The shard connection](/docs/administration/the-shard-connection/). |
+| **…set `AssetsEnabled` on the shard** | The asset plane is switched off in [`Bridge.cfg`](/docs/reference/bridge-cfg/). It is a separate switch on purpose — turning it on is consenting to the website reading this host's client files. |
+| **The shard host cannot render images** | A Linux host with no `libgdiplus`. Names are unaffected, because they have no pixels in them. |
+| **Waiting for you: *n* pictures … no longer offered** | The shard stopped offering artwork this site holds. A deletion is never silent here; it waits for you to approve or dismiss it. |
+
+
+
+## What it will not do
+
+- **It never writes to the game.** Everything on this plane is a read.
+- **It never overwrites your own artwork.** A portrait you drew and named yourself always
+ wins over an imported one.
+- **Creatures with no artwork stay as text.** That is normal rather than a failure — a stock
+ client has no animation for most ghost and gargoyle bodies, and the shard reports nothing
+ rather than guessing. A wrong picture is worse than no picture.
+
+## Canonical documents
+
+[`link/v8.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v8.md)
+is the asset plane's design of record;
+[`link/SHARD_PREREQS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md)
+covers what a shard host needs first, and
+[`website/UPGRADE_NOTES.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/UPGRADE_NOTES.md)
+is what to do on a site that was running before this existed.
diff --git a/src/content/docs/docs/administration/maintenance-and-upgrades.mdx b/src/content/docs/docs/administration/maintenance-and-upgrades.mdx
index ec64545..a167ac1 100644
--- a/src/content/docs/docs/administration/maintenance-and-upgrades.mdx
+++ b/src/content/docs/docs/administration/maintenance-and-upgrades.mdx
@@ -116,6 +116,13 @@ when ServUO needs restarting — it never restarts your shard itself. Because it
**bundle**, the sidecar and the plugin move together and cannot end up disagreeing about the
protocol.
+
+
+## The bestiary has no pictures, or items show numbers
+
+Those come out of the UO client on the shard host, and **nothing imports them on a
+restart** — a button on **Admin → Client files** is the only thing that does. Check that
+page first: it reports why rather than failing.
+
+| What it says | What to do |
+|---|---|
+| Counts are zero and no import is recorded | Press *Update*. On a shard that was linked before this existed, nobody ever has. |
+| *…set `AssetsEnabled` on the shard* | The asset plane is off in `Bridge.cfg`. It is a separate switch on purpose. |
+| *The shard host cannot render images* | A Linux host with no `libgdiplus`. Install it and press *Update* again. Names are unaffected either way. |
+| *The shard is busy with another client-file request* | Not a fault. Something ordinary holds the slot; it frees itself. |
+| Pictures were fine and went blank | Check the uploads volume before anything else — the database still remembers pictures that are no longer on disk, and *Re-import everything* is the button for exactly that. |
+
+Items reading as numbers rather than names is the same page, different section: it means the
+cliloc table has not been imported. See [Client files](/docs/administration/client-files/).
+
## Teams are missing
Check the sync panel on **Admin → Teams** before anything else: *last success: never* with
diff --git a/src/content/docs/docs/architecture/protocol-versions.mdx b/src/content/docs/docs/architecture/protocol-versions.mdx
index 893b863..7f67737 100644
--- a/src/content/docs/docs/architecture/protocol-versions.mdx
+++ b/src/content/docs/docs/architecture/protocol-versions.mdx
@@ -46,28 +46,35 @@ allowed to be chosen independently.
## What a bump obliges
Changing a message shape means editing every side plus the specification. The most recent
-bump touched five repositories:
+bump — **8**, which taught the bridge to carry a game's own client files — touched four
+repositories:
| Repository | What had to change |
|---|---|
-| `servuo-plugins` | The handlers, the caps and switches in `Bridge.cfg`, and `overlay.toml` |
-| `link` | `PROTOCOL_VERSION`, and the endpoints that carry the new commands |
-| `module-uo` | The verbs it declares, their option sources, and the ingest |
-| `website` | Core learned a shape it had not had — a lease aimed at one named target |
+| `servuo-plugins` | The extractors and the decoders they call, the switches and caps in `Bridge.cfg`, and `overlay.toml` |
+| `link` | `PROTOCOL_VERSION`, a cap on how large a line the shard may send, and the endpoints that carry the new commands |
+| `module-uo` | The importers, the admin screen, and the pages that render a picture |
| `docs` | The protocol document and the integration guide |
-The `website` row is the one worth noticing. Core holds no game connection and names no
-game noun, so most protocol bumps do not reach it at all — the two before this one did not.
-This one did, because what changed was not a game *noun* but the shape of a thing core owns
-the ledger for.
+**`website` is not on that list, and its absence is the interesting part.** Core holds no
+game connection and names no game noun, so most protocol bumps do not reach it at all. The
+one before this did, because what changed then was not a game *noun* but the shape of a
+thing core owns the ledger for. This one did not reach core because everything it needed —
+somewhere to put a picture — core already offered every module. Its entire share of eight
+phases of work was a **deletion**: a developer tool it no longer needed.
**A protocol bump can also require a store migration**, because the sidecar persists what it
forwards. That is not automatic, and it has happened once: version 4 added a column to a
-table that already existed. Versions 5, 6 and 7 needed none, because every frame is
+table that already existed. Versions 5, 6, 7 and 8 needed none, because every frame is
persisted whole — a bump that only widens a frame, or adds a kind, or adds a guarantee about
how a command is executed, asks nothing of a store that defines no schema for a frame's
contents. That is the dumb-forwarder property paying for itself.
+Version 8 puts it more sharply still. It is the largest bump this protocol has had, and it
+moves megabytes of artwork rather than events — and it changed **no line** of the sidecar's
+store, because the things it carries are answers to requests rather than events to keep. A
+forwarder that holds no opinion about what it forwards has nothing to migrate.
+
## This is not the module API version
Two different numbers, versioning two different contracts, and confusing them is easy.
@@ -102,9 +109,9 @@ What is worth inheriting is the **shape**:
## Canonical documents
-[`link/v7.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v7.md)
+[`link/v8.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v8.md)
is the current protocol's record, including its cross-repository obligations, and
-[`link/v6.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v6.md)
+[`link/v7.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v7.md)
the one before it;
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
§7 is the wire protocol, and
diff --git a/src/content/docs/docs/architecture/the-bridge.mdx b/src/content/docs/docs/architecture/the-bridge.mdx
index dcafcf3..e616358 100644
--- a/src/content/docs/docs/architecture/the-bridge.mdx
+++ b/src/content/docs/docs/architecture/the-bridge.mdx
@@ -68,7 +68,7 @@ responsibility. The sidecar is a **dumb forwarder** — it makes no access-contr
and holds no policy. Access control and the admin-toggleable visibility scope live on the
**website**, where an administrator can see and change them.
-## Two ways in
+## Three ways in
**Live events** arrive over an outbound **WebSocket** and are routed by the module's ingest
dispatcher. Kinds are handled differently by nature: state-changing kinds update tables,
@@ -77,6 +77,23 @@ than accumulating history.
**Point-in-time reads and commands** go over **REST**, through a client that never throws.
+**Bulk reads** — a game's own client artwork, its string table, its spawn files — are the
+newest and the least obvious. They go over the request/reply path in **pages**, with **one
+request in flight at a time** and a hard cap on how large a single line may be.
+
+
+
Every call carries `Authorization: Bearer ` and an `X-UOLink-Version` header. **A
protocol mismatch fails fast with `409`** rather than being mis-parsed — see [Protocol
versions](/docs/architecture/protocol-versions/).
diff --git a/src/content/docs/docs/getting-started/requirements.mdx b/src/content/docs/docs/getting-started/requirements.mdx
index 233510f..ed24586 100644
--- a/src/content/docs/docs/getting-started/requirements.mdx
+++ b/src/content/docs/docs/getting-started/requirements.mdx
@@ -36,11 +36,20 @@ the [`uo` module](/docs/getting-started/install-a-game-module/) and the installe
|---|---|
| **A working ServUO install** | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. |
| **ServUO {platform.bundle.servuoMin}** *(patch tier only)* | The base install works on any reasonably current ServUO. The optional patch tier is written and tested against stock {platform.bundle.servuoMin}; on any other version it is unsupported, and skipping it still leaves you with a working bridge. |
+| **`libgdiplus`** *(Linux shard hosts only)* | Only needed for **artwork**. ServUO runs under Mono on Linux, and the library it decodes sprites with is a thin layer over this one — in the decode path, not merely the encode. Without it creature portraits and item pictures stay empty, and names and the spawn atlas are unaffected because neither touches a pixel. `sudo apt-get install libgdiplus`; `doctor` checks for it. **Windows hosts need nothing.** |
| **The shard stopped** | `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit. The installer refuses to deploy under a running shard. |
| **Administrator / root** | It writes into system directories and registers a service. |
| **Outbound HTTPS** | To fetch the bundle and its two artifacts. No Gitea account and no git client are needed. |
| **The sidecar on the same host as the shard** | The shard connects to `127.0.0.1:7788`. Splitting them is not supported — that loopback socket *is* the trust boundary for inbound commands. |
+
+