docs(link): the guide an integrator reads stopped at protocol 6 (Phase 9a)

The Asset Bridge's docs pass, and the acceptance walk that shaped it (v8.md §16
row 9a, §17.13-14). Phase 9 is three legs now: this one, the edge->main cutover,
and the site.

## INTEGRATION.md had stopped at 6 and contradicted itself

Its §2 said "the current version is 6" above examples already carrying
`X-UOLink-Version: 8`, there was no protocol-7 paragraph, and `assets.` appeared
zero times in 1,306 lines. It is the only document an integrator outside this org
has, so it is carried the whole way: the version block corrected, v7 (the event
plane's command half) and v8 (the asset plane) written, a §5 section for the five
routes, 425/422 in the status table, and a caveat that the asset plane is a
working set rather than a stream.

Protocol 7's absence is the Events workstream's debt rather than this one's, but
it cannot be stepped over on the way to 8.

## The operator-facing half

`UPGRADE_NOTES.md` gains the entry an operator reads when this ships: what
changed, the one required action on a Linux host, and the thing that will not
announce itself -- nothing here happens on a restart, so a patched client keeps
serving the old pictures until somebody presses a button.

`installer/INSTALL.md` gains libgdiplus as a prerequisite row and the `doctor`
row that checks it. The index rows for SPAWN_ATLAS and CLILOCS described the
workflows this protocol deleted; v8.md now has an index row of its own, and v7 is
marked as the released protocol.

## The walk

Wiped every asset row and every imported sprite, then walked it as a new
operator: 1,095 portraits in 3.18 s, 67,496 names in 1.42 s, 313 item pictures in
1.38 s, the atlas over the bridge in ~2.0 s, an Update with no drift answered in
0.99 s. Bestiary portraits are the right animals by eye; the marketplace shows
hued item art with cliloc names.

It found two defects (§17.14) and one cutover hazard: module-uo's `edge` is
behind its `main`, missing #35, so the walk measured 0 of 6,455 spawners carrying
a UniqueId. 9b's row says to sync before merging or the cutover ships a
regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-14 13:06:26 -05:00
parent 1f3c728053
commit 57ecdc8c29
5 changed files with 247 additions and 17 deletions

View File

@@ -55,14 +55,14 @@ That is the same set of values Admin → Shard asks for — base URL and WS URL
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
The current version is **6**. It is not released yet — it lives on `edge` and ships with the event system's cutover; the last released pairing is protocol **5**, sidecar **v2.1.0** + overlay **v1.1.0**, resolved as bundle **2026.09.01**, never as "latest of each".
The current version is **8**. It is not released yet — it lives on `edge` and ships with the Asset Bridge's cutover; the last released pairing is protocol **7**, sidecar **v2.2.0** + overlay **v1.2.0**, resolved as bundle **2026.09.10**, never as "latest of each".
- Every response carries an **`X-UOLink-Version: 8`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 6`.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 8`.
- **Optionally**, send `X-UOLink-Version: 8` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
```json
{ "error": "protocol version mismatch", "sidecar_protocol": 6, "client_protocol": "5" }
{ "error": "protocol version mismatch", "sidecar_protocol": 8, "client_protocol": "7" }
```
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
@@ -118,6 +118,41 @@ the work.
Additive again: no existing field changed shape, **no new endpoint**, and a client that sends no key
behaves exactly as it did under v5.
**v7 (Protocol 7)** adds the **event plane's command half** ([`v7.md`](v7.md)) — the verbs a
website-authored event needs in order to happen in the world: spawn a bounded, named, hued set of
creatures; place decoration from the shard's own `Data/Decoration` vocabulary; open a temporary
gate; run an oracle NPC's dialogue; grant an item to a run's participants; lease a property on an
existing spawner; toggle one of ServUO's seasonal events; start a world save. Every one of them is
capped on the shard, ledgered so it can be reverted, and gated by `Bridge.EventsEnabled` — an
operator who has not switched the plane on gets **403**, not a half-applied event.
Additive on the feed, like every bump before it, and the new endpoints require a v7 sidecar. The
one thing to know as a consumer is that these are **world writes**, so they are exactly the calls
v6's `idempotencyKey` exists for: send one, and a retry after a lost acknowledgement is answered by
the original reply instead of spawning the creatures twice.
**v8 (Protocol 8)** adds the **asset plane** ([`v8.md`](v8.md)) — the shard reads its own UO client
files and serves what is in them, so nothing has to be converted on somebody's desktop and no
component but the shard ever needs a copy of the client. It carries four things: creature and
player-body artwork, item and land art on demand, the decompressed cliloc table, and the shard's own
`Spawns/*.xml` tree. See §5's *Client assets* for the routes.
Three properties of this plane are unlike the rest of this API, and a consumer that does not know
them will misread healthy behaviour as failure:
- **The shard serves one asset request at a time**, and says so: `425 Too Early` is *flow control*,
the ordinary answer during an import rather than a rare collision. Back off and retry; do not
treat it as an error and do not abandon a transfer over it.
- **Every response is paged, and the caller drives the paging** — echo the previous reply's
`cursor` until one says `more: false`, then read `cut` to learn why that page was the last. Only
`cut: "end"` means you have the whole thing.
- **A key the shard cannot serve comes back as a row with a `status`, not as a failed request.** A
body this client has no art for is the expected answer for most ghost and gargoyle bodies on a
stock client; failing a page over one would make an import impossible.
Additive again — no existing field changed shape, and a v7 client that never calls `/assets/*` or
`/cliloc` behaves exactly as it did.
**Upgrading a pinned client.** Every bump is an operator-visible hard break in one direction only: a
client still declaring the old number gets a 409 on every protected route and, on the WebSocket, a
closed connection on the `ws.hello` mismatch. So update the pinned version at the same time you
@@ -743,7 +778,7 @@ Full character sheet: stats, all trained skills, worn equipment with flattened i
Field notes:
- `skills[].base` is trained value, `value` includes item/temp bonuses, `cap` is the cap. **Do not assume `base <= cap`** — GM characters can exceed it.
- `equipment[].mods` is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty `{}` for plain items.
- Item names are usually **clilocs**, not strings: use `name` when present, otherwise resolve `cliloc` against a UO cliloc table on the site. **Do not expect the shard to resolve them for you** — on any modern client ServUO's own `Ultima.StringList` cannot read the client's compressed cliloc files, so `VendorSearch.GetItemName` returns `item.Name` and the in-game Vendor Search gump has the same gap. Building that table is a consumer-side job; the website's is described in [`website/CLILOCS.md`](../website/CLILOCS.md).
- Item names are usually **clilocs**, not strings: use `name` when present, otherwise resolve `cliloc` against a UO cliloc table on the site. **Do not expect the shard to resolve them for you per item** — on any modern client ServUO's own `Ultima.StringList` cannot read the client's compressed cliloc files, so `VendorSearch.GetItemName` returns `item.Name` and the in-game Vendor Search gump has the same gap. As of Protocol 8 the shard will hand you the whole table instead: `GET /cliloc` (§5), which is where the website's own comes from ([`website/CLILOCS.md`](../website/CLILOCS.md)).
- `titles` (Protocol 2.0): `selected` is the index into `reward` currently displayed (`-1` if none). `fameKarma`/`skill` are computed display titles, omitted when the character has none. `reward` entries may be a **cliloc number as a string** or a literal string — resolve numeric ones against your cliloc table, same as item names.
- Errors: unknown account → **404** `{"kind":"bridge.error","reason":"unknown account"}`; bad slot → **404**/**400** similarly.
@@ -778,6 +813,90 @@ Every player vendor owned by any character on the account, with held gold and cu
] } ] }
```
### Client assets — artwork, names and the shard's own files (Protocol 8)
Five routes, and they are unlike everything above them: they read the **UO client installed on the
shard host** rather than the live world. Nothing here changes unless an operator patches that
client, so these are the only reads on this API you should cache indefinitely and refresh on an
event you decide, not on a timer. The design is [`v8.md`](v8.md); what follows is what a consumer
needs.
Everything on this plane obeys three rules stated in §2: **425 is flow control**, **the caller
drives the paging** (`more` / `cursor` / `cut`), and **an unserveable key is a row, not a failure**.
```
GET /assets/sources # what the shard's client files currently are
GET /assets/manifest?family=&cursor= # one row per asset: key, hash, size — no pixels
POST /assets/fetch # the bytes, for keys you name
POST /assets/bodies # creature class name → body id
GET /cliloc?lang=&cursor= # the decompressed cliloc table
```
**`GET /assets/sources` is the gate, and you call it first.** It is cheap, it touches no pixels, and
its answer decides whether there is anything to do at all:
```json
{ "kind":"assets.sources.ok", "assetsEnabled":true, "treeEnabled":true,
"extractorVersion":3, "families":["body","land","static","tree"],
"hashing":false, "complete":true, "imaging":{"ok":true},
"files":[ {"name":"anim.mul","size":194950053,"mtime":1778566017000,"sha256":"2d89…"} ],
"more":false, "cut":"end" }
```
- **`files[].sha256` is the whole update story.** Store the set; on the next run, compare. Equal
means the client has not been patched and there is nothing to transfer — which is the normal case
and must cost one round trip, not a re-download.
- **`hashing: true` means "not computed yet", never "changed".** The shard fingerprints a few
hundred megabytes in the background after a restart; a null hash during that window is an absence
of an answer, not an answer of absence.
- **`extractorVersion` is ours, not the client's.** It changes when the shard's *derivation* changes
— a better reader, a different frame — so a bump invalidates stored pictures even though the
client files are byte-identical. Treat a change in it exactly like a changed hash.
- **`families` says what this overlay can serve.** An older overlay answers `["body"]`; asking it
for `static` fails per key, per pass, forever. Read this and say "update your plugin" instead.
- **`imaging.ok: false` is `NO_IMAGING`** — a Linux shard host with no `libgdiplus` cannot decode a
sprite at all ([`SHARD_PREREQS.md`](SHARD_PREREQS.md)). Names and tree files are unaffected: they
have no pixels in them.
**`GET /assets/manifest?family=body`** lists what exists, with a hash and a size and no pixels — so
you can diff it against what you hold and fetch only what moved. Rows carry
`{key, sha256, bytes, width, height, body, action, source}`; `source` is `legacy` or `uop` (which
reader produced the bytes) and `action` is which animation action the thumbnail came from, because a
body with nothing at action 0 is catalogued at the first action that has anything — `body/820/a23`
is a horse. This family pages on the shard's wall clock, so expect several pages of a few hundred
rows.
**`POST /assets/fetch`** takes `{"keys":[…], "catalog":"…", "cursor":"…"}` and returns a row per key
carrying the sprite as **base64 PNG** (the `tree` family returns gzipped chunks instead). Two things
are load-bearing:
- **Pass back the `catalog` id the manifest gave you.** It is derived from the client files
themselves, and it makes the shard refuse (**422**) if those files moved mid-import. Without it,
an operator who patches their client halfway through gets one asset set stitched out of two, with
no error anywhere.
- **A batch must be of one family**, derived from the keys rather than named as a field. Mixing is
refused (400), because a reply carries a single `catalog` and two families have two fingerprints.
**`POST /assets/bodies`** takes `{"types":["GiantSpider", …]}` and answers the one question only code
running inside ServUO can: which body id a creature class actually uses. It runs on the Core thread,
so the shard **caps the batch and refuses rather than truncates** — a 400 here names the cap; chunk
your list.
**`GET /cliloc?lang=enu`** is the client's id → text table, decompressed on the shard and delivered
as rows (`[{n, f, t}]`) inside the ordinary paging envelope. Blank entries are omitted — roughly
56,000 of them — because every consumer discards them anyway. This is the table you resolve
`cliloc` numbers against in `vendor.listing`, `char.profile` equipment and title rows; §6's note
that the shard cannot resolve them for you is **why this route exists**.
Two absences worth stating, because both look like bugs and neither is:
- **A body with no art is normal**, and on a stock client that is most ghost and gargoyle bodies.
The row says so; render text.
- **The shard never sweeps file types looking for a hit.** If the client's own mapping yields
nothing, the answer is nothing — asking the other animation files for the same index returns a
*different creature's* art that reports success, which is a wrong picture nothing downstream can
detect ([`v8.md`](v8.md) §4.6).
---
## 6. REST — commands & history
@@ -1254,13 +1373,13 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
| 200 | OK |
| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) |
| 401 | Missing or invalid auth token |
| 403 | Refused by the operator — the admin write plane, or the event plane (`Bridge.EventsEnabled`), is switched off on the shard |
| 404 | Not found (unknown account / character / id, a not-linked account, an unoffered lease key, or a run the shard is not counting) |
| 403 | Refused by the operator — the admin write plane, the event plane (`Bridge.EventsEnabled`), or the asset plane (`Bridge.AssetsEnabled` / `Bridge.TreeEnabled`), is switched off on the shard |
| 404 | Not found (unknown account / character / id, a not-linked account, an unoffered lease key, a run the shard is not counting, or a client file this install does not have) |
| 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` |
| 425 | Too Early — a command with this `idempotencyKey` is still in flight on the shard (Protocol 6). Nothing ran; retry |
| 425 | Too Early — a command with this `idempotencyKey` is still in flight (Protocol 6), or the shard's single asset slot is held by another `/assets/*` or `/cliloc` call (Protocol 8). Nothing ran; back off and retry |
| 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` |
| 500 | Internal error (e.g. database) |
| 503 | Shard not connected — the query needs the live game and it's down |
| 503 | Shard not connected — the query needs the live game and it's down. On the asset plane it also carries `NO_IMAGING`: the host cannot render images at all |
| 504 | Shard connected but didn't reply within 10s |
`503` vs `404`: a `503` is transient (shard restarting — retry), a `404` is a real "doesn't exist."
@@ -1270,6 +1389,10 @@ deployment fault — your pinned protocol version disagrees with the sidecar's
never help. A `425` is a retry that will succeed on its own. They are deliberately different codes
so a retry loop cannot quietly swallow a mismatched deployment.
`422` exists only on the asset plane, and it means one of two things, both of which are the client
files moving under you: a file the shard cannot decode, or a `catalog` id that no longer describes
what is on disk — the mid-import guard. Start the import again rather than retrying the page.
---
## 8. Putting it together
@@ -1304,3 +1427,8 @@ const sales = await fetch(`${BASE}/history?kind=vendor.sale&limit=20`, { headers
- **Cache freshness.** `GET /char/serial/...` may serve a stale cached profile when the shard is down; the account+slot form always goes live (503 if down).
- **`bootId`** on `server.hello` is your signal to invalidate site-side caches: if it changed, the shard restarted.
- **Protocol changes** bump `X-UOLink-Version`. Compare it on startup and fail fast rather than mis-parsing a newer shape.
- **The asset plane is a working set, not a stream.** `/assets/*` and `/cliloc` read files that only
change when an operator patches the shard's UO client, and the shard serves one such request at a
time. Import on an operator's action or a hash change — never on your own boot, and never on a
timer: a client-file pass costs hundreds of megabytes of hashing to discover that nothing moved,
and it holds the slot every other caller is waiting for.

View File

@@ -1678,10 +1678,10 @@ disagree, so a split bump means the next bundle silently fails to compose.
| `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. 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 |
| `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). Phase 9a added the rest: `INTEGRATION.md` to protocol 8, an operator entry in `UPGRADE_NOTES.md`, the index rows, and the prerequisite + `doctor` row in `installer/INSTALL.md` |
| `installer/` | A `doctor` check for libgdiplus on Linux hosts (§4.4)**built in phase 9a**, Linux-only (a Windows host has nothing to check, so there is no row rather than a row saying "not applicable") and a `⚠` rather than a `✗`, because names and the spawn atlas have no pixels in them. Bundle pairing already enforces §15 |
| `android-app/` | Consumes images by URL; no parity gate expected until a screen shows one |
| `integration-kit/` | A chapter note only — this is UO-specific and teaches nothing about the module contract |
| `integration-kit/` | A chapter note only — this is UO-specific and teaches nothing about the module contract. **Built in phase 9a** as chapter 3 §2b: the *pattern* (the game host already has the files; route them over the channel you already have; request/reply, one at a time, two stages, version your derivation, never on boot) with no contract re-specified |
---
@@ -1698,7 +1698,9 @@ disagree, so a split bump means the next bundle silently fails to compose.
| 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 | **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 | **DONE 2026-09-14.** The admin surface (§12.2): `Admin → Client Files` — one page over all three planes, because they come off one client and change on one event. The cliloc pair had had no UI since phase 2, which on a bridge install meant `curl` was the only way to import 67,496 names. §14's "activity view" is the **last import's own summary** rather than a filtered feed, which kept the phase to one repo (org lead, 2026-09-14). The walk imported **1,095 portraits in 3.5 s, warmed 313 item pictures in 0.6 s and reloaded 67,496 cliloc rows in 1.7 s** against a real shard — and found **two deletions nobody could see before a screen put the numbers together**: the body import diffing its manifest against *every* family's rows, which staged all 313 item pictures for deletion, and an approved vanish that unlinked the sprite and kept the row, so the key came back for review forever. Both fixed here; `EXTRACTOR_VERSION` and the protocol are untouched | module-uo |
| 9 | Docs pass across five repos; live walk on the real rig | docs |
| 9a | **DONE 2026-09-14.** The docs pass and the acceptance walk (§17.13). `INTEGRATION.md` carried to protocol 8 — it had stopped at **6**, contradicted itself, and documented none of this plane; the operator-facing upgrade note; the index rows; the installer's `libgdiplus` `doctor` check and the kit's chapter note, both promised by §15 and built by no phase. The walk wiped every asset row and every imported sprite and went through it as a new operator: **1,095 portraits in 3.18 s, 67,496 names in 1.42 s, 313 item pictures in 1.38 s, the atlas over the bridge in ~2.0 s, and an Update with no drift answered in 0.99 s**. Two defects (§17.14), the larger of them **not ours and released** — Events phase 12b's `unique_id` has been failing every spawn-atlas import on every upgraded install since v1.2.0, which only a walk on a rig whose tables predate it could see | docs, module-uo, installer, integration-kit |
| 9b | The `edge → main` cutover: `link`, `servuo-plugins`, `module-uo`, `website`, in dependency order. **`module-uo`'s `edge` is behind its `main`** — it is missing #35, the fix that keeps the atlas's `UniqueId`, so the walk measured 0 of 6,455 spawners carrying one. Sync `main` into `edge` first or the cutover ships a regression | link, servuo-plugins, module-uo, website |
| 9c | runicgateway.com: `platform.json`'s protocol 7 → 8 and the bundle triple, and any prose the bridge changed. Must follow 9b — `checkFacts.mjs` reads the protocol from `link`'s `main` | runicgateway.com |
Phase 0 exists because §4 chose to call code that can take the shard down if it is wrong, and the
honest way to hold that choice is to try to break it on purpose — in the real host process, against
@@ -1872,3 +1874,42 @@ in the document.
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.
13. **Phase 9's shape and its two inherited obligations — settled 2026-09-14.** Put to the org lead
at the start of the phase, because §16 described phase 9 as a docs pass in one repo and three
things it did not cover had fallen between the phases:
- **Phase 9 is three legs, not one** — `9a` the docs pass plus the acceptance walk on the real
rig, `9b` the `edge → main` cutover across the four code repos, `9c` the runicgateway.com
facts. The cutover was in no phase row at all, and the site cannot move until `link`'s
`main` carries protocol 8 — `runicgateway.com`'s `checkFacts.mjs` reads it from there and goes
red the moment it changes. The Events and Engagement workstreams split their last phase the
same way, for the same reason.
- **The two §15 obligations nobody built are built here**, rather than dropped: the installer's
`doctor` check for `libgdiplus` and the integration kit's chapter note. The doctor check was
the third of the three answers §17.2 took, and `SHARD_PREREQS.md` had been *claiming* it
existed since phase 1.
- **`INTEGRATION.md` is repaired the whole way to 8, protocol 7 included.** It had stopped at 6
and contradicted itself — prose saying "the current version is 6" above examples already
carrying `X-UOLink-Version: 8` — with no protocol-7 paragraph and not one mention of
`assets.*`. Protocol 7's absence is the Events workstream's debt rather than this one's, but
it cannot be stepped over on the way to 8, and the file is the only thing an integrator
outside this org has.
14. **The two defects the phase-9 walk found, and where they ship — settled 2026-09-14.** Both were
put to the org lead with the walk's evidence:
- **A busy shard gets its own sentence, and the panel re-reads once.** The status call exhausts
its 425 backoff whenever something else holds the single slot — an import, or the item-art
warm pass refilling itself — and phase 8's panel rendered that as *"The shard is not answering
for client files"*, the same banner as a shard that is down or switched off, and left it
standing because the page never re-reads. On the rig that banner was up for a quarter of an
hour over a perfectly healthy shard. `BUSY` now says what it is, and one automatic re-read
four seconds later clears the ordinary case; the panel still does not poll.
- **`shard_spawn_points.unique_id` ships on `edge` with this phase** rather than as a hotfix to
`main` (org lead, weighing that it is already released). Events phase 12b added the column to
the `CREATE TABLE` and nowhere else, so it reached fresh installs and no existing one —
`CREATE TABLE IF NOT EXISTS` does not add a column, which is what the twenty-odd
`ADD COLUMN IF NOT EXISTS` lines in that same file exist to do. Every spawn-atlas import on an
upgraded install has failed outright since **v1.2.0** with `Unknown column 'unique_id'`: no
bestiary refresh, no spawn map, no champion altars. The cost of the decision is that operators
on a released v1.2.x keep that until the Asset Bridge cuts over.